mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
One tool was doing three jobs (wait on the user, wait on other agents, and - wrongly - wait for a long-running command), so the driver had to guess which one an agent meant and used parent_id as the proxy: the root waits for a human, everyone else waits for agents. That proxy is wrong, since the user can message any agent from the TUI's agent tree. Tool identity now carries the intent, and the coordinator records it as a wait_kind that survives snapshot/restore: respond_to_user -> wait_kind="user", never auto-resumed (root or not) wait_for_agents -> wait_kind="agents", auto-resumed on a 300s timer recovery exhaust -> wait_kind="stalled" respond_to_user fuses the message and the yield into one call, so there is no way to answer and then forget to stop - the two-step that gpt-4o-mini skipped 2/2 in live testing. Plain text still renders as before. Auto-resume is also bounded now: an agent that re-parks after every timeout burned a model turn every 300s for the rest of the scan (and, since parked children notify their parent, spammed the parent's inbox on the same cycle). After _MAX_IDLE_AUTO_RESUMES it stays parked until a real message arrives.
111 lines
3.9 KiB
Python
111 lines
3.9 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.
|
|
"""
|
|
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,
|
|
)
|