diff --git a/strix/interface/assets/tui_styles.tcss b/strix/interface/assets/tui_styles.tcss index c9424f43..20ccc5be 100644 --- a/strix/interface/assets/tui_styles.tcss +++ b/strix/interface/assets/tui_styles.tcss @@ -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%; diff --git a/strix/interface/tui.py b/strix/interface/tui.py index dc01f9f8..75263f39 100644 --- a/strix/interface/tui.py +++ b/strix/interface/tui.py @@ -157,7 +157,8 @@ def compose(self) -> ComposeResult: 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 @@ def on_button_pressed(self, event: Button.Pressed) -> None: 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 @@ def action_confirm_stop_agent(self, agent_id: str) -> None: 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) diff --git a/strix/tools/agents_graph/agents_graph_actions.py b/strix/tools/agents_graph/agents_graph_actions.py index e5b36b33..21e782da 100644 --- a/strix/tools/agents_graph/agents_graph_actions.py +++ b/strix/tools/agents_graph/agents_graph_actions.py @@ -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 = """ + URGENT + + 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. + +""" + + 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"]: