[eric] browser: model-written send confirmation (cheap aux call, validated + fail-open to template) instead of a hardcoded line

This commit is contained in:
ciregenz
2026-07-15 19:14:31 -07:00
parent 7edb81a6cc
commit 32215136d2
2 changed files with 67 additions and 2 deletions
+41 -2
View File
@@ -514,6 +514,40 @@ def send_submit_index_in_state(state_text: str):
return None
# Tokens that mean the aux wrote machinery, not a user sentence: reject and fall back to a template.
P_NOT_A_REPLY = ("browser", "clickindex", "composer", "textbox", "```", "{", "index", "http")
async def compose_send_confirmation(aux_client, aux_model, task: str, payload: str) -> str:
"""The final 'done' line in the model's OWN voice, via one cheap aux call, so it isn't a
hardcoded template. The SEND already happened in code; this only writes the words. Fail-open:
returns '' on any error OR if the output doesn't read like a plain user sentence (tool names,
JSON, a URL), so the caller falls back to a simple template. Aux tier = cheap + fast; a
one-sentence confirmation needs no frontier model, and it never re-does the mechanical work."""
if not aux_client or not aux_model or not payload:
return ""
prompt = (
"You just finished a task for the user by controlling their web browser, and it SUCCEEDED.\n"
f"The user asked: {task[:280]}\n"
f"What you sent: \"{payload[:280]}\"\n"
"Reply with ONE short, warm, first-person sentence confirming it's done, the way a helpful "
'friend would (e.g. "Done, I messaged Tyler and said hi."). No technical words, no quotes '
"wrapping the whole sentence, no preamble, just the sentence."
)
try:
resp = await aux_client.messages.create(
model=aux_model, max_tokens=80,
messages=[{"role": "user", "content": prompt}],
)
text = "".join(getattr(b, "text", "") for b in (resp.content or [])).strip().strip('"').strip()
except Exception:
return ""
low = text.lower()
if not text or len(text) > 220 or any(t in low for t in P_NOT_A_REPLY):
return ""
return text
def is_composer_fill(tool_name: str, tool_input: dict) -> bool:
"""True if this action typed a message into a composer (the moment the Send
button is about to matter). Covers the solo fill, BrowserType, and a batched
@@ -1378,7 +1412,9 @@ async def run_browser_agent(
send_confirmed = True
done_called = True
done_success = True
done_message = f'Done, I sent "{p_script["payload"]}" for you.'
p_aux_c, p_aux_m = await p_get_aux_client()
done_message = (await compose_send_confirmation(p_aux_c, p_aux_m, task, p_script["payload"])
or f'Done, I sent "{p_script["payload"]}" for you.')
else:
# Clicked but the composer did NOT clear: the send is UNVERIFIED. Leave send_confirmed False so the loop can't shortcut to a "done" it never earned (r264 set it True here and the model then FALSELY claimed delivery). The model gets ONE truthful verify pass, never a blind resend.
task = f"{task}\n\n[{p_script['note']}]"
@@ -2070,7 +2106,10 @@ async def run_browser_agent(
done_called = True
done_success = True
p_payload = browser_batch_replay.send_payload_from_log(action_log, task)
done_message = (
p_aux_c, p_aux_m = await p_get_aux_client()
p_nice = (await compose_send_confirmation(p_aux_c, p_aux_m, task, p_payload)
if p_payload else "")
done_message = p_nice or (
f'Done, I sent "{p_payload}" for you.'
if p_payload else
"Done, I sent your message."
+26
View File
@@ -1514,6 +1514,32 @@ def test_composer_fill_detection():
assert not is_composer_fill("BrowserScroll", {})
def test_compose_send_confirmation_model_voice_with_safe_fallback():
# The done line is model-written (aux), but validated: a clean sentence is used as-is; tool-ish
# / JSON / URL output is rejected so the caller falls back to a template (never leaks machinery).
import asyncio
from backend.apps.agents.browser.browser_agent import compose_send_confirmation
class Blk2:
def __init__(self, text): self.type = "text"; self.text = text
class Resp2:
def __init__(self, text): self.content = [Blk2(text)]
class Aux:
def __init__(self, text): self._t = text; self.messages = self
async def create(self, **kw): return Resp2(self._t)
run = lambda a: asyncio.get_event_loop().run_until_complete(a)
# clean natural sentence -> used verbatim
assert run(compose_send_confirmation(Aux("Done, I messaged Tyler and said hi."), "m", "say hi", "hi")) \
== "Done, I messaged Tyler and said hi."
# tool-ish / JSON / url -> rejected (empty) so caller templates
assert run(compose_send_confirmation(Aux("Try BrowserClickIndex then list."), "m", "t", "hi")) == ""
assert run(compose_send_confirmation(Aux('{"done": true}'), "m", "t", "hi")) == ""
# no aux / no payload -> empty (fail-open)
assert run(compose_send_confirmation(None, "m", "t", "hi")) == ""
assert run(compose_send_confirmation(Aux("Done!"), "m", "t", "")) == ""
def test_send_index_handoff_points_only_at_a_real_send_button():
# after a composer fill we hand the model the Send button's index so it clicks it directly instead of hunting; must never mistake an upsell/profile link for it
from backend.apps.agents.browser.browser_agent import send_index_in_state