From 691cd60057dceb06ca93561b2939ff55b96e11ba Mon Sep 17 00:00:00 2001 From: Linus Date: Mon, 21 Sep 2026 12:26:16 +0200 Subject: [PATCH] Route a request to pick work through the suggestion tool Asked what to work on, the mentor could answer without ever naming a task. Nothing routed the question: `get_suggested_tasks` appeared only inside the hire-state list, which sorts tools by subject, and task selection is neither how the product works nor the hire's own progress. Starter Work is also a product noun, and `search_docs` is described as covering how the product works -- so the model explained the feature, accurately, and suggested nothing. The backend was never the cause: its tool always returns prose, "there are no starter-work tasks to suggest yet" included, so a hire who saw no tasks saw a turn in which the tool was never called. The routing rule is now its own clause, gated on the tool being mounted. It names the wordings that carry the intent, says the ranking exists nowhere else -- not in the corpus, not in the conversation summary, not in a list given earlier in the visit -- and says what to do when it comes back empty, because a mentor with nothing to offer will otherwise assemble something that reads like a task. Arrival gets its own sentence, mounted with its own tool. Reading it pauses the turn for the backend to run it, and the hop that resumes has already said something helpful about setup; the question is dropped on the way back unless both tools belong to the one reply. Dropped in team mode with the rest of the hire's clauses: a manager must never be handed a list of what they should work on. Closes #200 Co-Authored-By: Claude Opus 5 --- src/onboarding/buddy_persona.py | 33 +++++++++++++++ tests/onboarding/test_buddy_persona.py | 57 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/onboarding/buddy_persona.py b/src/onboarding/buddy_persona.py index 8894650..31a1f14 100644 --- a/src/onboarding/buddy_persona.py +++ b/src/onboarding/buddy_persona.py @@ -70,6 +70,32 @@ _CLAIM_CLAUSE = "- When the hire picks a suggested task, offer `claim_goal`.\n" +# The one question this mentor exists for, and the one it answered from the wrong +# place. "What should I work on?" is a question about *this hire*, but Starter Work +# is also a product noun -- so a mentor told to use `search_docs` for how the product +# works explains the feature, accurately, and never names a task. The ranking exists +# only behind the tool: the corpus cannot know which tasks fit this hire, and a +# summary of an earlier visit is a record of what was suggested then, not now. +_SUGGEST_CLAUSE = ( + "- When the hire asks what to work on -- in any wording, including naming " + "Starter Work or saying somebody told them to pick something up -- call " + "`get_suggested_tasks` and present what it returns. That ranking is the only " + "place these suggestions exist, so never answer this from `search_docs`, from " + "the conversation summary, or from a list you gave earlier in this visit.\n" + "- If it comes back with nothing to suggest, say so plainly and say who can put " + "work there. A task you assembled yourself is not one anybody has agreed to.\n" +) + +# Separate from the clause above because it is only true when the arrival tool is +# mounted, and because it is the specific way the answer went missing: arrival is +# read first, the turn pauses for the backend to run it, and the hop that resumes +# has already said something helpful about setup. Naming both tools in one reply is +# what stops the question being dropped on the way back. +_SUGGEST_AFTER_ARRIVAL_CLAUSE = ( + "- Reading `get_arrival_steps` first does not stand in for that call. Both " + "belong in the same reply: what is outstanding, and the tasks themselves.\n" +) + # Deliberately an *offer*, and deliberately in the conversation. There is no # separate intake mode and no questionnaire: a hire meets the mentor and, if they # want to, is placed by talking to them. The clause has to say all three of "offer, @@ -211,6 +237,13 @@ def build_persona( "own progress.\n" ) + # After the list above, which it narrows: that clause sorts tools by subject, and + # "what should I work on" belongs to no subject cleanly enough to be routed by it. + if "get_suggested_tasks" in available: + parts.append(_SUGGEST_CLAUSE) + if "get_arrival_steps" in available: + parts.append(_SUGGEST_AFTER_ARRIVAL_CLAUSE) + # The escalation offer only makes sense when the hire can actually escalate. escalation = ( "; offer `flag_to_pm` as the last resort.\n" diff --git a/tests/onboarding/test_buddy_persona.py b/tests/onboarding/test_buddy_persona.py index 457b679..6c7b561 100644 --- a/tests/onboarding/test_buddy_persona.py +++ b/tests/onboarding/test_buddy_persona.py @@ -86,6 +86,58 @@ def test_the_arrival_clause_is_absent_without_the_tool() -> None: assert "Before suggesting anything to work on" not in persona +def test_asking_what_to_work_on_is_routed_to_the_suggestion_tool() -> None: + """The bug this clause exists for: the mentor answered the question without ever + naming a task, because the hire-state list sorts tools by subject and "what + should I work on" is not cleanly the hire's own progress.""" + persona = build_persona(_ALL_TOOLS) + + assert "When the hire asks what to work on" in persona + assert "call `get_suggested_tasks` and present what it returns" in persona + + +def test_starter_work_is_never_answered_from_the_corpus() -> None: + """Starter Work is a product noun as well as this hire's queue, and + `search_docs` is described as covering how the product works -- so without this + the model explains the feature, accurately, and names no task.""" + persona = build_persona(_ALL_TOOLS) + + assert "naming Starter Work" in persona + assert "never answer this from `search_docs`" in persona + assert "from the conversation summary" in persona + + +def test_an_empty_ranking_is_reported_rather_than_filled_in() -> None: + persona = build_persona(_ALL_TOOLS) + + assert "nothing to suggest" in persona + assert "A task you assembled yourself is not one anybody has agreed to" in persona + + +def test_the_arrival_read_does_not_stand_in_for_the_suggestion() -> None: + """Arrival pauses the turn for the backend to run it, and the hop that resumes + has already said something helpful. Both tools belong in the one reply.""" + persona = build_persona(_ALL_TOOLS) + + assert "does not stand in for that call" in persona + assert "Both belong in the same reply" in persona + + +def test_without_the_arrival_tool_nothing_is_said_about_reading_it_first() -> None: + persona = build_persona([t for t in _ALL_TOOLS if t != "get_arrival_steps"]) + + assert "When the hire asks what to work on" in persona + assert "does not stand in for that call" not in persona + + +def test_the_routing_rule_is_absent_without_the_suggestion_tool() -> None: + """A role with no starter work mounted must not be told to call for it.""" + persona = build_persona([t for t in _ALL_TOOLS if t != "get_suggested_tasks"]) + + assert "get_suggested_tasks" not in persona + assert "When the hire asks what to work on" not in persona + + def test_default_vocabulary_is_the_engineering_wording() -> None: persona = build_persona(_ALL_TOOLS, DEFAULT_VOCABULARY) @@ -202,6 +254,7 @@ def test_capabilities_off_never_offers_an_escalation_or_a_claim() -> None: assert "flag_to_pm" not in persona assert "claim_goal" not in persona assert "get_arrival_steps" not in persona + assert "get_suggested_tasks" not in persona def test_team_mode_addresses_the_manager_not_a_hire() -> None: @@ -222,6 +275,10 @@ def test_team_mode_drops_every_hire_directed_clause() -> None: assert "get_competencies_to_assess" not in persona assert "record_assessment" not in persona assert "hire-state tools" not in persona + # The identity already forbids it; dropping the routing rule as well means a + # backend that one day mounts the tool still cannot hand a manager a task list. + assert "get_suggested_tasks" not in persona + assert "what to work on" not in persona def test_team_mode_states_situations_as_facts_about_the_situation() -> None: