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 quotationresolve_reply_quote) + revert hard-gate from current branch.
Root cause (accepted):
- WA:
$inputbecame> {"text":"¡Hola! Soy Domi..."}\n\n30-10 hasta 01-11; Reason matched ~9 topics against Domi's text. - Correct behavior for that turn: quotation may run; zero topic automations whose trigger lives only in the quoted bot copy.
- 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-botonly
Task 1: Revert one-topic-per-turn hard-gate
Files:
- Modify:
src/tools/topic_based_automation/topic_based_automation_service.py(removehas_topic_automation_already_ran,execute_topic_automation; restore directbuild_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(callbuild_topic_automation_responseagain; 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.pyif 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_automationin service - Tool
_runpath callsbuild_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.pyinto a module both quotation and Reason can import — e.g. keep functions inreply_quote.pyand import frominit_state, or move normalize/same-message/last-bot check to a neutral path undersrc/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— ifreplyingToMessagepresent, 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", ...)inchat_history - Test:
tests/test_unit/test_init_state_skip_bot_self_quote.py(new) and/or extendtests/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 withtext),chat_history: list[tuple[str, Any]], travelerinput - Produces:
$inputwithout 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,
replyingToMessageJSON of that text, traveler30-10 hasta 01-11→ afterinit_state_values,inputis 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_quotestill 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_valuesstill 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 (replyingToMessageJSON + 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,
inputequals 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