Skip to content

Commit 9566960

Browse files
committed
Correct false claims about sends, saves and other actions (#492)
Extend #407's false-claim correction from browser clicks to the other action tools with a clear outcome: send email/message, file write, calendar create, Home Assistant calls. Outcome checks (#165/#380) are reused through code-recorded action_calls; the same conservative rule applies: never correct a true reply, and when unsure, leave it alone. Voice and channel replies are covered via the same final text.
1 parent 52c7e74 commit 9566960

4 files changed

Lines changed: 448 additions & 8 deletions

File tree

‎changes/492.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**Sentient now corrects itself when it claims a send, save, booking or home action happened but it didn't.** If an email, message, file write, calendar event or smart-home action was declined, failed, or never attempted, the reply says so plainly instead of claiming it worked. True reports are never corrected, and voice and channel replies get the same correction.

‎sentient/agent/loop.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,45 @@ def browser_click_history(rows: list[dict]) -> list[dict]:
343343
return out
344344

345345

346+
def action_call_history(rows: list[dict], registry) -> list[dict]:
347+
"""Completed action-tool outcomes in stored chat history, oldest first (#492)."""
348+
calls: dict[str, dict] = {}
349+
out: list[dict] = []
350+
for row in rows:
351+
if row.get("role") == "assistant":
352+
for call in _normalize_tool_calls(row.get("tool_calls")):
353+
tool = registry.get(call["function"]["name"]) if registry is not None else None
354+
kind = outcomes.kind_of(tool)
355+
if kind in outcomes.KINDS:
356+
calls[call["id"]] = {
357+
"kind": kind,
358+
"targets": outcomes.targets_of(
359+
kind, _arguments(call["function"].get("arguments"))
360+
),
361+
}
362+
continue
363+
if row.get("role") != "tool":
364+
continue
365+
call = calls.pop(str(row.get("tool_call_id") or ""), None)
366+
if call is None:
367+
continue
368+
try:
369+
res = json.loads(str(row.get("content") or ""))
370+
except (TypeError, ValueError):
371+
res = {}
372+
declined = isinstance(res, dict) and bool(res.get("declined"))
373+
err = _error_text(res)
374+
call.update(
375+
declined=declined,
376+
# the check semantics: no problem found means it worked (#165); a terse stored result
377+
# errs toward never correcting a true reply
378+
ok=not declined and not err and outcomes.result_problem(res) is None,
379+
uncertain=not declined and bool(err),
380+
)
381+
out.append(call)
382+
return out
383+
384+
346385
def is_tool_format_error(exc: Exception) -> bool:
347386
"""True when the provider rejected the request because the model cannot do tool calling
348387
(e.g. an old Ollama model template emitting malformed tool calls)."""
@@ -442,6 +481,11 @@ class LoopResult:
442481
browser_clicks: list[dict] = field(default_factory=list)
443482
# completed browser clicks from earlier chat turns, consulted only for an explicit reference to one (#402)
444483
browser_click_history: list[dict] = field(default_factory=list)
484+
# action calls with a clear outcome attempted in this run, recorded by code so a final reply cannot invent
485+
# one (#492): send email/message, file write, calendar create, Home Assistant calls
486+
action_calls: list[dict] = field(default_factory=list)
487+
# completed action calls from earlier chat turns, consulted only for an explicit reference to one (#492)
488+
action_call_history: list[dict] = field(default_factory=list)
445489

446490

447491
def _hosts(raw: Any) -> set[str]:
@@ -978,6 +1022,14 @@ def interrupted() -> bool:
9781022
correction = f"\n\nCorrection: {note}"
9791023
text_acc += correction
9801024
yield TextDelta(text=correction, **ev)
1025+
note = outcomes.unmade_action_note(
1026+
text_acc, result.action_calls, result.action_call_history
1027+
)
1028+
# the model may copy an earlier correction from the chat into its reply: one is enough (#488)
1029+
if note and f"Correction: {note}" not in text_acc:
1030+
correction = f"\n\nCorrection: {note}"
1031+
text_acc += correction
1032+
yield TextDelta(text=correction, **ev)
9811033
result.text = text_acc
9821034
result.messages = messages
9831035
return
@@ -1116,6 +1168,12 @@ def interrupted() -> bool:
11161168
correction = f"\n\nCorrection: {note}"
11171169
result.text += correction
11181170
yield TextDelta(text=correction, **ev)
1171+
note = outcomes.unmade_action_note(result.text, result.action_calls, result.action_call_history)
1172+
# the model may copy an earlier correction from the chat into its reply: one is enough (#488)
1173+
if note and f"Correction: {note}" not in result.text:
1174+
correction = f"\n\nCorrection: {note}"
1175+
result.text += correction
1176+
yield TextDelta(text=correction, **ev)
11191177
result.messages = messages
11201178

11211179
async def _model_stream(
@@ -1551,6 +1609,9 @@ async def _record(
15511609
check = await self._check_outcome(p, res, is_error, ctx) if ran else None
15521610
if check is not None:
15531611
result.checks.append(check)
1612+
if (action := outcomes.action_call(p.tool, tc.arguments, res, ran=ran, declined=declined,
1613+
is_error=is_error, err=err, check=check)) is not None:
1614+
result.action_calls.append(action) # the run's own record, so a reply cannot invent the action (#492)
15541615
if (is_error or err) and err != DECLINED:
15551616
result.tool_errors.append({"name": tc.name, "error": err or str(res)[:500]})
15561617
elif check is not None and check.get("ok") is False: # the call said it worked but did not (#165)
@@ -1800,6 +1861,7 @@ def release() -> list[str]:
18001861
history = await self.store.recent_messages(session_id, self.config.chat.history_window)
18011862
convo = self.mark_outside_messages(history_to_openai(history))
18021863
result.browser_click_history.extend(browser_click_history(history))
1864+
result.action_call_history.extend(action_call_history(history, self.registry))
18031865

18041866
# Replace the just-stored user message with the rich version (attachments inlined).
18051867
# Keep the stored user message unchanged. Add the current time to the

‎sentient/agent/outcomes.py‎

Lines changed: 241 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,18 @@
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)
@@ -65,6 +70,80 @@
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+
209316
def _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+
432666
def _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

Comments
 (0)