fast-agent-mcp 0.3.16__py3-none-any.whl → 0.3.17__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fast-agent-mcp might be problematic. Click here for more details.
- fast_agent/agents/mcp_agent.py +1 -1
- fast_agent/cli/constants.py +2 -0
- fast_agent/cli/main.py +1 -1
- fast_agent/interfaces.py +4 -0
- fast_agent/llm/model_database.py +4 -1
- fast_agent/llm/model_factory.py +4 -2
- fast_agent/llm/model_info.py +19 -43
- fast_agent/llm/provider/google/llm_google_native.py +238 -7
- fast_agent/llm/provider/openai/llm_openai.py +229 -32
- fast_agent/skills/registry.py +17 -9
- fast_agent/tools/shell_runtime.py +4 -4
- fast_agent/ui/console_display.py +43 -1259
- fast_agent/ui/enhanced_prompt.py +26 -12
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +103 -45
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/streaming.py +638 -0
- fast_agent/ui/tool_display.py +417 -0
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.17.dist-info}/METADATA +1 -1
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.17.dist-info}/RECORD +23 -19
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.17.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.17.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.16.dist-info → fast_agent_mcp-0.3.17.dist-info}/licenses/LICENSE +0 -0
fast_agent/ui/console_display.py
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import math
|
|
3
|
-
import time
|
|
4
1
|
from contextlib import contextmanager
|
|
5
|
-
from enum import Enum
|
|
6
2
|
from json import JSONDecodeError
|
|
7
|
-
from typing import TYPE_CHECKING, Any,
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Iterator, List, Mapping, Optional, Union
|
|
8
4
|
|
|
9
5
|
from mcp.types import CallToolResult
|
|
10
|
-
from rich.live import Live
|
|
11
6
|
from rich.markdown import Markdown
|
|
12
7
|
from rich.panel import Panel
|
|
13
8
|
from rich.text import Text
|
|
@@ -16,7 +11,7 @@ from fast_agent.config import Settings
|
|
|
16
11
|
from fast_agent.constants import REASONING
|
|
17
12
|
from fast_agent.core.logging.logger import get_logger
|
|
18
13
|
from fast_agent.ui import console
|
|
19
|
-
from fast_agent.ui.
|
|
14
|
+
from fast_agent.ui.markdown_helpers import prepare_markdown_content
|
|
20
15
|
from fast_agent.ui.mcp_ui_utils import UILink
|
|
21
16
|
from fast_agent.ui.mermaid_utils import (
|
|
22
17
|
MermaidDiagram,
|
|
@@ -24,7 +19,17 @@ from fast_agent.ui.mermaid_utils import (
|
|
|
24
19
|
detect_diagram_type,
|
|
25
20
|
extract_mermaid_diagrams,
|
|
26
21
|
)
|
|
27
|
-
from fast_agent.ui.
|
|
22
|
+
from fast_agent.ui.message_primitives import MESSAGE_CONFIGS, MessageType
|
|
23
|
+
from fast_agent.ui.streaming import (
|
|
24
|
+
NullStreamingHandle as _NullStreamingHandle,
|
|
25
|
+
)
|
|
26
|
+
from fast_agent.ui.streaming import (
|
|
27
|
+
StreamingHandle,
|
|
28
|
+
)
|
|
29
|
+
from fast_agent.ui.streaming import (
|
|
30
|
+
StreamingMessageHandle as _StreamingMessageHandle,
|
|
31
|
+
)
|
|
32
|
+
from fast_agent.ui.tool_display import ToolDisplay
|
|
28
33
|
|
|
29
34
|
if TYPE_CHECKING:
|
|
30
35
|
from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
|
|
@@ -34,190 +39,6 @@ logger = get_logger(__name__)
|
|
|
34
39
|
|
|
35
40
|
CODE_STYLE = "native"
|
|
36
41
|
|
|
37
|
-
MARKDOWN_STREAM_TARGET_RATIO = 0.7
|
|
38
|
-
MARKDOWN_STREAM_REFRESH_PER_SECOND = 4
|
|
39
|
-
MARKDOWN_STREAM_HEIGHT_FUDGE = 1
|
|
40
|
-
PLAIN_STREAM_TARGET_RATIO = 0.9
|
|
41
|
-
PLAIN_STREAM_REFRESH_PER_SECOND = 20
|
|
42
|
-
PLAIN_STREAM_HEIGHT_FUDGE = 1
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class MessageType(Enum):
|
|
46
|
-
"""Types of messages that can be displayed."""
|
|
47
|
-
|
|
48
|
-
USER = "user"
|
|
49
|
-
ASSISTANT = "assistant"
|
|
50
|
-
SYSTEM = "system"
|
|
51
|
-
TOOL_CALL = "tool_call"
|
|
52
|
-
TOOL_RESULT = "tool_result"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# Configuration for each message type
|
|
56
|
-
MESSAGE_CONFIGS = {
|
|
57
|
-
MessageType.USER: {
|
|
58
|
-
"block_color": "blue",
|
|
59
|
-
"arrow": "▶",
|
|
60
|
-
"arrow_style": "dim blue",
|
|
61
|
-
"highlight_color": "blue",
|
|
62
|
-
},
|
|
63
|
-
MessageType.ASSISTANT: {
|
|
64
|
-
"block_color": "green",
|
|
65
|
-
"arrow": "◀",
|
|
66
|
-
"arrow_style": "dim green",
|
|
67
|
-
"highlight_color": "bright_green",
|
|
68
|
-
},
|
|
69
|
-
MessageType.SYSTEM: {
|
|
70
|
-
"block_color": "yellow",
|
|
71
|
-
"arrow": "●",
|
|
72
|
-
"arrow_style": "dim yellow",
|
|
73
|
-
"highlight_color": "bright_yellow",
|
|
74
|
-
},
|
|
75
|
-
MessageType.TOOL_CALL: {
|
|
76
|
-
"block_color": "magenta",
|
|
77
|
-
"arrow": "◀",
|
|
78
|
-
"arrow_style": "dim magenta",
|
|
79
|
-
"highlight_color": "magenta",
|
|
80
|
-
},
|
|
81
|
-
MessageType.TOOL_RESULT: {
|
|
82
|
-
"block_color": "magenta", # Can be overridden to red if error
|
|
83
|
-
"arrow": "▶",
|
|
84
|
-
"arrow_style": "dim magenta",
|
|
85
|
-
"highlight_color": "magenta",
|
|
86
|
-
},
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
HTML_ESCAPE_CHARS = {
|
|
90
|
-
"&": "&",
|
|
91
|
-
"<": "<",
|
|
92
|
-
">": ">",
|
|
93
|
-
'"': """,
|
|
94
|
-
"'": "'",
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def _prepare_markdown_content(content: str, escape_xml: bool = True) -> str:
|
|
99
|
-
"""Prepare content for markdown rendering by escaping HTML/XML tags
|
|
100
|
-
while preserving code blocks and inline code.
|
|
101
|
-
|
|
102
|
-
This ensures XML/HTML tags are displayed as visible text rather than
|
|
103
|
-
being interpreted as markup by the markdown renderer.
|
|
104
|
-
|
|
105
|
-
Uses markdown-it parser to properly identify code regions, avoiding
|
|
106
|
-
the issues with regex-based approaches (e.g., backticks inside fenced
|
|
107
|
-
code blocks being misidentified as inline code).
|
|
108
|
-
"""
|
|
109
|
-
if not escape_xml or not isinstance(content, str):
|
|
110
|
-
return content
|
|
111
|
-
|
|
112
|
-
# Import markdown-it for proper parsing
|
|
113
|
-
from markdown_it import MarkdownIt
|
|
114
|
-
|
|
115
|
-
# Parse the markdown to identify code regions
|
|
116
|
-
parser = MarkdownIt()
|
|
117
|
-
try:
|
|
118
|
-
tokens = parser.parse(content)
|
|
119
|
-
except Exception:
|
|
120
|
-
# If parsing fails, fall back to escaping everything
|
|
121
|
-
# (better safe than corrupting content)
|
|
122
|
-
result = content
|
|
123
|
-
for char, replacement in HTML_ESCAPE_CHARS.items():
|
|
124
|
-
result = result.replace(char, replacement)
|
|
125
|
-
return result
|
|
126
|
-
|
|
127
|
-
# Collect protected ranges from tokens
|
|
128
|
-
protected_ranges = []
|
|
129
|
-
lines = content.split("\n")
|
|
130
|
-
|
|
131
|
-
def _flatten_tokens(tokens):
|
|
132
|
-
"""Recursively flatten token tree."""
|
|
133
|
-
for token in tokens:
|
|
134
|
-
yield token
|
|
135
|
-
if token.children:
|
|
136
|
-
yield from _flatten_tokens(token.children)
|
|
137
|
-
|
|
138
|
-
# Process all tokens to find code blocks and inline code
|
|
139
|
-
for token in _flatten_tokens(tokens):
|
|
140
|
-
if token.map is not None:
|
|
141
|
-
# Block-level tokens with line mapping (fence, code_block)
|
|
142
|
-
if token.type in ("fence", "code_block"):
|
|
143
|
-
start_line = token.map[0]
|
|
144
|
-
end_line = token.map[1]
|
|
145
|
-
start_pos = sum(len(line) + 1 for line in lines[:start_line])
|
|
146
|
-
end_pos = sum(len(line) + 1 for line in lines[:end_line])
|
|
147
|
-
protected_ranges.append((start_pos, end_pos))
|
|
148
|
-
|
|
149
|
-
# Inline code tokens don't have map, but have content
|
|
150
|
-
if token.type == "code_inline":
|
|
151
|
-
# For inline code, we need to find its position in the source
|
|
152
|
-
# The token has the content, but we need to search for it
|
|
153
|
-
# We'll look for the pattern `content` in the content string
|
|
154
|
-
code_content = token.content
|
|
155
|
-
if code_content:
|
|
156
|
-
# Search for this inline code in the content
|
|
157
|
-
# We need to account for the backticks: `content`
|
|
158
|
-
pattern = f"`{code_content}`"
|
|
159
|
-
start = 0
|
|
160
|
-
while True:
|
|
161
|
-
pos = content.find(pattern, start)
|
|
162
|
-
if pos == -1:
|
|
163
|
-
break
|
|
164
|
-
# Check if this position is already in a protected range
|
|
165
|
-
in_protected = any(s <= pos < e for s, e in protected_ranges)
|
|
166
|
-
if not in_protected:
|
|
167
|
-
protected_ranges.append((pos, pos + len(pattern)))
|
|
168
|
-
start = pos + len(pattern)
|
|
169
|
-
|
|
170
|
-
# Check for incomplete code blocks (streaming scenario)
|
|
171
|
-
# Count opening vs closing fences
|
|
172
|
-
import re
|
|
173
|
-
|
|
174
|
-
fence_pattern = r"^```"
|
|
175
|
-
fences = list(re.finditer(fence_pattern, content, re.MULTILINE))
|
|
176
|
-
|
|
177
|
-
# If we have an odd number of fences, the last one is incomplete
|
|
178
|
-
if len(fences) % 2 == 1:
|
|
179
|
-
# Protect from the last fence to the end
|
|
180
|
-
last_fence_pos = fences[-1].start()
|
|
181
|
-
# Only add if not already protected
|
|
182
|
-
in_protected = any(s <= last_fence_pos < e for s, e in protected_ranges)
|
|
183
|
-
if not in_protected:
|
|
184
|
-
protected_ranges.append((last_fence_pos, len(content)))
|
|
185
|
-
|
|
186
|
-
# Sort and merge overlapping ranges
|
|
187
|
-
protected_ranges.sort(key=lambda x: x[0])
|
|
188
|
-
|
|
189
|
-
# Merge overlapping ranges
|
|
190
|
-
merged_ranges = []
|
|
191
|
-
for start, end in protected_ranges:
|
|
192
|
-
if merged_ranges and start <= merged_ranges[-1][1]:
|
|
193
|
-
# Overlapping or adjacent - merge
|
|
194
|
-
merged_ranges[-1] = (merged_ranges[-1][0], max(end, merged_ranges[-1][1]))
|
|
195
|
-
else:
|
|
196
|
-
merged_ranges.append((start, end))
|
|
197
|
-
|
|
198
|
-
# Build the escaped content
|
|
199
|
-
result = []
|
|
200
|
-
last_end = 0
|
|
201
|
-
|
|
202
|
-
for start, end in merged_ranges:
|
|
203
|
-
# Escape everything outside protected ranges
|
|
204
|
-
unprotected_text = content[last_end:start]
|
|
205
|
-
for char, replacement in HTML_ESCAPE_CHARS.items():
|
|
206
|
-
unprotected_text = unprotected_text.replace(char, replacement)
|
|
207
|
-
result.append(unprotected_text)
|
|
208
|
-
|
|
209
|
-
# Keep protected ranges (code blocks) as-is
|
|
210
|
-
result.append(content[start:end])
|
|
211
|
-
last_end = end
|
|
212
|
-
|
|
213
|
-
# Escape any remaining content after the last protected range
|
|
214
|
-
remainder_text = content[last_end:]
|
|
215
|
-
for char, replacement in HTML_ESCAPE_CHARS.items():
|
|
216
|
-
remainder_text = remainder_text.replace(char, replacement)
|
|
217
|
-
result.append(remainder_text)
|
|
218
|
-
|
|
219
|
-
return "".join(result)
|
|
220
|
-
|
|
221
42
|
|
|
222
43
|
class ConsoleDisplay:
|
|
223
44
|
"""
|
|
@@ -225,6 +46,8 @@ class ConsoleDisplay:
|
|
|
225
46
|
This centralizes the UI display logic used by LLM implementations.
|
|
226
47
|
"""
|
|
227
48
|
|
|
49
|
+
CODE_STYLE = CODE_STYLE
|
|
50
|
+
|
|
228
51
|
def __init__(self, config: Settings | None = None) -> None:
|
|
229
52
|
"""
|
|
230
53
|
Initialize the console display handler.
|
|
@@ -235,6 +58,11 @@ class ConsoleDisplay:
|
|
|
235
58
|
self.config = config
|
|
236
59
|
self._markup = config.logger.enable_markup if config else True
|
|
237
60
|
self._escape_xml = True
|
|
61
|
+
self._tool_display = ToolDisplay(self)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def code_style(self) -> str:
|
|
65
|
+
return CODE_STYLE
|
|
238
66
|
|
|
239
67
|
def resolve_streaming_preferences(self) -> tuple[bool, str]:
|
|
240
68
|
"""Return whether streaming is enabled plus the active mode."""
|
|
@@ -365,7 +193,6 @@ class ConsoleDisplay:
|
|
|
365
193
|
import json
|
|
366
194
|
import re
|
|
367
195
|
|
|
368
|
-
from rich.markdown import Markdown
|
|
369
196
|
from rich.pretty import Pretty
|
|
370
197
|
from rich.syntax import Syntax
|
|
371
198
|
|
|
@@ -411,7 +238,7 @@ class ConsoleDisplay:
|
|
|
411
238
|
# Check for markdown markers before deciding to use markdown rendering
|
|
412
239
|
if any(marker in content for marker in ["##", "**", "*", "`", "---", "###"]):
|
|
413
240
|
# Has markdown markers - render as markdown with escaping
|
|
414
|
-
prepared_content =
|
|
241
|
+
prepared_content = prepare_markdown_content(content, self._escape_xml)
|
|
415
242
|
md = Markdown(prepared_content, code_theme=CODE_STYLE)
|
|
416
243
|
console.console.print(md, markup=self._markup)
|
|
417
244
|
else:
|
|
@@ -431,7 +258,7 @@ class ConsoleDisplay:
|
|
|
431
258
|
# Check if it looks like markdown
|
|
432
259
|
if any(marker in content for marker in ["##", "**", "*", "`", "---", "###"]):
|
|
433
260
|
# Escape HTML/XML tags while preserving code blocks
|
|
434
|
-
prepared_content =
|
|
261
|
+
prepared_content = prepare_markdown_content(content, self._escape_xml)
|
|
435
262
|
md = Markdown(prepared_content, code_theme=CODE_STYLE)
|
|
436
263
|
# Markdown handles its own styling, don't apply style
|
|
437
264
|
console.console.print(md, markup=self._markup)
|
|
@@ -459,10 +286,6 @@ class ConsoleDisplay:
|
|
|
459
286
|
# We need to handle the main content (which may have markdown)
|
|
460
287
|
# and any styled segments that were appended
|
|
461
288
|
|
|
462
|
-
# For now, we'll render the entire content with markdown support
|
|
463
|
-
# This means extracting each span and handling it appropriately
|
|
464
|
-
from rich.markdown import Markdown
|
|
465
|
-
|
|
466
289
|
# If the Text object has multiple spans with different styles,
|
|
467
290
|
# we need to be careful about how we render them
|
|
468
291
|
if len(content._spans) > 1:
|
|
@@ -479,9 +302,7 @@ class ConsoleDisplay:
|
|
|
479
302
|
marker in markdown_part for marker in ["##", "**", "*", "`", "---", "###"]
|
|
480
303
|
):
|
|
481
304
|
# Render markdown part
|
|
482
|
-
prepared_content =
|
|
483
|
-
markdown_part, self._escape_xml
|
|
484
|
-
)
|
|
305
|
+
prepared_content = prepare_markdown_content(markdown_part, self._escape_xml)
|
|
485
306
|
md = Markdown(prepared_content, code_theme=CODE_STYLE)
|
|
486
307
|
console.console.print(md, markup=self._markup)
|
|
487
308
|
|
|
@@ -499,7 +320,7 @@ class ConsoleDisplay:
|
|
|
499
320
|
console.console.print(content, markup=self._markup)
|
|
500
321
|
else:
|
|
501
322
|
# Simple case: entire text should be rendered as markdown
|
|
502
|
-
prepared_content =
|
|
323
|
+
prepared_content = prepare_markdown_content(plain_text, self._escape_xml)
|
|
503
324
|
md = Markdown(prepared_content, code_theme=CODE_STYLE)
|
|
504
325
|
console.console.print(md, markup=self._markup)
|
|
505
326
|
else:
|
|
@@ -679,344 +500,35 @@ class ConsoleDisplay:
|
|
|
679
500
|
tool_name: str | None = None,
|
|
680
501
|
skybridge_config: "SkybridgeServerConfig | None" = None,
|
|
681
502
|
) -> None:
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
skybridge_config: Optional skybridge configuration for the server
|
|
689
|
-
"""
|
|
690
|
-
if not self.config or not self.config.logger.show_tools:
|
|
691
|
-
return
|
|
692
|
-
|
|
693
|
-
# Import content helpers
|
|
694
|
-
from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content
|
|
695
|
-
|
|
696
|
-
# Analyze content to determine display format and status
|
|
697
|
-
content = result.content
|
|
698
|
-
structured_content = getattr(result, "structuredContent", None)
|
|
699
|
-
has_structured = structured_content is not None
|
|
700
|
-
|
|
701
|
-
# Determine if this is a skybridge tool
|
|
702
|
-
is_skybridge_tool = False
|
|
703
|
-
skybridge_resource_uri = None
|
|
704
|
-
if has_structured and tool_name and skybridge_config:
|
|
705
|
-
# Check if this tool is a valid skybridge tool
|
|
706
|
-
for tool_cfg in skybridge_config.tools:
|
|
707
|
-
if tool_cfg.tool_name == tool_name and tool_cfg.is_valid:
|
|
708
|
-
is_skybridge_tool = True
|
|
709
|
-
skybridge_resource_uri = tool_cfg.resource_uri
|
|
710
|
-
break
|
|
711
|
-
|
|
712
|
-
if result.isError:
|
|
713
|
-
status = "ERROR"
|
|
714
|
-
else:
|
|
715
|
-
# Check if it's a list with content blocks
|
|
716
|
-
if len(content) == 0:
|
|
717
|
-
status = "No Content"
|
|
718
|
-
elif len(content) == 1 and is_text_content(content[0]):
|
|
719
|
-
text_content = get_text(content[0])
|
|
720
|
-
char_count = len(text_content) if text_content else 0
|
|
721
|
-
status = f"Text Only {char_count} chars"
|
|
722
|
-
else:
|
|
723
|
-
text_count = sum(1 for item in content if is_text_content(item))
|
|
724
|
-
if text_count == len(content):
|
|
725
|
-
status = f"{len(content)} Text Blocks" if len(content) > 1 else "1 Text Block"
|
|
726
|
-
else:
|
|
727
|
-
status = (
|
|
728
|
-
f"{len(content)} Content Blocks" if len(content) > 1 else "1 Content Block"
|
|
729
|
-
)
|
|
730
|
-
|
|
731
|
-
# Build transport channel info for bottom bar
|
|
732
|
-
channel = getattr(result, "transport_channel", None)
|
|
733
|
-
bottom_metadata_items: List[str] = []
|
|
734
|
-
if channel:
|
|
735
|
-
# Format channel info for bottom bar
|
|
736
|
-
if channel == "post-json":
|
|
737
|
-
transport_info = "HTTP (JSON-RPC)"
|
|
738
|
-
elif channel == "post-sse":
|
|
739
|
-
transport_info = "HTTP (SSE)"
|
|
740
|
-
elif channel == "get":
|
|
741
|
-
transport_info = "Legacy SSE"
|
|
742
|
-
elif channel == "resumption":
|
|
743
|
-
transport_info = "Resumption"
|
|
744
|
-
elif channel == "stdio":
|
|
745
|
-
transport_info = "STDIO"
|
|
746
|
-
else:
|
|
747
|
-
transport_info = channel.upper()
|
|
748
|
-
|
|
749
|
-
bottom_metadata_items.append(transport_info)
|
|
750
|
-
|
|
751
|
-
elapsed = getattr(result, "transport_elapsed", None)
|
|
752
|
-
if isinstance(elapsed, (int, float)):
|
|
753
|
-
bottom_metadata_items.append(self._format_elapsed(float(elapsed)))
|
|
754
|
-
|
|
755
|
-
# Add structured content indicator if present
|
|
756
|
-
if has_structured:
|
|
757
|
-
bottom_metadata_items.append("Structured ■")
|
|
758
|
-
|
|
759
|
-
bottom_metadata = bottom_metadata_items or None
|
|
760
|
-
|
|
761
|
-
# Build right info (without channel info)
|
|
762
|
-
right_info = f"[dim]tool result - {status}[/dim]"
|
|
763
|
-
|
|
764
|
-
if has_structured:
|
|
765
|
-
# Handle structured content display manually to insert it before bottom separator
|
|
766
|
-
# Display main content without bottom separator
|
|
767
|
-
config = MESSAGE_CONFIGS[MessageType.TOOL_RESULT]
|
|
768
|
-
block_color = "red" if result.isError else config["block_color"]
|
|
769
|
-
arrow = config["arrow"]
|
|
770
|
-
arrow_style = config["arrow_style"]
|
|
771
|
-
left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}]"
|
|
772
|
-
if name:
|
|
773
|
-
left += f" [{block_color if not result.isError else 'red'}]{name}[/{block_color if not result.isError else 'red'}]"
|
|
774
|
-
|
|
775
|
-
# Top separator
|
|
776
|
-
self._create_combined_separator_status(left, right_info)
|
|
777
|
-
|
|
778
|
-
# Main content
|
|
779
|
-
self._display_content(
|
|
780
|
-
content, True, result.isError, MessageType.TOOL_RESULT, check_markdown_markers=False
|
|
781
|
-
)
|
|
782
|
-
|
|
783
|
-
# Structured content separator and display
|
|
784
|
-
console.console.print()
|
|
785
|
-
total_width = console.console.size.width
|
|
786
|
-
|
|
787
|
-
if is_skybridge_tool:
|
|
788
|
-
# Skybridge: magenta separator with resource URI
|
|
789
|
-
resource_label = (
|
|
790
|
-
f"skybridge resource: {skybridge_resource_uri}"
|
|
791
|
-
if skybridge_resource_uri
|
|
792
|
-
else "skybridge resource"
|
|
793
|
-
)
|
|
794
|
-
prefix = Text("─| ")
|
|
795
|
-
prefix.stylize("dim")
|
|
796
|
-
resource_text = Text(resource_label, style="magenta")
|
|
797
|
-
suffix = Text(" |")
|
|
798
|
-
suffix.stylize("dim")
|
|
799
|
-
|
|
800
|
-
separator_line = Text()
|
|
801
|
-
separator_line.append_text(prefix)
|
|
802
|
-
separator_line.append_text(resource_text)
|
|
803
|
-
separator_line.append_text(suffix)
|
|
804
|
-
remaining = total_width - separator_line.cell_len
|
|
805
|
-
if remaining > 0:
|
|
806
|
-
separator_line.append("─" * remaining, style="dim")
|
|
807
|
-
console.console.print(separator_line, markup=self._markup)
|
|
808
|
-
console.console.print()
|
|
809
|
-
|
|
810
|
-
# Display with bright syntax highlighting
|
|
811
|
-
import json
|
|
812
|
-
|
|
813
|
-
from rich.syntax import Syntax
|
|
814
|
-
|
|
815
|
-
json_str = json.dumps(structured_content, indent=2)
|
|
816
|
-
syntax_obj = Syntax(json_str, "json", theme=CODE_STYLE, background_color="default")
|
|
817
|
-
console.console.print(syntax_obj, markup=self._markup)
|
|
818
|
-
else:
|
|
819
|
-
# Regular tool: dim separator
|
|
820
|
-
prefix = Text("─| ")
|
|
821
|
-
prefix.stylize("dim")
|
|
822
|
-
label_text = Text("Structured Content", style="dim")
|
|
823
|
-
suffix = Text(" |")
|
|
824
|
-
suffix.stylize("dim")
|
|
825
|
-
|
|
826
|
-
separator_line = Text()
|
|
827
|
-
separator_line.append_text(prefix)
|
|
828
|
-
separator_line.append_text(label_text)
|
|
829
|
-
separator_line.append_text(suffix)
|
|
830
|
-
remaining = total_width - separator_line.cell_len
|
|
831
|
-
if remaining > 0:
|
|
832
|
-
separator_line.append("─" * remaining, style="dim")
|
|
833
|
-
console.console.print(separator_line, markup=self._markup)
|
|
834
|
-
console.console.print()
|
|
835
|
-
|
|
836
|
-
# Display truncated content in dim
|
|
837
|
-
from rich.pretty import Pretty
|
|
838
|
-
|
|
839
|
-
if self.config and self.config.logger.truncate_tools:
|
|
840
|
-
pretty_obj = Pretty(structured_content, max_length=10, max_string=50)
|
|
841
|
-
else:
|
|
842
|
-
pretty_obj = Pretty(structured_content)
|
|
843
|
-
console.console.print(pretty_obj, style="dim", markup=self._markup)
|
|
844
|
-
|
|
845
|
-
# Bottom separator with metadata
|
|
846
|
-
console.console.print()
|
|
847
|
-
if bottom_metadata:
|
|
848
|
-
display_items = (
|
|
849
|
-
self._shorten_items(bottom_metadata, 12) if True else bottom_metadata
|
|
850
|
-
)
|
|
851
|
-
prefix = Text("─| ")
|
|
852
|
-
prefix.stylize("dim")
|
|
853
|
-
suffix = Text(" |")
|
|
854
|
-
suffix.stylize("dim")
|
|
855
|
-
available = max(0, total_width - prefix.cell_len - suffix.cell_len)
|
|
856
|
-
|
|
857
|
-
metadata_text = self._format_bottom_metadata(
|
|
858
|
-
display_items,
|
|
859
|
-
None,
|
|
860
|
-
config["highlight_color"],
|
|
861
|
-
max_width=available,
|
|
862
|
-
)
|
|
863
|
-
|
|
864
|
-
line = Text()
|
|
865
|
-
line.append_text(prefix)
|
|
866
|
-
line.append_text(metadata_text)
|
|
867
|
-
line.append_text(suffix)
|
|
868
|
-
remaining = total_width - line.cell_len
|
|
869
|
-
if remaining > 0:
|
|
870
|
-
line.append("─" * remaining, style="dim")
|
|
871
|
-
console.console.print(line, markup=self._markup)
|
|
872
|
-
else:
|
|
873
|
-
console.console.print("─" * total_width, style="dim")
|
|
874
|
-
console.console.print()
|
|
875
|
-
|
|
876
|
-
else:
|
|
877
|
-
# No structured content - use standard display
|
|
878
|
-
self.display_message(
|
|
879
|
-
content=content,
|
|
880
|
-
message_type=MessageType.TOOL_RESULT,
|
|
881
|
-
name=name,
|
|
882
|
-
right_info=right_info,
|
|
883
|
-
bottom_metadata=bottom_metadata,
|
|
884
|
-
is_error=result.isError,
|
|
885
|
-
truncate_content=True,
|
|
886
|
-
)
|
|
503
|
+
self._tool_display.show_tool_result(
|
|
504
|
+
result,
|
|
505
|
+
name=name,
|
|
506
|
+
tool_name=tool_name,
|
|
507
|
+
skybridge_config=skybridge_config,
|
|
508
|
+
)
|
|
887
509
|
|
|
888
510
|
def show_tool_call(
|
|
889
511
|
self,
|
|
890
512
|
tool_name: str,
|
|
891
|
-
tool_args:
|
|
513
|
+
tool_args: dict[str, Any] | None,
|
|
892
514
|
bottom_items: list[str] | None = None,
|
|
893
515
|
highlight_index: int | None = None,
|
|
894
516
|
max_item_length: int | None = None,
|
|
895
517
|
name: str | None = None,
|
|
896
|
-
metadata:
|
|
518
|
+
metadata: dict[str, Any] | None = None,
|
|
897
519
|
) -> None:
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
tool_args: Arguments being passed to the tool
|
|
903
|
-
bottom_items: Optional list of items for bottom separator (e.g., available tools)
|
|
904
|
-
highlight_index: Index of item to highlight in the bottom separator (0-based), or None
|
|
905
|
-
max_item_length: Optional max length for bottom items (with ellipsis)
|
|
906
|
-
name: Optional agent name
|
|
907
|
-
metadata: Optional dictionary of metadata about the tool call
|
|
908
|
-
"""
|
|
909
|
-
if not self.config or not self.config.logger.show_tools:
|
|
910
|
-
return
|
|
911
|
-
|
|
912
|
-
tool_args = tool_args or {}
|
|
913
|
-
metadata = metadata or {}
|
|
914
|
-
# Build right info and specialised content for known variants
|
|
915
|
-
right_info = f"[dim]tool request - {tool_name}[/dim]"
|
|
916
|
-
content: Any = tool_args
|
|
917
|
-
pre_content: Text | None = None
|
|
918
|
-
truncate_content = True
|
|
919
|
-
|
|
920
|
-
if metadata.get("variant") == "shell":
|
|
921
|
-
bottom_items = list()
|
|
922
|
-
max_item_length = 50
|
|
923
|
-
command = metadata.get("command") or tool_args.get("command")
|
|
924
|
-
|
|
925
|
-
command_text = Text()
|
|
926
|
-
if command and isinstance(command, str):
|
|
927
|
-
# Only prepend $ to the first line, not continuation lines
|
|
928
|
-
command_text.append("$ ", style="magenta")
|
|
929
|
-
command_text.append(command, style="white")
|
|
930
|
-
else:
|
|
931
|
-
command_text.append("$ ", style="magenta")
|
|
932
|
-
command_text.append("(no shell command provided)", style="dim")
|
|
933
|
-
|
|
934
|
-
content = command_text
|
|
935
|
-
|
|
936
|
-
# Include shell name and path in the header, with timeout
|
|
937
|
-
shell_name = metadata.get("shell_name") or "shell"
|
|
938
|
-
shell_path = metadata.get("shell_path")
|
|
939
|
-
if shell_path:
|
|
940
|
-
bottom_items.append(str(shell_path))
|
|
941
|
-
# Build header right info with shell and timeout
|
|
942
|
-
right_parts = []
|
|
943
|
-
if shell_path and shell_path != shell_name:
|
|
944
|
-
right_parts.append(f"{shell_name} ({shell_path})")
|
|
945
|
-
elif shell_name:
|
|
946
|
-
right_parts.append(shell_name)
|
|
947
|
-
|
|
948
|
-
right_info = f"[dim]{' | '.join(right_parts)}[/dim]" if right_parts else ""
|
|
949
|
-
truncate_content = False
|
|
950
|
-
|
|
951
|
-
# Build compact metadata summary - just working directory now
|
|
952
|
-
metadata_text = Text()
|
|
953
|
-
working_dir_display = metadata.get("working_dir_display") or metadata.get("working_dir")
|
|
954
|
-
if working_dir_display:
|
|
955
|
-
bottom_items.append(f"cwd: {working_dir_display}")
|
|
956
|
-
|
|
957
|
-
timeout_seconds = metadata.get("timeout_seconds")
|
|
958
|
-
warning_interval = metadata.get("warning_interval_seconds")
|
|
959
|
-
|
|
960
|
-
if timeout_seconds and warning_interval:
|
|
961
|
-
bottom_items.append(
|
|
962
|
-
f"timeout: {timeout_seconds}s, warning every {warning_interval}s"
|
|
963
|
-
)
|
|
964
|
-
|
|
965
|
-
pre_content = metadata_text
|
|
966
|
-
|
|
967
|
-
# Display using unified method
|
|
968
|
-
self.display_message(
|
|
969
|
-
content=content,
|
|
970
|
-
message_type=MessageType.TOOL_CALL,
|
|
971
|
-
name=name,
|
|
972
|
-
pre_content=pre_content,
|
|
973
|
-
right_info=right_info,
|
|
974
|
-
bottom_metadata=bottom_items,
|
|
520
|
+
self._tool_display.show_tool_call(
|
|
521
|
+
tool_name,
|
|
522
|
+
tool_args,
|
|
523
|
+
bottom_items=bottom_items,
|
|
975
524
|
highlight_index=highlight_index,
|
|
976
525
|
max_item_length=max_item_length,
|
|
977
|
-
|
|
526
|
+
name=name,
|
|
527
|
+
metadata=metadata,
|
|
978
528
|
)
|
|
979
529
|
|
|
980
530
|
async def show_tool_update(self, updated_server: str, agent_name: str | None = None) -> None:
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
Args:
|
|
984
|
-
updated_server: Name of the server being updated
|
|
985
|
-
agent_name: Optional agent name to display
|
|
986
|
-
"""
|
|
987
|
-
if not self.config or not self.config.logger.show_tools:
|
|
988
|
-
return
|
|
989
|
-
|
|
990
|
-
# Check if prompt_toolkit is active
|
|
991
|
-
try:
|
|
992
|
-
from prompt_toolkit.application.current import get_app
|
|
993
|
-
|
|
994
|
-
app = get_app()
|
|
995
|
-
# We're in interactive mode - add to notification tracker
|
|
996
|
-
from fast_agent.ui import notification_tracker
|
|
997
|
-
|
|
998
|
-
notification_tracker.add_tool_update(updated_server)
|
|
999
|
-
app.invalidate() # Force toolbar redraw
|
|
1000
|
-
|
|
1001
|
-
except: # noqa: E722
|
|
1002
|
-
# No active prompt_toolkit session - display with rich as before
|
|
1003
|
-
# Combined separator and status line
|
|
1004
|
-
if agent_name:
|
|
1005
|
-
left = f"[magenta]▎[/magenta][dim magenta]▶[/dim magenta] [magenta]{agent_name}[/magenta]"
|
|
1006
|
-
else:
|
|
1007
|
-
left = "[magenta]▎[/magenta][dim magenta]▶[/dim magenta]"
|
|
1008
|
-
|
|
1009
|
-
right = f"[dim]{updated_server}[/dim]"
|
|
1010
|
-
self._create_combined_separator_status(left, right)
|
|
1011
|
-
|
|
1012
|
-
# Display update message
|
|
1013
|
-
message = f"Updating tools for server {updated_server}"
|
|
1014
|
-
console.console.print(message, style="dim", markup=self._markup)
|
|
1015
|
-
|
|
1016
|
-
# Bottom separator
|
|
1017
|
-
console.console.print()
|
|
1018
|
-
console.console.print("─" * console.console.size.width, style="dim")
|
|
1019
|
-
console.console.print()
|
|
531
|
+
await self._tool_display.show_tool_update(updated_server, agent_name=agent_name)
|
|
1020
532
|
|
|
1021
533
|
def _create_combined_separator_status(self, left_content: str, right_info: str = "") -> None:
|
|
1022
534
|
"""
|
|
@@ -1061,136 +573,15 @@ class ConsoleDisplay:
|
|
|
1061
573
|
@staticmethod
|
|
1062
574
|
def summarize_skybridge_configs(
|
|
1063
575
|
configs: Mapping[str, "SkybridgeServerConfig"] | None,
|
|
1064
|
-
) ->
|
|
1065
|
-
|
|
1066
|
-
server_rows: List[Dict[str, Any]] = []
|
|
1067
|
-
warnings: List[str] = []
|
|
1068
|
-
warning_seen: Set[str] = set()
|
|
1069
|
-
|
|
1070
|
-
if not configs:
|
|
1071
|
-
return server_rows, warnings
|
|
1072
|
-
|
|
1073
|
-
def add_warning(message: str) -> None:
|
|
1074
|
-
formatted = message.strip()
|
|
1075
|
-
if not formatted:
|
|
1076
|
-
return
|
|
1077
|
-
if formatted not in warning_seen:
|
|
1078
|
-
warnings.append(formatted)
|
|
1079
|
-
warning_seen.add(formatted)
|
|
1080
|
-
|
|
1081
|
-
for server_name in sorted(configs.keys()):
|
|
1082
|
-
config = configs.get(server_name)
|
|
1083
|
-
if not config:
|
|
1084
|
-
continue
|
|
1085
|
-
resources = list(config.ui_resources or [])
|
|
1086
|
-
has_skybridge_signal = bool(
|
|
1087
|
-
config.enabled or resources or config.tools or config.warnings
|
|
1088
|
-
)
|
|
1089
|
-
if not has_skybridge_signal:
|
|
1090
|
-
continue
|
|
1091
|
-
|
|
1092
|
-
valid_resource_count = sum(1 for resource in resources if resource.is_skybridge)
|
|
1093
|
-
|
|
1094
|
-
server_rows.append(
|
|
1095
|
-
{
|
|
1096
|
-
"server_name": server_name,
|
|
1097
|
-
"config": config,
|
|
1098
|
-
"resources": resources,
|
|
1099
|
-
"valid_resource_count": valid_resource_count,
|
|
1100
|
-
"total_resource_count": len(resources),
|
|
1101
|
-
"active_tools": [
|
|
1102
|
-
{
|
|
1103
|
-
"name": tool.display_name,
|
|
1104
|
-
"template": str(tool.template_uri) if tool.template_uri else None,
|
|
1105
|
-
}
|
|
1106
|
-
for tool in config.tools
|
|
1107
|
-
if tool.is_valid
|
|
1108
|
-
],
|
|
1109
|
-
"enabled": config.enabled,
|
|
1110
|
-
}
|
|
1111
|
-
)
|
|
1112
|
-
|
|
1113
|
-
for warning in config.warnings:
|
|
1114
|
-
message = warning.strip()
|
|
1115
|
-
if not message:
|
|
1116
|
-
continue
|
|
1117
|
-
if not message.startswith(server_name):
|
|
1118
|
-
message = f"{server_name} {message}"
|
|
1119
|
-
add_warning(message)
|
|
1120
|
-
|
|
1121
|
-
return server_rows, warnings
|
|
576
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
577
|
+
return ToolDisplay.summarize_skybridge_configs(configs)
|
|
1122
578
|
|
|
1123
579
|
def show_skybridge_summary(
|
|
1124
580
|
self,
|
|
1125
581
|
agent_name: str,
|
|
1126
582
|
configs: Mapping[str, "SkybridgeServerConfig"] | None,
|
|
1127
583
|
) -> None:
|
|
1128
|
-
|
|
1129
|
-
server_rows, warnings = self.summarize_skybridge_configs(configs)
|
|
1130
|
-
|
|
1131
|
-
if not server_rows and not warnings:
|
|
1132
|
-
return
|
|
1133
|
-
|
|
1134
|
-
heading = "[dim]OpenAI Apps SDK ([/dim][cyan]skybridge[/cyan][dim]) detected:[/dim]"
|
|
1135
|
-
console.console.print()
|
|
1136
|
-
console.console.print(heading, markup=self._markup)
|
|
1137
|
-
|
|
1138
|
-
if not server_rows:
|
|
1139
|
-
console.console.print("[dim] ● none detected[/dim]", markup=self._markup)
|
|
1140
|
-
else:
|
|
1141
|
-
for row in server_rows:
|
|
1142
|
-
server_name = row["server_name"]
|
|
1143
|
-
resource_count = row["valid_resource_count"]
|
|
1144
|
-
total_resource_count = row["total_resource_count"]
|
|
1145
|
-
tool_infos = row["active_tools"]
|
|
1146
|
-
enabled = row["enabled"]
|
|
1147
|
-
|
|
1148
|
-
tool_count = len(tool_infos)
|
|
1149
|
-
tool_word = "tool" if tool_count == 1 else "tools"
|
|
1150
|
-
resource_word = (
|
|
1151
|
-
"skybridge resource" if resource_count == 1 else "skybridge resources"
|
|
1152
|
-
)
|
|
1153
|
-
tool_segment = f"[cyan]{tool_count}[/cyan][dim] {tool_word}[/dim]"
|
|
1154
|
-
resource_segment = f"[cyan]{resource_count}[/cyan][dim] {resource_word}[/dim]"
|
|
1155
|
-
name_style = "cyan" if enabled else "yellow"
|
|
1156
|
-
status_suffix = "" if enabled else "[dim] (issues detected)[/dim]"
|
|
1157
|
-
|
|
1158
|
-
console.console.print(
|
|
1159
|
-
f"[dim] ● [/dim][{name_style}]{server_name}[/{name_style}]{status_suffix}"
|
|
1160
|
-
f"[dim] — [/dim]{tool_segment}[dim], [/dim]{resource_segment}",
|
|
1161
|
-
markup=self._markup,
|
|
1162
|
-
)
|
|
1163
|
-
|
|
1164
|
-
for tool_info in tool_infos:
|
|
1165
|
-
template_text = (
|
|
1166
|
-
f"[dim] ({tool_info['template']})[/dim]" if tool_info["template"] else ""
|
|
1167
|
-
)
|
|
1168
|
-
console.console.print(
|
|
1169
|
-
f"[dim] ▶ [/dim][white]{tool_info['name']}[/white]{template_text}",
|
|
1170
|
-
markup=self._markup,
|
|
1171
|
-
)
|
|
1172
|
-
|
|
1173
|
-
if tool_count == 0 and resource_count > 0:
|
|
1174
|
-
console.console.print(
|
|
1175
|
-
"[dim] ▶ tools not linked[/dim]",
|
|
1176
|
-
markup=self._markup,
|
|
1177
|
-
)
|
|
1178
|
-
if not enabled and total_resource_count > resource_count:
|
|
1179
|
-
invalid_count = total_resource_count - resource_count
|
|
1180
|
-
invalid_word = "resource" if invalid_count == 1 else "resources"
|
|
1181
|
-
console.console.print(
|
|
1182
|
-
(
|
|
1183
|
-
"[dim] ▶ "
|
|
1184
|
-
f"[/dim][cyan]{invalid_count}[/cyan][dim] {invalid_word} detected with non-skybridge MIME type[/dim]"
|
|
1185
|
-
),
|
|
1186
|
-
markup=self._markup,
|
|
1187
|
-
)
|
|
1188
|
-
|
|
1189
|
-
for warning_entry in warnings:
|
|
1190
|
-
console.console.print(
|
|
1191
|
-
f"[dim red] ▶ [/dim red][red]warning[/red] [dim]{warning_entry}[/dim]",
|
|
1192
|
-
markup=self._markup,
|
|
1193
|
-
)
|
|
584
|
+
self._tool_display.show_skybridge_summary(agent_name, configs)
|
|
1194
585
|
|
|
1195
586
|
def _extract_reasoning_content(self, message: "PromptMessageExtended") -> Text | None:
|
|
1196
587
|
"""Extract reasoning channel content as dim text."""
|
|
@@ -1289,7 +680,7 @@ class ConsoleDisplay:
|
|
|
1289
680
|
max_item_length: int | None = None,
|
|
1290
681
|
name: str | None = None,
|
|
1291
682
|
model: str | None = None,
|
|
1292
|
-
) -> Iterator[
|
|
683
|
+
) -> Iterator[StreamingHandle]:
|
|
1293
684
|
"""Create a streaming context for assistant messages."""
|
|
1294
685
|
streaming_enabled, streaming_mode = self.resolve_streaming_preferences()
|
|
1295
686
|
|
|
@@ -1618,610 +1009,3 @@ class ConsoleDisplay:
|
|
|
1618
1009
|
summary_text = " • ".join(summary_parts)
|
|
1619
1010
|
console.console.print(f"[dim]{summary_text}[/dim]")
|
|
1620
1011
|
console.console.print()
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
class _NullStreamingHandle:
|
|
1624
|
-
"""No-op streaming handle used when streaming is disabled."""
|
|
1625
|
-
|
|
1626
|
-
def update(self, _chunk: str) -> None:
|
|
1627
|
-
return
|
|
1628
|
-
|
|
1629
|
-
def finalize(self, _message: "PromptMessageExtended | str") -> None:
|
|
1630
|
-
return
|
|
1631
|
-
|
|
1632
|
-
def close(self) -> None:
|
|
1633
|
-
return
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
class _StreamingMessageHandle:
|
|
1637
|
-
"""Helper that manages live rendering for streaming assistant responses."""
|
|
1638
|
-
|
|
1639
|
-
def __init__(
|
|
1640
|
-
self,
|
|
1641
|
-
*,
|
|
1642
|
-
display: ConsoleDisplay,
|
|
1643
|
-
bottom_items: List[str] | None,
|
|
1644
|
-
highlight_index: int | None,
|
|
1645
|
-
max_item_length: int | None,
|
|
1646
|
-
use_plain_text: bool = False,
|
|
1647
|
-
header_left: str = "",
|
|
1648
|
-
header_right: str = "",
|
|
1649
|
-
progress_display=None,
|
|
1650
|
-
) -> None:
|
|
1651
|
-
self._display = display
|
|
1652
|
-
self._bottom_items = bottom_items
|
|
1653
|
-
self._highlight_index = highlight_index
|
|
1654
|
-
self._max_item_length = max_item_length
|
|
1655
|
-
self._use_plain_text = use_plain_text
|
|
1656
|
-
self._header_left = header_left
|
|
1657
|
-
self._header_right = header_right
|
|
1658
|
-
self._progress_display = progress_display
|
|
1659
|
-
self._progress_paused = False
|
|
1660
|
-
self._buffer: List[str] = []
|
|
1661
|
-
initial_renderable = Text("") if self._use_plain_text else Markdown("")
|
|
1662
|
-
refresh_rate = (
|
|
1663
|
-
PLAIN_STREAM_REFRESH_PER_SECOND
|
|
1664
|
-
if self._use_plain_text
|
|
1665
|
-
else MARKDOWN_STREAM_REFRESH_PER_SECOND
|
|
1666
|
-
)
|
|
1667
|
-
self._min_render_interval = 1.0 / refresh_rate if refresh_rate else None
|
|
1668
|
-
self._last_render_time = 0.0
|
|
1669
|
-
try:
|
|
1670
|
-
self._loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
|
|
1671
|
-
except RuntimeError:
|
|
1672
|
-
self._loop = None
|
|
1673
|
-
self._async_mode = self._loop is not None
|
|
1674
|
-
self._queue: asyncio.Queue[object] | None = asyncio.Queue() if self._async_mode else None
|
|
1675
|
-
self._stop_sentinel: object = object()
|
|
1676
|
-
self._worker_task: asyncio.Task[None] | None = None
|
|
1677
|
-
self._live: Live | None = Live(
|
|
1678
|
-
initial_renderable,
|
|
1679
|
-
console=console.console,
|
|
1680
|
-
vertical_overflow="ellipsis",
|
|
1681
|
-
refresh_per_second=refresh_rate,
|
|
1682
|
-
transient=True,
|
|
1683
|
-
)
|
|
1684
|
-
self._live_started = False
|
|
1685
|
-
self._active = True
|
|
1686
|
-
self._finalized = False
|
|
1687
|
-
# Track whether we're in a table to batch updates
|
|
1688
|
-
self._in_table = False
|
|
1689
|
-
self._pending_table_row = ""
|
|
1690
|
-
# Smart markdown truncator for creating display window (doesn't mutate buffer)
|
|
1691
|
-
self._truncator = MarkdownTruncator(target_height_ratio=MARKDOWN_STREAM_TARGET_RATIO)
|
|
1692
|
-
self._plain_truncator = (
|
|
1693
|
-
PlainTextTruncator(target_height_ratio=PLAIN_STREAM_TARGET_RATIO)
|
|
1694
|
-
if self._use_plain_text
|
|
1695
|
-
else None
|
|
1696
|
-
)
|
|
1697
|
-
self._max_render_height = 0
|
|
1698
|
-
|
|
1699
|
-
if self._async_mode and self._loop and self._queue is not None:
|
|
1700
|
-
self._worker_task = self._loop.create_task(self._render_worker())
|
|
1701
|
-
|
|
1702
|
-
def update(self, chunk: str) -> None:
|
|
1703
|
-
if not self._active or not chunk:
|
|
1704
|
-
return
|
|
1705
|
-
|
|
1706
|
-
if self._async_mode and self._queue is not None:
|
|
1707
|
-
self._enqueue_chunk(chunk)
|
|
1708
|
-
return
|
|
1709
|
-
|
|
1710
|
-
if self._handle_chunk(chunk):
|
|
1711
|
-
self._render_current_buffer()
|
|
1712
|
-
|
|
1713
|
-
def _build_header(self) -> Text:
|
|
1714
|
-
"""Build the header bar as a Text renderable.
|
|
1715
|
-
|
|
1716
|
-
Returns:
|
|
1717
|
-
Text object representing the header bar.
|
|
1718
|
-
"""
|
|
1719
|
-
width = console.console.size.width
|
|
1720
|
-
|
|
1721
|
-
# Create left text
|
|
1722
|
-
left_text = Text.from_markup(self._header_left)
|
|
1723
|
-
|
|
1724
|
-
# Create right text if we have info
|
|
1725
|
-
if self._header_right and self._header_right.strip():
|
|
1726
|
-
# Add dim brackets around the right info
|
|
1727
|
-
right_text = Text()
|
|
1728
|
-
right_text.append("[", style="dim")
|
|
1729
|
-
right_text.append_text(Text.from_markup(self._header_right))
|
|
1730
|
-
right_text.append("]", style="dim")
|
|
1731
|
-
# Calculate separator count
|
|
1732
|
-
separator_count = width - left_text.cell_len - right_text.cell_len
|
|
1733
|
-
if separator_count < 1:
|
|
1734
|
-
separator_count = 1 # Always at least 1 separator
|
|
1735
|
-
else:
|
|
1736
|
-
right_text = Text("")
|
|
1737
|
-
separator_count = width - left_text.cell_len
|
|
1738
|
-
|
|
1739
|
-
# Build the combined line
|
|
1740
|
-
combined = Text()
|
|
1741
|
-
combined.append_text(left_text)
|
|
1742
|
-
combined.append(" ", style="default")
|
|
1743
|
-
combined.append("─" * (separator_count - 1), style="dim")
|
|
1744
|
-
combined.append_text(right_text)
|
|
1745
|
-
|
|
1746
|
-
return combined
|
|
1747
|
-
|
|
1748
|
-
def _pause_progress_display(self) -> None:
|
|
1749
|
-
if self._progress_display and not self._progress_paused:
|
|
1750
|
-
try:
|
|
1751
|
-
self._progress_display.pause()
|
|
1752
|
-
self._progress_paused = True
|
|
1753
|
-
except Exception:
|
|
1754
|
-
self._progress_paused = False
|
|
1755
|
-
|
|
1756
|
-
def _resume_progress_display(self) -> None:
|
|
1757
|
-
if self._progress_display and self._progress_paused:
|
|
1758
|
-
try:
|
|
1759
|
-
self._progress_display.resume()
|
|
1760
|
-
except Exception:
|
|
1761
|
-
pass
|
|
1762
|
-
finally:
|
|
1763
|
-
self._progress_paused = False
|
|
1764
|
-
|
|
1765
|
-
def _ensure_started(self) -> None:
|
|
1766
|
-
"""Start live rendering and pause progress display if needed."""
|
|
1767
|
-
if not self._live:
|
|
1768
|
-
return
|
|
1769
|
-
|
|
1770
|
-
if self._live_started:
|
|
1771
|
-
return
|
|
1772
|
-
|
|
1773
|
-
self._pause_progress_display()
|
|
1774
|
-
|
|
1775
|
-
if self._live and not self._live_started:
|
|
1776
|
-
self._live.__enter__()
|
|
1777
|
-
self._live_started = True
|
|
1778
|
-
|
|
1779
|
-
def _close_incomplete_code_blocks(self, text: str) -> str:
|
|
1780
|
-
"""Add temporary closing fence to incomplete code blocks for display.
|
|
1781
|
-
|
|
1782
|
-
During streaming, incomplete code blocks (opening fence without closing)
|
|
1783
|
-
are rendered as literal text by Rich's Markdown renderer. This method
|
|
1784
|
-
adds a temporary closing fence so the code can be syntax-highlighted
|
|
1785
|
-
during streaming display.
|
|
1786
|
-
|
|
1787
|
-
When the real closing fence arrives in a subsequent chunk, this method
|
|
1788
|
-
will detect the now-complete block and stop adding the temporary fence.
|
|
1789
|
-
|
|
1790
|
-
Args:
|
|
1791
|
-
text: The markdown text that may contain incomplete code blocks.
|
|
1792
|
-
|
|
1793
|
-
Returns:
|
|
1794
|
-
Text with temporary closing fences added for incomplete code blocks.
|
|
1795
|
-
"""
|
|
1796
|
-
import re
|
|
1797
|
-
|
|
1798
|
-
# Count opening and closing fences
|
|
1799
|
-
opening_fences = len(re.findall(r"^```", text, re.MULTILINE))
|
|
1800
|
-
closing_fences = len(re.findall(r"^```\s*$", text, re.MULTILINE))
|
|
1801
|
-
|
|
1802
|
-
# If we have more opening fences than closing fences, and the text
|
|
1803
|
-
# doesn't end with a closing fence, we have an incomplete code block
|
|
1804
|
-
if opening_fences > closing_fences:
|
|
1805
|
-
# Check if text ends with a closing fence (might be partial line)
|
|
1806
|
-
if not re.search(r"```\s*$", text):
|
|
1807
|
-
# Add temporary closing fence for display only
|
|
1808
|
-
return text + "\n```\n"
|
|
1809
|
-
|
|
1810
|
-
return text
|
|
1811
|
-
|
|
1812
|
-
def _trim_to_displayable(self, text: str) -> str:
|
|
1813
|
-
"""Trim text to keep only displayable content plus small buffer.
|
|
1814
|
-
|
|
1815
|
-
Keeps ~1.5x terminal height worth of recent content.
|
|
1816
|
-
Uses the optimized streaming truncator for better performance.
|
|
1817
|
-
|
|
1818
|
-
Args:
|
|
1819
|
-
text: Full text to trim
|
|
1820
|
-
|
|
1821
|
-
Returns:
|
|
1822
|
-
Trimmed text (most recent content)
|
|
1823
|
-
"""
|
|
1824
|
-
if not text:
|
|
1825
|
-
return text
|
|
1826
|
-
|
|
1827
|
-
terminal_height = console.console.size.height - 1
|
|
1828
|
-
|
|
1829
|
-
if self._use_plain_text and self._plain_truncator:
|
|
1830
|
-
terminal_width = console.console.size.width
|
|
1831
|
-
return self._plain_truncator.truncate(
|
|
1832
|
-
text,
|
|
1833
|
-
terminal_height=terminal_height,
|
|
1834
|
-
terminal_width=terminal_width,
|
|
1835
|
-
)
|
|
1836
|
-
|
|
1837
|
-
# Use the optimized streaming truncator (16x faster!) for markdown
|
|
1838
|
-
return self._truncator.truncate(
|
|
1839
|
-
text,
|
|
1840
|
-
terminal_height=terminal_height,
|
|
1841
|
-
console=console.console,
|
|
1842
|
-
code_theme=CODE_STYLE,
|
|
1843
|
-
prefer_recent=True, # Streaming mode
|
|
1844
|
-
)
|
|
1845
|
-
|
|
1846
|
-
def _switch_to_plain_text(self) -> None:
|
|
1847
|
-
"""Switch from markdown to plain text rendering for tool arguments."""
|
|
1848
|
-
if not self._use_plain_text:
|
|
1849
|
-
self._use_plain_text = True
|
|
1850
|
-
# Initialize plain truncator if needed
|
|
1851
|
-
if not self._plain_truncator:
|
|
1852
|
-
self._plain_truncator = PlainTextTruncator(
|
|
1853
|
-
target_height_ratio=PLAIN_STREAM_TARGET_RATIO
|
|
1854
|
-
)
|
|
1855
|
-
|
|
1856
|
-
def finalize(self, _message: "PromptMessageExtended | str") -> None:
|
|
1857
|
-
if not self._active or self._finalized:
|
|
1858
|
-
return
|
|
1859
|
-
|
|
1860
|
-
self._finalized = True
|
|
1861
|
-
self.close()
|
|
1862
|
-
|
|
1863
|
-
def close(self) -> None:
|
|
1864
|
-
if not self._active:
|
|
1865
|
-
return
|
|
1866
|
-
|
|
1867
|
-
self._active = False
|
|
1868
|
-
if self._async_mode:
|
|
1869
|
-
if self._queue and self._loop:
|
|
1870
|
-
try:
|
|
1871
|
-
current_loop = asyncio.get_running_loop()
|
|
1872
|
-
except RuntimeError:
|
|
1873
|
-
current_loop = None
|
|
1874
|
-
|
|
1875
|
-
# Send stop sentinel to queue
|
|
1876
|
-
try:
|
|
1877
|
-
if current_loop is self._loop:
|
|
1878
|
-
self._queue.put_nowait(self._stop_sentinel)
|
|
1879
|
-
else:
|
|
1880
|
-
# Use call_soon_threadsafe from different thread/loop
|
|
1881
|
-
self._loop.call_soon_threadsafe(self._queue.put_nowait, self._stop_sentinel)
|
|
1882
|
-
except RuntimeError as e:
|
|
1883
|
-
# Expected during event loop shutdown - log at debug level
|
|
1884
|
-
logger.debug(
|
|
1885
|
-
"RuntimeError while closing streaming display (expected during shutdown)",
|
|
1886
|
-
data={"error": str(e)},
|
|
1887
|
-
)
|
|
1888
|
-
except Exception as e:
|
|
1889
|
-
# Unexpected exception - log at warning level
|
|
1890
|
-
logger.warning(
|
|
1891
|
-
"Unexpected error while closing streaming display",
|
|
1892
|
-
exc_info=True,
|
|
1893
|
-
data={"error": str(e)},
|
|
1894
|
-
)
|
|
1895
|
-
if self._worker_task:
|
|
1896
|
-
self._worker_task.cancel()
|
|
1897
|
-
self._worker_task = None
|
|
1898
|
-
self._shutdown_live_resources()
|
|
1899
|
-
self._max_render_height = 0
|
|
1900
|
-
|
|
1901
|
-
def _extract_trailing_paragraph(self, text: str) -> str:
|
|
1902
|
-
"""Return text since the last blank line, used to detect in-progress paragraphs."""
|
|
1903
|
-
if not text:
|
|
1904
|
-
return ""
|
|
1905
|
-
double_break = text.rfind("\n\n")
|
|
1906
|
-
if double_break != -1:
|
|
1907
|
-
candidate = text[double_break + 2 :]
|
|
1908
|
-
else:
|
|
1909
|
-
candidate = text
|
|
1910
|
-
if "\n" in candidate:
|
|
1911
|
-
candidate = candidate.split("\n")[-1]
|
|
1912
|
-
return candidate
|
|
1913
|
-
|
|
1914
|
-
def _wrap_plain_chunk(self, chunk: str) -> str:
|
|
1915
|
-
"""Insert soft line breaks into long plain text segments."""
|
|
1916
|
-
width = max(1, console.console.size.width)
|
|
1917
|
-
if not chunk or width <= 1:
|
|
1918
|
-
return chunk
|
|
1919
|
-
|
|
1920
|
-
result_segments: List[str] = []
|
|
1921
|
-
start = 0
|
|
1922
|
-
length = len(chunk)
|
|
1923
|
-
|
|
1924
|
-
while start < length:
|
|
1925
|
-
newline_pos = chunk.find("\n", start)
|
|
1926
|
-
if newline_pos == -1:
|
|
1927
|
-
line = chunk[start:]
|
|
1928
|
-
delimiter = ""
|
|
1929
|
-
start = length
|
|
1930
|
-
else:
|
|
1931
|
-
line = chunk[start:newline_pos]
|
|
1932
|
-
delimiter = "\n"
|
|
1933
|
-
start = newline_pos + 1
|
|
1934
|
-
|
|
1935
|
-
if len(line.expandtabs()) > width:
|
|
1936
|
-
wrapped = self._wrap_plain_line(line, width)
|
|
1937
|
-
result_segments.append("\n".join(wrapped))
|
|
1938
|
-
else:
|
|
1939
|
-
result_segments.append(line)
|
|
1940
|
-
|
|
1941
|
-
result_segments.append(delimiter)
|
|
1942
|
-
|
|
1943
|
-
return "".join(result_segments)
|
|
1944
|
-
|
|
1945
|
-
@staticmethod
|
|
1946
|
-
def _wrap_plain_line(line: str, width: int) -> List[str]:
|
|
1947
|
-
"""Wrap a single line to the terminal width."""
|
|
1948
|
-
if not line:
|
|
1949
|
-
return [""]
|
|
1950
|
-
|
|
1951
|
-
segments: List[str] = []
|
|
1952
|
-
remaining = line
|
|
1953
|
-
|
|
1954
|
-
while len(remaining) > width:
|
|
1955
|
-
break_at = remaining.rfind(" ", 0, width)
|
|
1956
|
-
if break_at == -1 or break_at < width // 2:
|
|
1957
|
-
break_at = width
|
|
1958
|
-
segments.append(remaining[:break_at])
|
|
1959
|
-
remaining = remaining[break_at:]
|
|
1960
|
-
else:
|
|
1961
|
-
segments.append(remaining[:break_at])
|
|
1962
|
-
remaining = remaining[break_at + 1 :]
|
|
1963
|
-
segments.append(remaining)
|
|
1964
|
-
return segments
|
|
1965
|
-
|
|
1966
|
-
def _estimate_plain_render_height(self, text: str) -> int:
|
|
1967
|
-
"""Estimate rendered height for plain text taking terminal width into account."""
|
|
1968
|
-
if not text:
|
|
1969
|
-
return 0
|
|
1970
|
-
|
|
1971
|
-
width = max(1, console.console.size.width)
|
|
1972
|
-
lines = text.split("\n")
|
|
1973
|
-
total = 0
|
|
1974
|
-
for line in lines:
|
|
1975
|
-
expanded_len = len(line.expandtabs())
|
|
1976
|
-
total += max(1, math.ceil(expanded_len / width)) if expanded_len else 1
|
|
1977
|
-
return total
|
|
1978
|
-
|
|
1979
|
-
def _enqueue_chunk(self, chunk: str) -> None:
|
|
1980
|
-
if not self._queue or not self._loop:
|
|
1981
|
-
return
|
|
1982
|
-
|
|
1983
|
-
try:
|
|
1984
|
-
current_loop = asyncio.get_running_loop()
|
|
1985
|
-
except RuntimeError:
|
|
1986
|
-
current_loop = None
|
|
1987
|
-
|
|
1988
|
-
if current_loop is self._loop:
|
|
1989
|
-
try:
|
|
1990
|
-
self._queue.put_nowait(chunk)
|
|
1991
|
-
except asyncio.QueueFull:
|
|
1992
|
-
# Shouldn't happen with default unlimited queue, but fail safe
|
|
1993
|
-
pass
|
|
1994
|
-
else:
|
|
1995
|
-
try:
|
|
1996
|
-
self._loop.call_soon_threadsafe(self._queue.put_nowait, chunk)
|
|
1997
|
-
except RuntimeError as e:
|
|
1998
|
-
# Expected during event loop shutdown - log at debug level
|
|
1999
|
-
logger.debug(
|
|
2000
|
-
"RuntimeError while enqueuing chunk (expected during shutdown)",
|
|
2001
|
-
data={"error": str(e), "chunk_length": len(chunk)},
|
|
2002
|
-
)
|
|
2003
|
-
except Exception as e:
|
|
2004
|
-
# Unexpected exception - log at warning level
|
|
2005
|
-
logger.warning(
|
|
2006
|
-
"Unexpected error while enqueuing chunk",
|
|
2007
|
-
exc_info=True,
|
|
2008
|
-
data={"error": str(e), "chunk_length": len(chunk)},
|
|
2009
|
-
)
|
|
2010
|
-
|
|
2011
|
-
def _handle_chunk(self, chunk: str) -> bool:
|
|
2012
|
-
"""
|
|
2013
|
-
Process an incoming chunk and determine whether rendering is needed.
|
|
2014
|
-
|
|
2015
|
-
Returns:
|
|
2016
|
-
True if the display should be updated, False otherwise.
|
|
2017
|
-
"""
|
|
2018
|
-
if not chunk:
|
|
2019
|
-
return False
|
|
2020
|
-
|
|
2021
|
-
if self._use_plain_text:
|
|
2022
|
-
chunk = self._wrap_plain_chunk(chunk)
|
|
2023
|
-
if self._pending_table_row:
|
|
2024
|
-
self._buffer.append(self._pending_table_row)
|
|
2025
|
-
self._pending_table_row = ""
|
|
2026
|
-
else:
|
|
2027
|
-
text_so_far = "".join(self._buffer)
|
|
2028
|
-
lines = text_so_far.strip().split("\n")
|
|
2029
|
-
last_line = lines[-1] if lines else ""
|
|
2030
|
-
currently_in_table = last_line.strip().startswith("|")
|
|
2031
|
-
|
|
2032
|
-
if currently_in_table and "\n" not in chunk:
|
|
2033
|
-
self._pending_table_row += chunk
|
|
2034
|
-
return False
|
|
2035
|
-
|
|
2036
|
-
if self._pending_table_row:
|
|
2037
|
-
self._buffer.append(self._pending_table_row)
|
|
2038
|
-
self._pending_table_row = ""
|
|
2039
|
-
|
|
2040
|
-
self._buffer.append(chunk)
|
|
2041
|
-
return True
|
|
2042
|
-
|
|
2043
|
-
def _render_current_buffer(self) -> None:
|
|
2044
|
-
if not self._buffer:
|
|
2045
|
-
return
|
|
2046
|
-
|
|
2047
|
-
self._ensure_started()
|
|
2048
|
-
|
|
2049
|
-
if not self._live:
|
|
2050
|
-
return
|
|
2051
|
-
|
|
2052
|
-
text = "".join(self._buffer)
|
|
2053
|
-
|
|
2054
|
-
if self._use_plain_text:
|
|
2055
|
-
trimmed = self._trim_to_displayable(text)
|
|
2056
|
-
if trimmed != text:
|
|
2057
|
-
text = trimmed
|
|
2058
|
-
self._buffer = [trimmed]
|
|
2059
|
-
trailing_paragraph = self._extract_trailing_paragraph(text)
|
|
2060
|
-
if trailing_paragraph and "\n" not in trailing_paragraph:
|
|
2061
|
-
width = max(1, console.console.size.width)
|
|
2062
|
-
target_ratio = (
|
|
2063
|
-
PLAIN_STREAM_TARGET_RATIO if self._use_plain_text else MARKDOWN_STREAM_TARGET_RATIO
|
|
2064
|
-
)
|
|
2065
|
-
target_rows = max(1, int(console.console.size.height * target_ratio) - 1)
|
|
2066
|
-
estimated_rows = math.ceil(len(trailing_paragraph.expandtabs()) / width)
|
|
2067
|
-
if estimated_rows > target_rows:
|
|
2068
|
-
trimmed_text = self._trim_to_displayable(text)
|
|
2069
|
-
if trimmed_text != text:
|
|
2070
|
-
text = trimmed_text
|
|
2071
|
-
self._buffer = [trimmed_text]
|
|
2072
|
-
|
|
2073
|
-
if len(self._buffer) > 10:
|
|
2074
|
-
text = self._trim_to_displayable(text)
|
|
2075
|
-
self._buffer = [text]
|
|
2076
|
-
|
|
2077
|
-
# Build the header bar
|
|
2078
|
-
header = self._build_header()
|
|
2079
|
-
|
|
2080
|
-
# Build the content renderable
|
|
2081
|
-
max_allowed_height = max(1, console.console.size.height - 2)
|
|
2082
|
-
self._max_render_height = min(self._max_render_height, max_allowed_height)
|
|
2083
|
-
|
|
2084
|
-
if self._use_plain_text:
|
|
2085
|
-
content_height = self._estimate_plain_render_height(text)
|
|
2086
|
-
budget_height = min(content_height + PLAIN_STREAM_HEIGHT_FUDGE, max_allowed_height)
|
|
2087
|
-
|
|
2088
|
-
if budget_height > self._max_render_height:
|
|
2089
|
-
self._max_render_height = budget_height
|
|
2090
|
-
|
|
2091
|
-
padding_lines = max(0, self._max_render_height - content_height)
|
|
2092
|
-
display_text = text + ("\n" * padding_lines if padding_lines else "")
|
|
2093
|
-
content = Text(display_text)
|
|
2094
|
-
else:
|
|
2095
|
-
prepared = _prepare_markdown_content(text, self._display._escape_xml)
|
|
2096
|
-
prepared_for_display = self._close_incomplete_code_blocks(prepared)
|
|
2097
|
-
|
|
2098
|
-
content_height = self._truncator.measure_rendered_height(
|
|
2099
|
-
prepared_for_display, console.console, CODE_STYLE
|
|
2100
|
-
)
|
|
2101
|
-
budget_height = min(content_height + MARKDOWN_STREAM_HEIGHT_FUDGE, max_allowed_height)
|
|
2102
|
-
|
|
2103
|
-
if budget_height > self._max_render_height:
|
|
2104
|
-
self._max_render_height = budget_height
|
|
2105
|
-
|
|
2106
|
-
padding_lines = max(0, self._max_render_height - content_height)
|
|
2107
|
-
if padding_lines:
|
|
2108
|
-
prepared_for_display = prepared_for_display + ("\n" * padding_lines)
|
|
2109
|
-
|
|
2110
|
-
content = Markdown(prepared_for_display, code_theme=CODE_STYLE)
|
|
2111
|
-
|
|
2112
|
-
from rich.console import Group
|
|
2113
|
-
|
|
2114
|
-
header_with_spacing = header.copy()
|
|
2115
|
-
header_with_spacing.append("\n", style="default")
|
|
2116
|
-
|
|
2117
|
-
combined = Group(header_with_spacing, content)
|
|
2118
|
-
try:
|
|
2119
|
-
self._live.update(combined)
|
|
2120
|
-
self._last_render_time = time.monotonic()
|
|
2121
|
-
except Exception:
|
|
2122
|
-
# Avoid crashing streaming on renderer errors
|
|
2123
|
-
pass
|
|
2124
|
-
|
|
2125
|
-
async def _render_worker(self) -> None:
|
|
2126
|
-
assert self._queue is not None
|
|
2127
|
-
try:
|
|
2128
|
-
while True:
|
|
2129
|
-
try:
|
|
2130
|
-
item = await self._queue.get()
|
|
2131
|
-
except asyncio.CancelledError:
|
|
2132
|
-
break
|
|
2133
|
-
|
|
2134
|
-
if item is self._stop_sentinel:
|
|
2135
|
-
break
|
|
2136
|
-
|
|
2137
|
-
stop_requested = False
|
|
2138
|
-
chunks = [item]
|
|
2139
|
-
while True:
|
|
2140
|
-
try:
|
|
2141
|
-
next_item = self._queue.get_nowait()
|
|
2142
|
-
except asyncio.QueueEmpty:
|
|
2143
|
-
break
|
|
2144
|
-
if next_item is self._stop_sentinel:
|
|
2145
|
-
stop_requested = True
|
|
2146
|
-
break
|
|
2147
|
-
chunks.append(next_item)
|
|
2148
|
-
|
|
2149
|
-
should_render = False
|
|
2150
|
-
for chunk in chunks:
|
|
2151
|
-
if isinstance(chunk, str):
|
|
2152
|
-
should_render = self._handle_chunk(chunk) or should_render
|
|
2153
|
-
|
|
2154
|
-
if should_render:
|
|
2155
|
-
self._render_current_buffer()
|
|
2156
|
-
if self._min_render_interval:
|
|
2157
|
-
try:
|
|
2158
|
-
await asyncio.sleep(self._min_render_interval)
|
|
2159
|
-
except asyncio.CancelledError:
|
|
2160
|
-
break
|
|
2161
|
-
|
|
2162
|
-
if stop_requested:
|
|
2163
|
-
break
|
|
2164
|
-
except asyncio.CancelledError:
|
|
2165
|
-
pass
|
|
2166
|
-
finally:
|
|
2167
|
-
self._shutdown_live_resources()
|
|
2168
|
-
|
|
2169
|
-
def _shutdown_live_resources(self) -> None:
|
|
2170
|
-
if self._live and self._live_started:
|
|
2171
|
-
try:
|
|
2172
|
-
self._live.__exit__(None, None, None)
|
|
2173
|
-
except Exception:
|
|
2174
|
-
pass
|
|
2175
|
-
self._live = None
|
|
2176
|
-
self._live_started = False
|
|
2177
|
-
|
|
2178
|
-
self._resume_progress_display()
|
|
2179
|
-
self._active = False
|
|
2180
|
-
|
|
2181
|
-
def handle_tool_event(self, event_type: str, info: Dict[str, Any] | None = None) -> None:
|
|
2182
|
-
"""Handle tool streaming events with comprehensive error handling.
|
|
2183
|
-
|
|
2184
|
-
This is called from listener callbacks during async streaming, so we need
|
|
2185
|
-
to be defensive about any errors to prevent crashes in the event loop.
|
|
2186
|
-
"""
|
|
2187
|
-
try:
|
|
2188
|
-
if not self._active:
|
|
2189
|
-
return
|
|
2190
|
-
|
|
2191
|
-
# Check if this provider streams tool arguments
|
|
2192
|
-
streams_arguments = info.get("streams_arguments", False) if info else False
|
|
2193
|
-
|
|
2194
|
-
if event_type == "start":
|
|
2195
|
-
if streams_arguments:
|
|
2196
|
-
# OpenAI: Switch to plain text and show tool call header
|
|
2197
|
-
self._switch_to_plain_text()
|
|
2198
|
-
tool_name = info.get("tool_name", "unknown") if info else "unknown"
|
|
2199
|
-
self.update(f"\n→ Calling {tool_name}\n")
|
|
2200
|
-
else:
|
|
2201
|
-
# Anthropic: Close streaming display immediately
|
|
2202
|
-
self.close()
|
|
2203
|
-
return
|
|
2204
|
-
elif event_type == "delta":
|
|
2205
|
-
if streams_arguments and info and "chunk" in info:
|
|
2206
|
-
# Stream the tool argument chunks as plain text
|
|
2207
|
-
self.update(info["chunk"])
|
|
2208
|
-
elif event_type == "text":
|
|
2209
|
-
self._pause_progress_display()
|
|
2210
|
-
elif event_type == "stop":
|
|
2211
|
-
if streams_arguments:
|
|
2212
|
-
# Close the streaming display
|
|
2213
|
-
self.update("\n")
|
|
2214
|
-
self.close()
|
|
2215
|
-
else:
|
|
2216
|
-
self._resume_progress_display()
|
|
2217
|
-
except Exception as e:
|
|
2218
|
-
# Log but don't crash - streaming display is "nice to have"
|
|
2219
|
-
logger.warning(
|
|
2220
|
-
"Error handling tool event",
|
|
2221
|
-
exc_info=True,
|
|
2222
|
-
data={
|
|
2223
|
-
"event_type": event_type,
|
|
2224
|
-
"streams_arguments": info.get("streams_arguments") if info else None,
|
|
2225
|
-
"error": str(e),
|
|
2226
|
-
},
|
|
2227
|
-
)
|