diff --git a/backend/apps/agents/browser/browser_send_parse.py b/backend/apps/agents/browser/browser_send_parse.py
index 6c7db5ff..21bafbc0 100644
--- a/backend/apps/agents/browser/browser_send_parse.py
+++ b/backend/apps/agents/browser/browser_send_parse.py
@@ -292,9 +292,43 @@ def opener_contradicts_task(task: str, opener_name: str) -> bool:
return responding and bool(P_CREATE_OPENER_RE.search(name))
-def composer_index_in_state(state_text: str):
- """(index, name) of the single compose-shaped textbox, or None. Two
- candidates = ambiguous = model's problem."""
+# Fields a person names out loud that a compose-shaped picker will never choose, because they are
+# short single-line inputs and nothing about them looks like a composer. Deliberately excludes
+# "body"/"message"/"comment": those ARE compose-shaped, the existing picker already finds them, and
+# adding them here would only create a second way to pick the same box.
+P_FIELD_HINT_RE = re.compile(r"\b(title|subject|headline)\b", re.I)
+
+
+def hinted_field_in_state(state_text: str, task: str):
+ """(index, name) of the textbox the TASK named by field, or None.
+
+ Measured live on reddit 2026-08-05: the task was 'create a text post whose title is exactly X',
+ the picker below chose 'Post body text field' (correctly, by its own compose-shape rule), the
+ Title stayed empty, and reddit's submit therefore stayed DISABLED on every attempt. The task had
+ already said which field it meant, and nobody read it.
+
+ Not a guess: the field word comes from the user's own sentence and has to appear in the
+ element's accessible name. Ambiguity still stands down, same rule as everywhere else here.
+ """
+ m = P_FIELD_HINT_RE.search(task or "")
+ if not m:
+ return None
+ word = m.group(1).lower()
+ hits = [(int(i), n) for i, n in P_COMPOSER_ROW_RE.findall(state_text or "")
+ if word in (n or "").lower()]
+ return hits[0] if len(hits) == 1 else None
+
+
+def composer_index_in_state(state_text: str, task: str = ""):
+ """(index, name) of the textbox to write into, or None. Two candidates = ambiguous =
+ model's problem.
+
+ A field the task named by name wins over the compose-shaped guess, because it is the one piece
+ of evidence here that came from the person asking rather than from the page's shape.
+ """
+ hinted = hinted_field_in_state(state_text, task)
+ if hinted:
+ return hinted
hits = [(int(m.group(1)), m.group(2)) for m in P_COMPOSER_ROW_RE.finditer(state_text or "")
if P_COMPOSER_NAME_RE.search(m.group(2) or "")]
return hits[0] if len(hits) == 1 else None
diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py
index 8420f03d..fd1820d4 100644
--- a/backend/apps/agents/browser/browser_send_script.py
+++ b/backend/apps/agents/browser/browser_send_script.py
@@ -340,7 +340,7 @@ async def run_send_script(
return None
log: list[dict] = []
- composer = browser_send_parse.composer_index_in_state(state_text)
+ composer = browser_send_parse.composer_index_in_state(state_text, task_sans_brief)
if composer and browser_send_parse.surface_mismatch(task_sans_brief, composer[1]):
# Asked to POST and found a COMMENT box, or found a DM box nobody asked for: either way it
# is someone else's surface, not a slower route to ours. Drop it and let the tiers below
@@ -352,7 +352,7 @@ async def run_send_script(
for wait_s in (0.0, 1.2, 1.4):
await asyncio.sleep(wait_s)
fresh = await fresh_list()
- composer = browser_send_parse.composer_index_in_state(fresh)
+ composer = browser_send_parse.composer_index_in_state(fresh, task_sans_brief)
if composer:
state_text = fresh
break
@@ -393,7 +393,7 @@ async def run_send_script(
for wait_s in (0.0, 1.2, 1.5, 2.0, 2.0, 2.0):
await asyncio.sleep(wait_s)
state_text = await fresh_list()
- composer = browser_send_parse.composer_index_in_state(state_text)
+ composer = browser_send_parse.composer_index_in_state(state_text, task_sans_brief)
if composer:
break
p_settled = p_settled + 1 if state_text and state_text == p_prev else 0
@@ -495,7 +495,8 @@ async def run_send_script(
if wait_s:
await asyncio.sleep(wait_s)
state_retry = await fresh_list()
- composer_retry = browser_send_parse.composer_index_in_state(state_retry)
+ composer_retry = browser_send_parse.composer_index_in_state(
+ state_retry, task_sans_brief)
if composer_retry:
break
if not composer_retry:
diff --git a/backend/tests/test_task_names_the_field.py b/backend/tests/test_task_names_the_field.py
new file mode 100644
index 00000000..3b01f073
--- /dev/null
+++ b/backend/tests/test_task_names_the_field.py
@@ -0,0 +1,60 @@
+"""When the task names a field, write into THAT field.
+
+Measured live on reddit 2026-08-05, twice in one round. Task: "create a text post whose title is
+exactly canary5e95c195. Submit it." The compose-shaped picker chose 'Post body text field', which is
+the right answer to the question it was asking (which box looks like a composer?) and the wrong
+answer to the question that mattered (which box did the person mean?). reddit's Title stayed empty,
+its submit stayed DISABLED, and the run could never complete: `submit 'post' is present but
+DISABLED; the form is incomplete, handing to the model`, on every attempt.
+
+The field word comes from the user's own sentence and must appear in the element's accessible name,
+so this is not the picker guessing; it is the picker finally reading the instruction.
+"""
+
+from backend.apps.agents.browser import browser_send_parse as bp
+
+# A reddit submit form as the perception lists it: a short Title input and the big body composer.
+REDDIT = '[21]\n[23]\n[25]'
+# LinkedIn's post modal: one compose-shaped box, no named field anywhere.
+LINKEDIN = '[54]\n[57]'
+
+
+def test_a_named_title_beats_the_compose_shaped_guess():
+ """The regression, exactly as it happened."""
+ assert bp.composer_index_in_state(
+ REDDIT, 'create a text post whose title is exactly "canary5e95c195"') == (21, "Title")
+
+
+def test_without_a_field_word_the_old_picker_still_decides():
+ """No hint means no change: every site that worked before must keep working."""
+ assert bp.composer_index_in_state(
+ REDDIT, 'create a text post with body "hello"') == (23, "Post body text field")
+ assert bp.composer_index_in_state(
+ LINKEDIN, 'create a post with exactly this text: "hello"') == (
+ 54, "Text editor for creating content")
+
+
+def test_a_field_word_with_no_matching_box_changes_nothing():
+ """LinkedIn has no Title field. Naming one must not break the composer it does have."""
+ assert bp.composer_index_in_state(
+ LINKEDIN, 'post with the title "hello"') == (54, "Text editor for creating content")
+
+
+def test_two_boxes_matching_the_hint_stand_down():
+ """Ambiguity is the model's problem here, same as everywhere else in this file."""
+ two = '[1]\n[2]\n[3]'
+ assert bp.hinted_field_in_state(two, 'post with the title "x"') is None
+ # and the compose-shaped picker still answers
+ assert bp.composer_index_in_state(two, 'post with the title "x"') == (3, "Post body text field")
+
+
+def test_the_hint_words_stay_off_compose_shaped_fields():
+ """'body', 'message' and 'comment' are deliberately NOT hints: those boxes are already found by
+ shape, and a second route to the same element is a second thing to keep in sync."""
+ for word in ("body", "message", "comment"):
+ assert bp.P_FIELD_HINT_RE.search(word) is None
+
+
+def test_the_old_single_argument_call_still_works():
+ """Callers outside the send script pass state only."""
+ assert bp.composer_index_in_state(LINKEDIN) == (54, "Text editor for creating content")