Skip to content

Commit ea5b9f8

Browse files
jwesleyeclaude
andcommitted
feat: add SmartStdoutFilter to allow tool prompts while suppressing double-output
Implements pattern-based stdout filtering to solve the double-output issue while preserving interactive tool functionality (CRUD confirmations). Changes: - Add SmartStdoutFilter class with regex-based prompt detection - Detects interactive patterns: ?, Y/n:, [options], etc. - Passes prompts through to real stdout - Suppresses accumulated agent output during streaming - Update response_streamer.py to use SmartStdoutFilter - Change default suppress_agent_stdout back to true - Update config comments and wizard help text - Export SmartStdoutFilter from components module This resolves the issue where stdout suppression (which fixed double-output) was also suppressing CRUD tool Y/n confirmation prompts from BOAT/COAT tools. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent e472e96 commit ea5b9f8

7 files changed

Lines changed: 137 additions & 28 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "basic-agent-chat-loop"
7-
version = "1.7.2"
7+
version = "1.7.3"
88
description = "Feature-rich interactive CLI for AWS Strands agents with token tracking, prompt templates, aliases, and configuration"
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/basic_agent_chat_loop/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
agent aliases, and extensive configuration options.
55
"""
66

7-
__version__ = "1.7.2"
7+
__version__ = "1.7.3"
88

99
from .chat_config import ChatConfig
1010
from .chat_loop import ChatLoop

src/basic_agent_chat_loop/chat_config.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,10 @@ class ChatConfig:
5454
"retry_delay": 2.0,
5555
"timeout": 120.0,
5656
"spinner_style": "dots",
57-
# Suppress agent library stdout during streaming
58-
# WARNING: May hide interactive tool prompts (confirmations, etc)
59-
# Only enable if you experience double-output and don't use
60-
# interactive tools
61-
"suppress_agent_stdout": False,
57+
# Suppress agent library stdout during streaming using smart filter
58+
# Smart filter allows interactive tool prompts (Y/n) to pass through
59+
# while suppressing accumulated agent output
60+
"suppress_agent_stdout": True,
6261
},
6362
"ui": {
6463
"show_banner": True,
@@ -380,10 +379,10 @@ def initialize_default_config() -> Path:
380379
retry_delay: 2.0 # Seconds to wait between retries
381380
timeout: 120.0 # Request timeout in seconds
382381
spinner_style: dots # Thinking indicator style (dots, line, arc, etc.)
383-
suppress_agent_stdout: false # Suppress agent library stdout during streaming
384-
# WARNING: May hide interactive tool prompts!
385-
# Only enable if you experience double-output AND
386-
# your agent doesn't use interactive tools (CRUD confirmations)
382+
suppress_agent_stdout: true # Suppress agent library stdout during streaming
383+
# Uses smart filter to allow interactive prompts
384+
# (Y/n confirmations) through while suppressing
385+
# other output
387386
388387
# ============================================================================
389388
# UI - User interface preferences

src/basic_agent_chat_loop/components/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .session_persister import SessionPersister
2525
from .session_restorer import SessionRestorer
2626
from .session_state import SessionState
27+
from .smart_stdout_filter import SmartStdoutFilter
2728
from .streaming_event_parser import StreamingEventParser
2829
from .template_manager import TemplateManager
2930
from .token_tracker import TokenTracker
@@ -49,6 +50,7 @@
4950
"SessionPersister",
5051
"SessionRestorer",
5152
"SessionState",
53+
"SmartStdoutFilter",
5254
"StatusBar",
5355
"StreamingEventParser",
5456
"TemplateManager",

src/basic_agent_chat_loop/components/config_wizard.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def reset_config_to_defaults() -> Optional[Path]:
8787
"retry_delay": 2.0,
8888
"timeout": 120.0,
8989
"spinner_style": "dots",
90-
"suppress_agent_stdout": False,
90+
"suppress_agent_stdout": True,
9191
},
9292
"paths": {
9393
"log_location": "~/.chat_loop_logs",
@@ -639,18 +639,18 @@ def _configure_behavior(self):
639639

640640
# suppress_agent_stdout
641641
current_suppress = (
642-
self.current_config.get("behavior.suppress_agent_stdout", False)
642+
self.current_config.get("behavior.suppress_agent_stdout", True)
643643
if self.current_config
644-
else False
644+
else True
645645
)
646646
self.config["behavior"]["suppress_agent_stdout"] = self._prompt_bool(
647647
"\nSuppress agent library streaming output?",
648648
default=current_suppress,
649649
help_text=(
650-
"Prevents double-output from some agent libraries. "
651-
"WARNING: May hide interactive tool prompts (CRUD confirmations). "
652-
"Recommended: no (unless you experience double-output and don't "
653-
"use interactive tools)"
650+
"Prevents double-output from agent libraries using smart filter. "
651+
"The filter allows interactive tool prompts (Y/n confirmations) "
652+
"to pass through while suppressing accumulated output. "
653+
"Recommended: yes"
654654
),
655655
)
656656

src/basic_agent_chat_loop/components/response_streamer.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@
1212
"""
1313

1414
import asyncio
15-
import io
1615
import logging
1716
import sys
1817
import time
1918
from datetime import datetime
2019
from typing import TYPE_CHECKING, Any, Optional
2120

2221
from .response_renderer import ResponseRenderer
22+
from .smart_stdout_filter import SmartStdoutFilter
2323
from .streaming_event_parser import StreamingEventParser
2424
from .token_tracker import TokenTracker
2525
from .usage_extractor import UsageExtractor
@@ -189,17 +189,21 @@ async def stream_agent_response(
189189

190190
# Check if agent supports streaming
191191
if hasattr(self.agent, "stream_async"):
192-
# WORKAROUND: Suppress stdout during streaming to prevent
193-
# agent libraries from printing accumulated response text as
194-
# a side effect (discovered in beta.8 diagnostics - text
195-
# appears between event loop iterations)
196-
# NOTE: Suppression is only active BETWEEN iterations (during
192+
# WORKAROUND: Use smart filter during streaming to prevent
193+
# agent libraries from printing accumulated response text
194+
# (discovered in beta.8 diagnostics - text appears between
195+
# event loop iterations) while still allowing interactive tool
196+
# prompts to display.
197+
# NOTE: Filter is only active BETWEEN iterations (during
197198
# yield back to stream_async). During our event processing,
198-
# stdout is restored so tool calls and logging work normally.
199+
# stdout is restored so our output and logging work normally.
199200
old_stdout = None
201+
stdout_filter = None
200202
if self.suppress_agent_stdout:
201203
old_stdout = sys.stdout
202-
sys.stdout = io.StringIO()
204+
# Use SmartStdoutFilter to allow prompts through
205+
stdout_filter = SmartStdoutFilter(old_stdout)
206+
sys.stdout = stdout_filter
203207

204208
try:
205209
async for event in self.agent.stream_async(query):
@@ -230,13 +234,20 @@ async def stream_agent_response(
230234
# Display streaming text (renderer handles skip logic)
231235
self.response_renderer.render_streaming_text(text_to_add)
232236

233-
# Suppress stdout again before yielding back to stream_async
237+
# Re-enable smart filter before yielding back
234238
if self.suppress_agent_stdout:
235-
sys.stdout = io.StringIO()
239+
sys.stdout = stdout_filter
236240
finally:
237241
# Always restore stdout
238242
if self.suppress_agent_stdout and old_stdout is not None:
239243
sys.stdout = old_stdout
244+
# Log what was suppressed for debugging
245+
if stdout_filter:
246+
suppressed = stdout_filter.get_suppressed_output()
247+
if suppressed:
248+
logger.debug(
249+
f"Suppressed output: {len(suppressed)} chars"
250+
)
240251
else:
241252
# Fallback to non-streaming call if streaming not supported
242253
response = await asyncio.get_event_loop().run_in_executor(
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Smart stdout filter for selectively suppressing output.
2+
3+
Allows interactive prompts to pass through while suppressing
4+
accumulated agent output during streaming.
5+
"""
6+
7+
import io
8+
import re
9+
10+
11+
class SmartStdoutFilter(io.StringIO):
12+
"""Stdout wrapper that detects and passes through interactive prompts.
13+
14+
Suppresses accumulated agent output during streaming but allows
15+
tool confirmation prompts to display normally.
16+
"""
17+
18+
def __init__(self, real_stdout):
19+
"""Initialize the filter.
20+
21+
Args:
22+
real_stdout: The real sys.stdout to pass prompts through to
23+
"""
24+
super().__init__()
25+
self.real_stdout = real_stdout
26+
self.buffer = ""
27+
28+
# Patterns that indicate an interactive prompt
29+
self.prompt_patterns = [
30+
r'\? *$', # Ends with ? (e.g., "Delete file?")
31+
r'[Yy]/[Nn] *:? *$', # Contains Y/n (e.g., "Continue? Y/n:")
32+
r': *$', # Ends with : (e.g., "Enter name:")
33+
r'\[.*\] *:? *$', # Ends with [option]: (e.g., "Choose [Y/n]:")
34+
r'> *$', # Ends with > (shell-like prompt)
35+
]
36+
37+
def write(self, text: str) -> int:
38+
"""Write text, passing through if it looks like a prompt.
39+
40+
Args:
41+
text: Text to write
42+
43+
Returns:
44+
Number of characters written
45+
"""
46+
if not text:
47+
return 0
48+
49+
# Accumulate text for pattern detection
50+
self.buffer += text
51+
52+
# Check if this looks like an interactive prompt
53+
if self._looks_like_prompt():
54+
# Pass through to real stdout
55+
self.real_stdout.write(text)
56+
self.real_stdout.flush()
57+
return len(text)
58+
59+
# Otherwise suppress it (accumulate in buffer)
60+
return super().write(text)
61+
62+
def _looks_like_prompt(self) -> bool:
63+
"""Check if buffered text looks like an interactive prompt.
64+
65+
Returns:
66+
True if text appears to be a prompt
67+
"""
68+
# Get the last line (prompts are typically on their own line)
69+
lines = self.buffer.split('\n')
70+
last_line = lines[-1] if lines else ""
71+
72+
# Also check the previous line in case of multi-line prompts
73+
prev_line = lines[-2] if len(lines) > 1 else ""
74+
75+
# Check both lines against patterns
76+
for pattern in self.prompt_patterns:
77+
if re.search(pattern, last_line):
78+
return True
79+
if re.search(pattern, prev_line):
80+
return True
81+
82+
return False
83+
84+
def flush(self):
85+
"""Flush both buffers."""
86+
super().flush()
87+
self.real_stdout.flush()
88+
89+
def get_suppressed_output(self) -> str:
90+
"""Get the output that was suppressed.
91+
92+
Useful for debugging what was filtered out.
93+
94+
Returns:
95+
The suppressed output text
96+
"""
97+
return self.getvalue()

0 commit comments

Comments
 (0)