ask-handoff

Ignore bot self-quote for topic triggers (no hard-gate)

slug ticket-108211-bot-self-reply-automations project sophia-bot source cursor updated 10/28/2026, 5:47:06 PM

Ignore bot self-quote on topic triggers — Implementation Plan

Goal: When the traveler reply-quotes Sophia's own last message, do not fold that bot text into $input, so topic automations cannot match the bot's copy (WA Pichilemu: dates-only must not fire foto/tour/cabaña topics). Task: TICKET-108211 Handoff: 108211 Spec: none — human chose option B (quote-fix only; revert one-topic-per-turn hard-gate) Architecture: init_state always prefixes metadata.replyingToMessage onto $input. Quotation already skips folding when the quote is the last AI message (resolve_reply_quote + _is_same_message). Mirror that gate in init_state so Reason/topic tools never see Domi's text as traveler intent. Do not limit how many distinct topic_automation_* tools may run in a turn. Tech: sophia-bot Python 3.12, pytest Test layout: pipenv run python -m pytest tests/test_unit/...; existing test_reason_quoted_reply_subagent_input.py, test_quotation/.../reply_quote patterns; unit under tests/test_unit/

Cheap-first gate:

  • Why NOT keep hard-gate: product allows multiple topic tools per turn; gate blocks legitimate multi-topic turns and still lets one wrong automation fire on bot quote.
  • Why NOT prompt-only traveler-only: already soft; prod cascaded anyway; wrong first match remains.
  • Code needed: deterministic skip of self-quote prefix in init_state (same semantics as quotation resolve_reply_quote) + revert hard-gate from current branch.

Root cause (accepted):

  1. WA: $input became > {"text":"¡Hola! Soy Domi..."}\n\n30-10 hasta 01-11; Reason matched ~9 topics against Domi's text.
  2. Correct behavior for that turn: quotation may run; zero topic automations whose trigger lives only in the quoted bot copy.
  3. IG multi-topic cascade without quote is out of scope for this plan (follow-up if still needed).

Decision (human 2026-10-28): B — quote-fix only; revert has_topic_automation_already_ran / execute_topic_automation gate.

Global Constraints:

  • No bug-entity negative examples in prompts ("foto cabaña", Domi greeting)
  • Reuse same-message semantics as resolve_reply_quote / _is_same_message (containment + normalize); do not invent a second comparison rule
  • Prefer shared helper over copy-paste drift between quotation and init_state
  • TDD on every task (red → green)
  • Execute via execute (inline only)
  • Final review via review (designated reviewer model, mandatory)
  • No commit / no push
  • Scope: sophia-bot only

Task 1: Revert one-topic-per-turn hard-gate

Files:

  • Modify: src/tools/topic_based_automation/topic_based_automation_service.py (remove has_topic_automation_already_ran, execute_topic_automation; restore direct build_topic_automation_response; remove "Do NOT call any further topic_automation tools" lines added for the gate)
  • Modify: src/tools/topic_based_automation/topic_based_automation_tool.py (call build_topic_automation_response again; drop one-per-turn wording from description; keep traveler-only match wording if still accurate after Task 2, or restore prior description if redundant)
  • Modify: tests/test_unit/test_topic_based_automation_service.py (drop asserts on forbid-further-topic lines)
  • Delete or gut: tests/test_unit/test_topic_automation_once_per_turn.py (gate-specific)
  • Delete or gut: tests/test_unit/test_topic_automation_tool_description.py if it only asserted one-per-turn / gate phrases

Interfaces:

  • Consumes: current uncommitted hard-gate diff
  • Produces: topic tools behave as pre-gate (N topic tools may succeed in one turn)

Acceptance:

  • No has_topic_automation_already_ran / execute_topic_automation in service
  • Tool _run path calls build_topic_automation_response
  • Unit suite for topic service passes without one-per-turn asserts

TDD:

  • Red: N/A (revert) — delete failing gate tests first or with revert
  • Green: restore prior call path
  • Verify: pipenv run python -m pytest tests/test_unit/test_topic_based_automation_service.py -q
  • Expected: pass

Risk: mech

Task 2: Shared "reply is last bot message" predicate + skip prefix in init_state

Files:

  • Create or Modify: small shared helper (prefer lift from src/agents/subagents/quotation/agent/reply_quote.py into a module both quotation and Reason can import — e.g. keep functions in reply_quote.py and import from init_state, or move normalize/same-message/last-bot check to a neutral path under src/ if quotation import from reason_core would be upside-down; avoid circular imports)
  • Modify: src/agents/subagents/quotation/agent/reply_quote.py (use shared helper; behavior unchanged)
  • Modify: src/agents/reason/reason_core/nodes/init_state.py — if replyingToMessage present, parse text (same as _replying_to_text), and only prefix > {raw}\n\n{input} when it is not the same message as the last ("ai", ...) in chat_history
  • Test: tests/test_unit/test_init_state_skip_bot_self_quote.py (new) and/or extend tests/test_unit/test_reason_quoted_reply_subagent_input.py
  • Test: keep quotation reply_quote unit coverage green (existing confirming/reply tests)

Interfaces:

  • Consumes: metadata.replyingToMessage (channel JSON string or dict with text), chat_history: list[tuple[str, Any]], traveler input
  • Produces: $input without bot self-quote when traveler reply-quoted the last AI message; unchanged prefix when quote is a different/earlier message

Acceptance:

  • State with last AI = Domi-style text, replyingToMessage JSON of that text, traveler 30-10 hasta 01-11 → after init_state_values, input is only the traveler dates (no > prefix / no Domi body)
  • Same setup but quote text is an earlier human/AI message that is not the last AI → prefix still applied (preserve 2026-09-01 "reply to message that stated dates" path for Reason)
  • Quotation resolve_reply_quote still skips last-bot fold (existing tests pass)
  • Existing subagent quoted-reply tests still pass (traveler-only current_input)

TDD:

  • Red: failing test that init_state_values still prefixes bot self-quote today
  • Green: skip prefix when last-bot match
  • Verify: pipenv run python -m pytest tests/test_unit/test_init_state_skip_bot_self_quote.py tests/test_unit/test_reason_quoted_reply_subagent_input.py tests/test_unit/test_quotation/ -q --tb=line (scope to reply_quote-related quotation unit files if full quotation suite is heavy)
  • Expected: pass

Risk: integ

Task 3: Topic tool description — traveler utterance only (no one-per-turn)

Files:

  • Modify: src/tools/topic_based_automation/topic_based_automation_tool.py (_create_tool_description)
  • Test: tests/test_unit/test_topic_automation_tool_description.py (recreate slim: traveler-only / never quoted bot; no at-most-one-topic asserts)

Interfaces:

  • Consumes: topic_instruction
  • Produces: description that match is on traveler's latest utterance only; never quoted prior bot/assistant content in a leading > blockquote — general wording only

Acceptance:

  • Description states traveler-only + ignore quoted bot content
  • Description does not require at-most-one topic automation per turn

TDD:

  • Red/green on description asserts
  • Verify: pipenv run python -m pytest tests/test_unit/test_topic_automation_tool_description.py -q
  • Expected: pass

Risk: mech

Task 4: Regression — WA dates + Domi self-quote does not put bot copy in Reason input

Files:

  • Test: tests/test_unit/test_init_state_skip_bot_self_quote.py (extend) — fixture shaped like prod WA (replyingToMessage JSON + dates + last AI greeting/body)

Interfaces:

  • Consumes: Task 2 behavior
  • Produces: locked acceptance that bot body substrings from the quote are absent from state["input"] after init

Acceptance:

  • After init, input equals traveler dates string; does not contain quoted bot greeting/body tokens from the fixture (assert absence of distinctive bot phrase from fixture — test-only string, not copied into production prompts)

TDD:

  • Covered with Task 2 tests; run same verify command
  • Verify: pipenv run python -m pytest tests/test_unit/test_init_state_skip_bot_self_quote.py -q
  • Expected: pass

Risk: mech