refactor(tools): split wait_for_message into respond_to_user + wait_for_agents

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.
This commit is contained in:
Ahmed Allam
2026-08-02 02:15:51 +03:00
committed by Ahmed Allam
parent 742f382836
commit 1c1fa49961
20 changed files with 539 additions and 128 deletions
@@ -6,6 +6,7 @@ from . import (
notes_renderer,
proxy_renderer,
reporting_renderer,
respond_renderer,
shell_renderer,
thinking_renderer,
todo_renderer,
@@ -23,6 +24,7 @@ __all__ = [
"proxy_renderer",
"render_tool_widget",
"reporting_renderer",
"respond_renderer",
"shell_renderer",
"thinking_renderer",
"todo_renderer",
@@ -117,8 +117,8 @@ class AgentFinishRenderer(BaseToolRenderer):
@register_tool_renderer
class WaitForMessageRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "wait_for_message"
class WaitForAgentsRenderer(BaseToolRenderer):
tool_name: ClassVar[str] = "wait_for_agents"
css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"]
@classmethod
@@ -0,0 +1,35 @@
from typing import Any, ClassVar
from rich.text import Text
from textual.widgets import Static
from .agent_message_renderer import AgentMessageRenderer
from .base_renderer import BaseToolRenderer
from .registry import register_tool_renderer
@register_tool_renderer
class RespondToUserRenderer(BaseToolRenderer):
"""Render a reply as the agent's own prose, not as a tool call.
``respond_to_user`` carries the message the user is meant to read, so it
gets the same markdown treatment as a plain assistant turn.
"""
tool_name: ClassVar[str] = "respond_to_user"
css_classes: ClassVar[list[str]] = ["tool-call", "respond-tool"]
@classmethod
def render(cls, tool_data: dict[str, Any]) -> Static:
args = tool_data.get("args", {})
message = args.get("message", "")
text = Text()
if message:
text.append_text(AgentMessageRenderer.render_simple(message))
text.append("\n\n")
text.append("", style="#6b7280")
text.append("waiting for your reply", style="dim")
css_classes = cls.get_css_classes(tool_data.get("status", "unknown"))
return Static(text, classes=css_classes)
@@ -54,7 +54,7 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps
);
}
if (toolName === "wait_for_message") {
if (toolName === "wait_for_agents") {
const reason = (args.reason as string) ?? "";
return (
<div className="flex items-center gap-2">
@@ -0,0 +1,20 @@
"use client";
import type { ToolRendererProps } from "@/types/events";
import Markdown from "./Markdown";
/**
* `respond_to_user` carries the message the user is meant to read, so it renders
* as the agent's own prose rather than as a tool call.
*/
export default function RespondRenderer({ args }: ToolRendererProps) {
const message = (args.message as string) ?? "";
if (!message) return null;
return (
<div>
<Markdown text={message} />
<div className="mt-1.5 text-[#888] text-[13px]">waiting for your reply</div>
</div>
);
}
@@ -24,6 +24,7 @@ import NotesRenderer from "./NotesRenderer";
import TodoRenderer from "./TodoRenderer";
import FallbackRenderer from "./FallbackRenderer";
import LoadSkillRenderer from "./LoadSkillRenderer";
import RespondRenderer from "./RespondRenderer";
/**
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
@@ -104,10 +105,10 @@ const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
reporting: ["create_vulnerability_report", "list_reports", "get_report"],
thinking: ["think"],
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_message", "view_agent_graph", "stop_agent"],
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_agents", "view_agent_graph", "stop_agent"],
search: ["web_search"],
// scan_start_info / subagent_start_info are strix-app synthetic events; finish_scan is the engine's
lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan"],
lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan", "respond_to_user"],
notes: ["create_note", "delete_note", "update_note", "list_notes", "get_note"],
skills: ["load_skill"],
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
@@ -127,6 +128,7 @@ const TOOL_CATEGORY: Record<string, ToolCategory> = Object.fromEntries(
*/
const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps>>> = {
finish_scan: FinishRenderer,
respond_to_user: RespondRenderer,
apply_patch: ApplyPatchRenderer,
view_image: ViewImageRenderer,
list_reports: ReportListRenderer,
@@ -140,7 +142,8 @@ const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps
const ICON_OVERRIDES: Partial<Record<string, ToolIconMeta>> = {
agent_finish: { icon: Flag, color: "text-cyan-400" },
send_message_to_agent: { icon: MessageCircle, color: "text-cyan-400" },
wait_for_message: { icon: MessageCircle, color: "text-cyan-400" },
wait_for_agents: { icon: MessageCircle, color: "text-cyan-400" },
respond_to_user: { icon: MessageCircle, color: "text-emerald-400" },
view_agent_graph: { icon: Eye, color: "text-cyan-400" },
stop_agent: { icon: Ban, color: "text-red-400" },
scan_start_info: { icon: Crosshair, color: "text-emerald-400" },
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-DzvI_0HX.js"></script>
<script type="module" crossorigin src="./assets/index-CGvQq6oe.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C3kQ5kk8.css">
</head>
<body>