mirror of
https://github.com/usestrix/strix.git
synced 2026-08-19 18:13:34 +02:00
* let an agent wait on what it already said An agent that answers in plain text is nudged to call a tool, and the only tool that hands control back takes a required message. So it says the same thing twice: once as text the user has already read, once as the argument it had to supply to stop. Seen on a run whose whole instruction was "hi" - a greeting, then the same greeting again through respond_to_user. message is optional now. The nudge arms the tool with the text that was delivered and says not to repeat it, so an agent that has said its piece can park on it with an empty call. Anything it does want to add it passes normally. Parking still cannot leave the user on silence: an empty call is refused unless something was actually said, and the arming is single use - execution clears it as soon as a turn ends any other way. The interactive prompt now also says to answer and stop in one respond_to_user call, which is what avoids the nudge in the first place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * drop the worked example from the interactive prompt "the user greeted you, asked something you can answer outright, or you need a decision" was the run I had been reading, written into a rule that holds whatever the reason. The rule is that replying and stopping is one call; listing occasions only invites the model to check whether this is one of them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * drop the arming flag; an empty message just waits Passing the delivered text from execution into the tool, and refusing an empty call without it, was machinery guarding against an agent parking having said nothing. That leaves the user looking at "waiting for your reply" with a cursor in front of them - they type. It does not need a mechanism. What is left is the default on message, and the nudge saying the text already landed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * only offer waiting on words that were written The nudge told every agent its text had already been delivered, but it fires whenever a turn leaves the agent running, and a turn can end with no tool call and no text at all - _final_output_preview has carried <none> and <empty> branches all along. An agent that said nothing was being invited to wait on an answer the user never received, leaving them at a bare prompt. It now reads the turn: waiting on what was said is offered only when something was, and otherwise the agent is told plainly that the user has read nothing and to send its message. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * leave the continuation nudge alone Rewording it meant asserting from the outside whether the agent had spoken, and the nudge fires whenever a turn leaves the agent running - text or no text. The agent knows which it did without being told, so the guidance belongs in its prompt, where the condition is its own to read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * say it in the nudge, where the agent is reading An agent stranded by the nudge reasons off the nudge. Told only to call respond_to_user, it supplies a message, and since it has just answered in plain text that message is the same answer again. The system prompt saying otherwise sits thousands of tokens earlier and loses. The clause goes on the line the agent acts on: call respond_to_user, with no message if it has already said it. That reads true whatever the turn did, including one that produced no text, because the agent is the one who knows which — nothing here has to work it out from the outside. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
4.1 KiB
Python
115 lines
4.1 KiB
Python
"""``respond_to_user`` — deliver a reply and hand control back to the user."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from agents import RunContextWrapper, function_tool
|
|
|
|
from strix.core.agents import coordinator_from_context
|
|
|
|
|
|
def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
|
|
return ctx.context if isinstance(ctx.context, dict) else {}
|
|
|
|
|
|
@function_tool
|
|
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
|
|
yielding are the same call on purpose: there is no way to answer and
|
|
then forget to stop, and no way to stop without having answered.
|
|
|
|
Call it when you have something for the user and nothing to do until
|
|
they reply — you answered their question, you need a decision or a
|
|
credential only they can give, or you finished a chunk of work and
|
|
want direction. You resume exactly where you left off when they
|
|
reply, with everything you have done so far intact.
|
|
|
|
Do NOT call it to narrate progress or to think out loud. Plain text
|
|
is still shown to the user as you work, so say whatever you like
|
|
mid-task without stopping; ``respond_to_user`` is specifically the
|
|
act of *waiting* for them. Every call costs the user their attention.
|
|
|
|
Not for these:
|
|
|
|
- **Waiting on another agent** (a child's report, a peer's reply) —
|
|
use ``wait_for_agents``.
|
|
- **Ending the engagement** — use ``finish_scan`` (root) or
|
|
``agent_finish`` (subagent). Those are terminal; this is a pause.
|
|
|
|
Args:
|
|
message: What to say to the user. Self-contained: they may not
|
|
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)
|
|
me = inner.get("agent_id")
|
|
interactive = bool(inner.get("interactive", False))
|
|
|
|
if coordinator is None or me is None:
|
|
return json.dumps(
|
|
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
|
ensure_ascii=False,
|
|
default=str,
|
|
)
|
|
|
|
if not interactive:
|
|
return json.dumps(
|
|
{
|
|
"success": False,
|
|
"error": (
|
|
"No user is attached to an autonomous run. Keep working, and call "
|
|
"finish_scan (root) or agent_finish (subagent) when the task is done."
|
|
),
|
|
},
|
|
ensure_ascii=False,
|
|
default=str,
|
|
)
|
|
|
|
async with coordinator._lock:
|
|
stopped = coordinator.statuses.get(me) == "stopped"
|
|
if stopped:
|
|
return json.dumps(
|
|
{"success": True, "wait_outcome": "stopped", "message": message},
|
|
ensure_ascii=False,
|
|
default=str,
|
|
)
|
|
|
|
# A message that arrived while this turn was running is the user already
|
|
# talking: take it now instead of parking for one they have sent.
|
|
pending, _ = await coordinator.consume_pending(me)
|
|
if pending > 0:
|
|
await coordinator.mark_running(me)
|
|
return json.dumps(
|
|
{
|
|
"success": True,
|
|
"wait_outcome": "message_arrived",
|
|
"pending_messages": pending,
|
|
"message": message,
|
|
"note": "Your reply was delivered; the user had already sent a new message.",
|
|
},
|
|
ensure_ascii=False,
|
|
default=str,
|
|
)
|
|
|
|
await coordinator.park_waiting(me, wait_kind="user")
|
|
return json.dumps(
|
|
{
|
|
"success": True,
|
|
"wait_outcome": "waiting",
|
|
"message": message,
|
|
"note": "Reply delivered; parked until the user responds.",
|
|
},
|
|
ensure_ascii=False,
|
|
default=str,
|
|
)
|