feat: add graceful wrap-up command for agents

Add a new "wrap up" feature that allows users to gracefully request
an agent to finish its current work and report findings, instead of
hard-stopping with ESC.

Changes:
- Add wrap_up_agent() function in agents_graph_actions.py that sends
  an urgent message instructing the agent to conclude within 3-5 iterations
- Add WrapUpAgentScreen modal dialog in tui.py with amber/warning styling
- Add 'W' keyboard binding to trigger wrap-up on selected agent
- Update help screen to show new keybinding
- Add CSS styles for the wrap-up dialog

This provides a middle ground between letting an agent run indefinitely
and hard-stopping it - the agent can summarize findings and properly
report back to its parent via agent_finish or finish_scan.
This commit is contained in:
Sayo 2026-01-04 16:18:05 +04:00
parent 706bb193c0
commit 39c94bfeec
3 changed files with 265 additions and 1 deletions

View file

@ -596,6 +596,76 @@ StopAgentScreen {
border: none;
}
WrapUpAgentScreen {
align: center middle;
background: $background 0%;
}
#wrap_up_agent_dialog {
grid-size: 1;
grid-gutter: 1;
grid-rows: auto auto auto;
padding: 1;
width: 40;
height: auto;
border: round #f59e0b;
background: #1a1a1a 98%;
}
#wrap_up_agent_title {
color: #f59e0b;
text-style: bold;
text-align: center;
width: 100%;
margin-bottom: 0;
}
#wrap_up_agent_description {
color: #a3a3a3;
text-align: center;
width: 100%;
margin-bottom: 0;
}
#wrap_up_agent_buttons {
grid-size: 2;
grid-gutter: 1;
grid-columns: 1fr 1fr;
width: 100%;
height: 1;
}
#wrap_up_agent_buttons Button {
height: 1;
min-height: 1;
border: none;
text-style: bold;
}
#wrap_up_agent {
background: transparent;
color: #f59e0b;
border: none;
}
#wrap_up_agent:hover, #wrap_up_agent:focus {
background: #f59e0b;
color: #1a1a1a;
border: none;
}
#cancel_wrap_up {
background: transparent;
color: #737373;
border: none;
}
#cancel_wrap_up:hover, #cancel_wrap_up:focus {
background: rgb(54, 54, 54);
color: #ffffff;
border: none;
}
QuitScreen {
align: center middle;
background: $background 0%;

View file

@ -157,7 +157,8 @@ class HelpScreen(ModalScreen): # type: ignore[misc]
yield Grid(
Label("🦉 Strix Help", id="help_title"),
Label(
"F1 Help\nCtrl+Q/C Quit\nESC Stop Agent\n"
"F1 Help\nCtrl+Q/C Quit\nESC Stop Agent (hard stop)\n"
"W Wrap Up Agent (graceful)\n"
"Enter Send message to agent\nTab Switch panels\n↑/↓ Navigate tree",
id="help_content",
),
@ -217,6 +218,59 @@ class StopAgentScreen(ModalScreen): # type: ignore[misc]
self.app.pop_screen()
class WrapUpAgentScreen(ModalScreen): # type: ignore[misc]
def __init__(self, agent_name: str, agent_id: str):
super().__init__()
self.agent_name = agent_name
self.agent_id = agent_id
def compose(self) -> ComposeResult:
yield Grid(
Label(f"📝 Wrap up '{self.agent_name}'?", id="wrap_up_agent_title"),
Label(
"Agent will finish current work and report findings.",
id="wrap_up_agent_description",
),
Grid(
Button("Yes", variant="warning", id="wrap_up_agent"),
Button("No", variant="default", id="cancel_wrap_up"),
id="wrap_up_agent_buttons",
),
id="wrap_up_agent_dialog",
)
def on_mount(self) -> None:
cancel_button = self.query_one("#cancel_wrap_up", Button)
cancel_button.focus()
def on_key(self, event: events.Key) -> None:
if event.key in ("left", "right", "up", "down"):
focused = self.focused
if focused and focused.id == "wrap_up_agent":
cancel_button = self.query_one("#cancel_wrap_up", Button)
cancel_button.focus()
else:
wrap_up_button = self.query_one("#wrap_up_agent", Button)
wrap_up_button.focus()
event.prevent_default()
elif event.key == "enter":
focused = self.focused
if focused and isinstance(focused, Button):
focused.press()
event.prevent_default()
elif event.key == "escape":
self.app.pop_screen()
event.prevent_default()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "wrap_up_agent":
self.app.action_confirm_wrap_up_agent(self.agent_id)
else:
self.app.pop_screen()
class QuitScreen(ModalScreen): # type: ignore[misc]
def compose(self) -> ComposeResult:
yield Grid(
@ -272,6 +326,7 @@ class StrixTUIApp(App): # type: ignore[misc]
Binding("ctrl+q", "request_quit", "Quit", priority=True),
Binding("ctrl+c", "request_quit", "Quit", priority=True),
Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
Binding("w", "wrap_up_selected_agent", "Wrap Up Agent", priority=True),
]
def __init__(self, args: argparse.Namespace):
@ -1227,6 +1282,69 @@ class StrixTUIApp(App): # type: ignore[misc]
logging.exception(f"Failed to stop agent {agent_id}")
def action_wrap_up_selected_agent(self) -> None:
if (
self.show_splash
or not self.is_mounted
or len(self.screen_stack) > 1
or not self.selected_agent_id
):
return
agent_name, should_wrap_up = self._validate_agent_for_wrap_up()
if not should_wrap_up:
return
try:
self.query_one("#main_container")
except (ValueError, Exception):
return
self.push_screen(WrapUpAgentScreen(agent_name, self.selected_agent_id))
def _validate_agent_for_wrap_up(self) -> tuple[str, bool]:
agent_name = "Unknown Agent"
try:
if self.tracer and self.selected_agent_id in self.tracer.agents:
agent_data = self.tracer.agents[self.selected_agent_id]
agent_name = agent_data.get("name", "Unknown Agent")
agent_status = agent_data.get("status", "running")
if agent_status not in ["running", "waiting"]:
return agent_name, False
return agent_name, True
except (KeyError, AttributeError, ValueError) as e:
import logging
logging.warning(f"Failed to validate agent for wrap-up: {e}")
return agent_name, False
def action_confirm_wrap_up_agent(self, agent_id: str) -> None:
self.pop_screen()
try:
from strix.tools.agents_graph.agents_graph_actions import wrap_up_agent
result = wrap_up_agent(agent_id)
import logging
if result.get("success"):
logging.info(f"Wrap-up request sent to agent: {result.get('message', 'Unknown')}")
else:
logging.warning(
f"Failed to send wrap-up request: {result.get('error', 'Unknown error')}"
)
except Exception:
import logging
logging.exception(f"Failed to wrap up agent {agent_id}")
def action_custom_quit(self) -> None:
for agent_id in list(self._agent_verb_timers.keys()):
self._stop_agent_verb_timer(agent_id)

View file

@ -530,6 +530,82 @@ def stop_agent(agent_id: str) -> dict[str, Any]:
}
def wrap_up_agent(agent_id: str) -> dict[str, Any]:
"""Request an agent to gracefully wrap up its work and report findings."""
try:
if agent_id not in _agent_graph["nodes"]:
return {
"success": False,
"error": f"Agent '{agent_id}' not found in graph",
"agent_id": agent_id,
}
agent_node = _agent_graph["nodes"][agent_id]
if agent_node["status"] in ["completed", "error", "failed", "stopped"]:
return {
"success": True,
"message": f"Agent '{agent_node['name']}' has already finished",
"agent_id": agent_id,
"previous_status": agent_node["status"],
}
# Send a wrap-up message to the agent
wrap_up_message = """<wrap_up_request>
<priority>URGENT</priority>
<instruction>
The user has requested that you wrap up your current work.
You have approximately 3-5 iterations to:
1. Complete or pause any critical in-progress work
2. Summarize your findings and progress so far
3. Call agent_finish (if you are a subagent) or finish_scan (if you are the root agent)
to properly report your results
Do NOT start any new major tasks. Focus on concluding your work gracefully.
</instruction>
</wrap_up_request>"""
if agent_id not in _agent_messages:
_agent_messages[agent_id] = []
from uuid import uuid4
_agent_messages[agent_id].append(
{
"id": f"wrapup_{uuid4().hex[:8]}",
"from": "user",
"to": agent_id,
"content": wrap_up_message,
"message_type": "instruction",
"priority": "urgent",
"timestamp": datetime.now(UTC).isoformat(),
"delivered": True,
"read": False,
}
)
# If agent is waiting, resume it so it can process the wrap-up request
if agent_id in _agent_states:
agent_state = _agent_states[agent_id]
if agent_state.is_waiting_for_input():
agent_state.resume_from_waiting()
return {
"success": True,
"message": f"Wrap-up request sent to agent '{agent_node['name']}'",
"agent_id": agent_id,
"agent_name": agent_node["name"],
"note": "Agent will gracefully finish and report its findings",
}
except Exception as e: # noqa: BLE001
return {
"success": False,
"error": f"Failed to send wrap-up request: {e}",
"agent_id": agent_id,
}
def send_user_message_to_agent(agent_id: str, message: str) -> dict[str, Any]:
try:
if agent_id not in _agent_graph["nodes"]: