diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py index c92c3a44..73a0868d 100644 --- a/backend/apps/agents/manager/prompt/prompt_context.py +++ b/backend/apps/agents/manager/prompt/prompt_context.py @@ -442,7 +442,13 @@ AGENT_IDENTITY = ( @typechecked def compose_system_prompt(default_prompt: Optional[str], mode_prompt: Optional[str], session_prompt: Optional[str], browser_ctx: Optional[str] = None, mcp_registry_ctx: Optional[str] = None, skills_catalog_ctx: Optional[str] = None) -> Optional[str]: # Identity always leads so it overrides the preset's Claude Code persona, even when the user has no custom default/mode/session prompt of their own. - parts = [AGENT_IDENTITY] + [p for p in (default_prompt, mode_prompt, session_prompt, mcp_registry_ctx, skills_catalog_ctx, browser_ctx) if p] + # The session prompt goes LAST with explicit priority framing: buried mid-prompt it was delivered but ignored on turn one (Haik's hackathon blocker; live-proven with a marker token both ways). + framed_session = ( + "## Instructions from your operator (highest priority)\n" + "The person who configured this agent wrote the following. Follow it over any earlier " + "guidance in this prompt, starting from your very first reply.\n\n" + session_prompt + ) if session_prompt else None + parts = [AGENT_IDENTITY] + [p for p in (default_prompt, mode_prompt, mcp_registry_ctx, skills_catalog_ctx, browser_ctx, framed_session) if p] return "\n\n".join(parts) diff --git a/backend/tests/test_session_prompt_position.py b/backend/tests/test_session_prompt_position.py new file mode 100644 index 00000000..a4e18d06 --- /dev/null +++ b/backend/tests/test_session_prompt_position.py @@ -0,0 +1,39 @@ +"""A custom system prompt must actually bite on the FIRST message (Haik's hackathon blocker). + +The prompt was always DELIVERED (verified in the live CLI argv, and the model could quote it when +asked), but it sat mid-prompt between the identity block and the MCP registry, after the whole +default prompt, and the model ignored it: a marker-token drill on the packaged build showed 0/3 +arms applying it. Moving it last with explicit operator framing flipped all arms to applied. + +These pin the structural half of that fix, the part a unit test can hold: position and framing. +The behavioural half lives in the live drill (scratchpad/sysprompt_test.sh pattern). +""" +from backend.apps.agents.manager.prompt.prompt_context import compose_system_prompt + +SESSION = "Always answer in pirate speak." +DEFAULT = "Be helpful and concise." +MODE = "You are in coding mode." + + +def test_session_prompt_is_the_last_content_in_the_composed_prompt(): + out = compose_system_prompt(DEFAULT, MODE, SESSION, "browser ctx", "mcp ctx", "skills ctx") + assert out is not None + assert out.rstrip().endswith(SESSION), "anything after the operator's words dilutes them again" + + +def test_session_prompt_carries_priority_framing(): + out = compose_system_prompt(DEFAULT, MODE, SESSION) + assert "highest priority" in out + assert "your very first reply" in out, "turn one is the reported failure; the framing must name it" + assert out.index("highest priority") < out.index(SESSION), "the framing introduces the prompt, not trails it" + + +def test_no_session_prompt_means_no_framing_block(): + out = compose_system_prompt(DEFAULT, MODE, None, "browser ctx") + assert "highest priority" not in out, "an empty operator block would be framing around nothing" + assert DEFAULT in out and "browser ctx" in out + + +def test_default_and_mode_prompts_still_precede_context_blocks(): + out = compose_system_prompt(DEFAULT, MODE, SESSION, "browser ctx", "mcp ctx") + assert out.index(DEFAULT) < out.index(MODE) < out.index("mcp ctx") < out.index(SESSION)