diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 5fc697d7..3efc07af 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -39,6 +39,8 @@ INTERACTIVE BEHAVIOR: - To end the whole engagement, call the lifecycle tool: finish_scan (root) or agent_finish (subagent). - A turn that ends with plain text and no tool call does NOT stop you: the system nudges you to continue and will re-run you. Do not rely on going silent to pause — it will not pause you. - Answering a user question: put the answer in respond_to_user's message. Do not write the answer as plain text and then fall silent — that does not reach a stopping point, it just triggers a continuation nudge. +- If all you want to do is reply and stop, that whole turn is ONE respond_to_user call carrying the answer. Do not write the answer as text and then call respond_to_user as well: the user reads it twice. +- If you do end a turn on plain text and the nudge arrives, your words already reached the user. Do not restate them: call respond_to_user with NO message to simply wait, or with only whatever you still need to add. - You may include brief explanatory text before a tool call, and you can narrate while you work — plain text is shown to the user as you go. Narrating is free; respond_to_user is specifically the act of WAITING for the user, so do not call it just to give a status update. - Respond naturally when the user asks questions or gives instructions. - While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and respond_to_user only when you genuinely need the user. diff --git a/strix/core/execution.py b/strix/core/execution.py index 91ceb8af..bd99e7c3 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -830,7 +830,7 @@ async def _append_tool_required_message( "execution and never hands control to the user: it is shown to the user, and the " "run continues. Continue immediately and call exactly one tool. " "If you have something to tell the user and nothing to do until they reply, " - "call respond_to_user. " + "call respond_to_user — with no message if you have already said it. " "If you are blocked waiting for another agent, call wait_for_agents. " f"If the whole engagement is complete, call {finish_tool}. " "Otherwise use the appropriate execution or planning tool. " diff --git a/strix/tools/respond/tool.py b/strix/tools/respond/tool.py index 796050c3..12538f0c 100644 --- a/strix/tools/respond/tool.py +++ b/strix/tools/respond/tool.py @@ -15,7 +15,7 @@ def _ctx(ctx: RunContextWrapper) -> dict[str, Any]: @function_tool -async def respond_to_user(ctx: RunContextWrapper, message: str) -> str: +async def respond_to_user(ctx: RunContextWrapper, message: str = "") -> str: """Answer the user and hand control back to them. This is the ONLY way to yield to the user. Delivering the message and @@ -45,6 +45,10 @@ async def respond_to_user(ctx: RunContextWrapper, message: str) -> str: have followed the tool calls that led here. Lead with the answer or the decision you need, and if you are blocked, say exactly what you need from them. + + Omit it when you have just said your piece as plain text and + only need to wait: that text has already reached them, and + repeating it makes them read the same answer twice. """ inner = _ctx(ctx) coordinator = coordinator_from_context(inner) diff --git a/tests/test_execution.py b/tests/test_execution.py index 6364f801..d389bde3 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -1228,3 +1228,40 @@ async def test_wait_kind_survives_a_snapshot_round_trip() -> None: assert restored.wait_kinds["root"] == "user" assert restored.idle_resume_counts["root"] == 1 assert await execution._plain_waiting_timeout(restored, "root") is None + + +@pytest.mark.asyncio +async def test_interactive_nudge_offers_waiting_without_repeating() -> None: + """The nudge is the instruction an agent reads when it is stranded here. + + It is where the option to wait on what was already said has to be, not only + in the system prompt: an agent that ended a turn on plain text reasons off + this text, and without the clause it restates its answer to reach a tool + call, so the user reads it twice. + + The clause holds whatever the turn did, because the agent is the one who + knows whether it spoke — this fires for a turn that produced no text at all. + """ + items = await execution._append_tool_required_message( + session=None, + context={"parent_id": None}, + attempt=1, + limit=3, + interactive=True, + ) + + assert "with no message if you have already said it" in items[0]["content"] + + +@pytest.mark.asyncio +async def test_autonomous_nudge_does_not_offer_the_user() -> None: + """There is nobody attached to an autonomous run to wait for.""" + items = await execution._append_tool_required_message( + session=None, + context={"parent_id": None}, + attempt=1, + limit=3, + interactive=False, + ) + + assert "respond_to_user" not in items[0]["content"] diff --git a/tests/test_respond_to_user.py b/tests/test_respond_to_user.py index 254bd357..fc59c267 100644 --- a/tests/test_respond_to_user.py +++ b/tests/test_respond_to_user.py @@ -64,3 +64,31 @@ async def test_a_message_that_already_arrived_is_taken_instead_of_parking() -> N assert result["wait_outcome"] == "message_arrived" assert result["pending_messages"] == 1 assert coordinator.statuses["root"] == "running" + + +async def _call_without_message(context: dict[str, Any]) -> dict[str, Any]: + ctx = ToolContext( + context=context, + tool_name="respond_to_user", + tool_call_id="call-1", + tool_arguments="{}", + ) + raw = await respond_to_user.on_invoke_tool(ctx, "{}") + return json.loads(raw) # type: ignore[no-any-return] + + +@pytest.mark.asyncio +async def test_parks_without_a_message() -> None: + """An agent that has already said its piece as plain text can just wait. + + The nudge is what leaves it here, and while a message was required the only + way to stop was to send the same answer a second time. + """ + context = await _context(interactive=True) + + result = await _call_without_message(context) + + assert result["success"] is True + assert result["wait_outcome"] == "waiting" + assert result["message"] == "" + assert context["coordinator"].statuses["root"] == "waiting"