Skip to content

Commit 6b97abc

Browse files
Allow pausing agent runs via ESC
1 parent 843ac58 commit 6b97abc

File tree

2 files changed

+129
-5
lines changed

2 files changed

+129
-5
lines changed

mini_agent/agent.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,34 @@ def __init__(
7373
# Initialize logger
7474
self.logger = AgentLogger()
7575

76+
# Stop control flag, allows external interrupt requests
77+
self._stop_requested = False
78+
self._stop_notified = False
79+
self.current_step = 0
80+
self._run_active = False
81+
7682
def add_user_message(self, content: str):
7783
"""Add a user message to history."""
7884
self.messages.append(Message(role="user", content=content))
7985

86+
def request_stop(self):
87+
"""Signal the agent loop to stop at the next safe checkpoint."""
88+
self._stop_requested = True
89+
self._stop_notified = False
90+
91+
def _reset_stop_request(self):
92+
self._stop_requested = False
93+
self._stop_notified = False
94+
95+
def _check_stop_requested(self) -> bool:
96+
"""Return True if a stop was requested and emit a single notification."""
97+
if not self._stop_requested:
98+
return False
99+
if not self._stop_notified:
100+
print(f"\n{Colors.BRIGHT_YELLOW}⏸️ Agent paused by user (press Enter to continue interacting).{Colors.RESET}\n")
101+
self._stop_notified = True
102+
return True
103+
80104
def _estimate_tokens(self) -> int:
81105
"""Accurately calculate token count for message history using tiktoken
82106
@@ -258,13 +282,20 @@ async def _create_summary(self, messages: list[Message], round_num: int) -> str:
258282

259283
async def run(self) -> str:
260284
"""Execute agent loop until task is complete or max steps reached."""
261-
# Start new run, initialize log file
262-
self.logger.start_new_run()
263-
print(f"{Colors.DIM}📝 Log file: {self.logger.get_log_file_path()}{Colors.RESET}")
285+
if not self._run_active:
286+
# Start new run, initialize log file
287+
self.logger.start_new_run()
288+
print(f"{Colors.DIM}📝 Log file: {self.logger.get_log_file_path()}{Colors.RESET}")
289+
self.current_step = 0
290+
self._run_active = True
264291

265-
step = 0
292+
step = self.current_step
293+
self._reset_stop_request()
266294

267295
while step < self.max_steps:
296+
if self._check_stop_requested():
297+
return "Agent run interrupted by user."
298+
268299
# Check and summarize message history to prevent context overflow
269300
await self._summarize_messages()
270301

@@ -296,8 +327,13 @@ async def run(self) -> str:
296327
else:
297328
error_msg = f"LLM call failed: {str(e)}"
298329
print(f"\n{Colors.BRIGHT_RED}❌ Error:{Colors.RESET} {error_msg}")
330+
self._run_active = False
331+
self.current_step = 0
299332
return error_msg
300333

334+
if self._check_stop_requested():
335+
return "Agent run interrupted by user."
336+
301337
# Log LLM response
302338
self.logger.log_response(
303339
content=response.content,
@@ -327,10 +363,15 @@ async def run(self) -> str:
327363

328364
# Check if task is complete (no tool calls)
329365
if not response.tool_calls:
366+
self._run_active = False
367+
self.current_step = 0
330368
return response.content
331369

332370
# Execute tool calls
333371
for tool_call in response.tool_calls:
372+
if self._check_stop_requested():
373+
return "Agent run interrupted by user."
374+
334375
tool_call_id = tool_call.id
335376
function_name = tool_call.function.name
336377
arguments = tool_call.function.arguments
@@ -403,10 +444,13 @@ async def run(self) -> str:
403444
self.messages.append(tool_msg)
404445

405446
step += 1
447+
self.current_step = step
406448

407449
# Max steps reached
408450
error_msg = f"Task couldn't be completed after {self.max_steps} steps."
409451
print(f"\n{Colors.BRIGHT_YELLOW}⚠️ {error_msg}{Colors.RESET}")
452+
self._run_active = False
453+
self.current_step = 0
410454
return error_msg
411455

412456
def get_history(self) -> list[Message]:

mini_agent/cli.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@
1111

1212
import argparse
1313
import asyncio
14+
import sys
1415
from datetime import datetime
1516
from pathlib import Path
1617
from typing import List
1718

19+
try:
20+
import termios
21+
import tty
22+
except ImportError: # pragma: no cover - Windows fallback
23+
termios = None # type: ignore[assignment]
24+
tty = None # type: ignore[assignment]
25+
1826
from prompt_toolkit import PromptSession
1927
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
2028
from prompt_toolkit.completion import WordCompleter
@@ -70,6 +78,75 @@ class Colors:
7078
BG_BLUE = "\033[44m"
7179

7280

81+
class EscapeKeyListener:
82+
"""Listen for ESC key presses during agent execution to request a stop."""
83+
84+
def __init__(self, agent: Agent):
85+
self.agent = agent
86+
self._loop: asyncio.AbstractEventLoop | None = None
87+
self._fd: int | None = None
88+
self._old_settings = None
89+
self._cbreak_enabled = False
90+
self._reader_registered = False
91+
92+
async def __aenter__(self):
93+
self._loop = asyncio.get_running_loop()
94+
await self._loop.run_in_executor(None, self._enable_cbreak_mode)
95+
if (
96+
self._cbreak_enabled
97+
and self._loop
98+
and self._fd is not None
99+
and hasattr(self._loop, "add_reader")
100+
):
101+
self._loop.add_reader(self._fd, self._handle_keypress)
102+
self._reader_registered = True
103+
return self
104+
105+
async def __aexit__(self, exc_type, exc, tb):
106+
if self._reader_registered and self._loop and self._fd is not None:
107+
self._loop.remove_reader(self._fd)
108+
self._reader_registered = False
109+
if self._loop:
110+
await self._loop.run_in_executor(None, self._restore_terminal)
111+
112+
def _enable_cbreak_mode(self):
113+
if termios is None or tty is None:
114+
return
115+
if not sys.stdin.isatty():
116+
return
117+
try:
118+
self._fd = sys.stdin.fileno()
119+
self._old_settings = termios.tcgetattr(self._fd)
120+
tty.setcbreak(self._fd)
121+
self._cbreak_enabled = True
122+
except Exception:
123+
self._cbreak_enabled = False
124+
125+
def _restore_terminal(self):
126+
if (
127+
self._cbreak_enabled
128+
and self._fd is not None
129+
and self._old_settings is not None
130+
and termios is not None
131+
):
132+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_settings)
133+
self._cbreak_enabled = False
134+
135+
def _handle_keypress(self):
136+
if not self._cbreak_enabled:
137+
return
138+
try:
139+
ch = sys.stdin.read(1)
140+
except Exception:
141+
return
142+
if ch == "\x1b": # ESC key
143+
print(f"\n{Colors.BRIGHT_YELLOW}⏹️ Escape detected, requesting agent pause...{Colors.RESET}")
144+
self.agent.request_stop()
145+
if self._loop and self._reader_registered and self._fd is not None:
146+
self._loop.remove_reader(self._fd)
147+
self._reader_registered = False
148+
149+
73150
def print_banner():
74151
"""Print welcome banner with proper alignment"""
75152
BOX_WIDTH = 58
@@ -105,13 +182,15 @@ def print_help():
105182
{Colors.BRIGHT_CYAN}Tab{Colors.RESET} - Auto-complete commands
106183
{Colors.BRIGHT_CYAN}↑/↓{Colors.RESET} - Browse command history
107184
{Colors.BRIGHT_CYAN}{Colors.RESET} - Accept auto-suggestion
185+
{Colors.BRIGHT_CYAN}Esc{Colors.RESET} - Pause the current agent run
108186
109187
{Colors.BOLD}{Colors.BRIGHT_YELLOW}Usage:{Colors.RESET}
110188
- Enter your task directly, Agent will help you complete it
111189
- Agent remembers all conversation content in this session
112190
- Use {Colors.BRIGHT_GREEN}/clear{Colors.RESET} to start a new session
113191
- Press {Colors.BRIGHT_CYAN}Enter{Colors.RESET} to submit your message
114192
- Use {Colors.BRIGHT_CYAN}Ctrl+J{Colors.RESET} to insert line breaks within your message
193+
- Press {Colors.BRIGHT_CYAN}Esc{Colors.RESET} anytime during execution to stop the agent
115194
"""
116195
print(help_text)
117196

@@ -551,7 +630,8 @@ def _(event):
551630
# Run Agent
552631
print(f"\n{Colors.BRIGHT_BLUE}Agent{Colors.RESET} {Colors.DIM}{Colors.RESET} {Colors.DIM}Thinking...{Colors.RESET}\n")
553632
agent.add_user_message(user_input)
554-
_ = await agent.run()
633+
async with EscapeKeyListener(agent):
634+
_ = await agent.run()
555635

556636
# Visual separation - keep it simple like the reference code
557637
print(f"\n{Colors.DIM}{'─' * 60}{Colors.RESET}\n")

0 commit comments

Comments
 (0)