fast-agent-mcp 0.3.15__py3-none-any.whl → 0.3.16__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/__init__.py +2 -0
- fast_agent/agents/agent_types.py +5 -0
- fast_agent/agents/llm_agent.py +7 -0
- fast_agent/agents/llm_decorator.py +6 -0
- fast_agent/agents/mcp_agent.py +134 -10
- fast_agent/cli/__main__.py +35 -0
- fast_agent/cli/commands/check_config.py +85 -0
- fast_agent/cli/commands/go.py +100 -36
- fast_agent/cli/constants.py +13 -1
- fast_agent/cli/main.py +1 -0
- fast_agent/config.py +39 -10
- fast_agent/constants.py +8 -0
- fast_agent/context.py +24 -15
- fast_agent/core/direct_decorators.py +9 -0
- fast_agent/core/fastagent.py +101 -1
- fast_agent/core/logging/listeners.py +8 -0
- fast_agent/interfaces.py +8 -0
- fast_agent/llm/fastagent_llm.py +45 -0
- fast_agent/llm/memory.py +26 -1
- fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
- fast_agent/llm/provider/openai/llm_openai.py +184 -18
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/resources/setup/agent.py +2 -0
- fast_agent/resources/setup/fastagent.config.yaml +6 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +200 -0
- fast_agent/tools/shell_runtime.py +404 -0
- fast_agent/ui/console_display.py +396 -129
- fast_agent/ui/elicitation_form.py +76 -24
- fast_agent/ui/elicitation_style.py +2 -2
- fast_agent/ui/enhanced_prompt.py +81 -25
- fast_agent/ui/history_display.py +20 -5
- fast_agent/ui/interactive_prompt.py +108 -3
- fast_agent/ui/markdown_truncator.py +1 -1
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/METADATA +8 -7
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/RECORD +39 -35
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/licenses/LICENSE +0 -0
fast_agent/ui/console_display.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import math
|
|
3
|
+
import time
|
|
2
4
|
from contextlib import contextmanager
|
|
3
5
|
from enum import Enum
|
|
4
6
|
from json import JSONDecodeError
|
|
@@ -12,6 +14,7 @@ from rich.text import Text
|
|
|
12
14
|
|
|
13
15
|
from fast_agent.config import Settings
|
|
14
16
|
from fast_agent.constants import REASONING
|
|
17
|
+
from fast_agent.core.logging.logger import get_logger
|
|
15
18
|
from fast_agent.ui import console
|
|
16
19
|
from fast_agent.ui.markdown_truncator import MarkdownTruncator
|
|
17
20
|
from fast_agent.ui.mcp_ui_utils import UILink
|
|
@@ -27,6 +30,8 @@ if TYPE_CHECKING:
|
|
|
27
30
|
from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
|
|
28
31
|
from fast_agent.mcp.skybridge import SkybridgeServerConfig
|
|
29
32
|
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
30
35
|
CODE_STYLE = "native"
|
|
31
36
|
|
|
32
37
|
MARKDOWN_STREAM_TARGET_RATIO = 0.7
|
|
@@ -884,10 +889,11 @@ class ConsoleDisplay:
|
|
|
884
889
|
self,
|
|
885
890
|
tool_name: str,
|
|
886
891
|
tool_args: Dict[str, Any] | None,
|
|
887
|
-
bottom_items:
|
|
892
|
+
bottom_items: list[str] | None = None,
|
|
888
893
|
highlight_index: int | None = None,
|
|
889
894
|
max_item_length: int | None = None,
|
|
890
895
|
name: str | None = None,
|
|
896
|
+
metadata: Dict[str, Any] | None = None,
|
|
891
897
|
) -> None:
|
|
892
898
|
"""Display a tool call in the new visual style.
|
|
893
899
|
|
|
@@ -898,23 +904,77 @@ class ConsoleDisplay:
|
|
|
898
904
|
highlight_index: Index of item to highlight in the bottom separator (0-based), or None
|
|
899
905
|
max_item_length: Optional max length for bottom items (with ellipsis)
|
|
900
906
|
name: Optional agent name
|
|
907
|
+
metadata: Optional dictionary of metadata about the tool call
|
|
901
908
|
"""
|
|
902
909
|
if not self.config or not self.config.logger.show_tools:
|
|
903
910
|
return
|
|
904
911
|
|
|
905
|
-
|
|
912
|
+
tool_args = tool_args or {}
|
|
913
|
+
metadata = metadata or {}
|
|
914
|
+
# Build right info and specialised content for known variants
|
|
906
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
|
|
907
966
|
|
|
908
967
|
# Display using unified method
|
|
909
968
|
self.display_message(
|
|
910
|
-
content=
|
|
969
|
+
content=content,
|
|
911
970
|
message_type=MessageType.TOOL_CALL,
|
|
912
971
|
name=name,
|
|
972
|
+
pre_content=pre_content,
|
|
913
973
|
right_info=right_info,
|
|
914
974
|
bottom_metadata=bottom_items,
|
|
915
975
|
highlight_index=highlight_index,
|
|
916
976
|
max_item_length=max_item_length,
|
|
917
|
-
truncate_content=
|
|
977
|
+
truncate_content=truncate_content,
|
|
918
978
|
)
|
|
919
979
|
|
|
920
980
|
async def show_tool_update(self, updated_server: str, agent_name: str | None = None) -> None:
|
|
@@ -1604,6 +1664,16 @@ class _StreamingMessageHandle:
|
|
|
1604
1664
|
if self._use_plain_text
|
|
1605
1665
|
else MARKDOWN_STREAM_REFRESH_PER_SECOND
|
|
1606
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
|
|
1607
1677
|
self._live: Live | None = Live(
|
|
1608
1678
|
initial_renderable,
|
|
1609
1679
|
console=console.console,
|
|
@@ -1626,120 +1696,19 @@ class _StreamingMessageHandle:
|
|
|
1626
1696
|
)
|
|
1627
1697
|
self._max_render_height = 0
|
|
1628
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
|
+
|
|
1629
1702
|
def update(self, chunk: str) -> None:
|
|
1630
1703
|
if not self._active or not chunk:
|
|
1631
1704
|
return
|
|
1632
1705
|
|
|
1633
|
-
self.
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
chunk = self._wrap_plain_chunk(chunk)
|
|
1637
|
-
if self._pending_table_row:
|
|
1638
|
-
self._buffer.append(self._pending_table_row)
|
|
1639
|
-
self._pending_table_row = ""
|
|
1640
|
-
else:
|
|
1641
|
-
# Detect if we're streaming table content
|
|
1642
|
-
# Tables have rows starting with '|' and we want to batch updates until we get a complete row
|
|
1643
|
-
text_so_far = "".join(self._buffer)
|
|
1644
|
-
|
|
1645
|
-
# Check if we're currently in a table (last non-empty line starts with |)
|
|
1646
|
-
lines = text_so_far.strip().split("\n")
|
|
1647
|
-
last_line = lines[-1] if lines else ""
|
|
1648
|
-
currently_in_table = last_line.strip().startswith("|")
|
|
1649
|
-
|
|
1650
|
-
# If we're in a table and the chunk doesn't contain a newline, accumulate it
|
|
1651
|
-
if currently_in_table and "\n" not in chunk:
|
|
1652
|
-
self._pending_table_row += chunk
|
|
1653
|
-
# Don't update display yet - wait for complete row
|
|
1654
|
-
return
|
|
1655
|
-
|
|
1656
|
-
# If we have a pending table row, flush it now
|
|
1657
|
-
if self._pending_table_row:
|
|
1658
|
-
self._buffer.append(self._pending_table_row)
|
|
1659
|
-
self._pending_table_row = ""
|
|
1660
|
-
|
|
1661
|
-
self._buffer.append(chunk)
|
|
1662
|
-
|
|
1663
|
-
text = "".join(self._buffer)
|
|
1664
|
-
|
|
1665
|
-
if self._use_plain_text:
|
|
1666
|
-
trimmed = self._trim_to_displayable(text)
|
|
1667
|
-
if trimmed != text:
|
|
1668
|
-
text = trimmed
|
|
1669
|
-
self._buffer = [trimmed]
|
|
1670
|
-
|
|
1671
|
-
# Guard against single logical paragraphs that would expand far wider than expected.
|
|
1672
|
-
trailing_paragraph = self._extract_trailing_paragraph(text)
|
|
1673
|
-
if trailing_paragraph and "\n" not in trailing_paragraph:
|
|
1674
|
-
width = max(1, console.console.size.width)
|
|
1675
|
-
target_ratio = (
|
|
1676
|
-
PLAIN_STREAM_TARGET_RATIO if self._use_plain_text else MARKDOWN_STREAM_TARGET_RATIO
|
|
1677
|
-
)
|
|
1678
|
-
target_rows = max(
|
|
1679
|
-
1,
|
|
1680
|
-
int(console.console.size.height * target_ratio) - 1,
|
|
1681
|
-
)
|
|
1682
|
-
estimated_rows = math.ceil(len(trailing_paragraph.expandtabs()) / width)
|
|
1683
|
-
if estimated_rows > target_rows:
|
|
1684
|
-
trimmed_text = self._trim_to_displayable(text)
|
|
1685
|
-
if trimmed_text != text:
|
|
1686
|
-
text = trimmed_text
|
|
1687
|
-
self._buffer = [trimmed_text]
|
|
1688
|
-
|
|
1689
|
-
# Trim buffer periodically to avoid unbounded growth
|
|
1690
|
-
# Keep only what can fit in ~1.5x terminal height
|
|
1691
|
-
if len(self._buffer) > 10:
|
|
1692
|
-
text = self._trim_to_displayable(text)
|
|
1693
|
-
self._buffer = [text]
|
|
1694
|
-
|
|
1695
|
-
if self._live:
|
|
1696
|
-
# Build the header bar
|
|
1697
|
-
header = self._build_header()
|
|
1698
|
-
|
|
1699
|
-
# Build the content renderable
|
|
1700
|
-
max_allowed_height = max(1, console.console.size.height - 2)
|
|
1701
|
-
self._max_render_height = min(self._max_render_height, max_allowed_height)
|
|
1702
|
-
|
|
1703
|
-
if self._use_plain_text:
|
|
1704
|
-
# Plain text rendering - no markdown processing
|
|
1705
|
-
content_height = self._estimate_plain_render_height(text)
|
|
1706
|
-
budget_height = min(content_height + PLAIN_STREAM_HEIGHT_FUDGE, max_allowed_height)
|
|
1707
|
-
|
|
1708
|
-
if budget_height > self._max_render_height:
|
|
1709
|
-
self._max_render_height = budget_height
|
|
1710
|
-
|
|
1711
|
-
padding_lines = max(0, self._max_render_height - content_height)
|
|
1712
|
-
display_text = text + ("\n" * padding_lines if padding_lines else "")
|
|
1713
|
-
content = Text(display_text)
|
|
1714
|
-
else:
|
|
1715
|
-
# Markdown rendering with XML escaping
|
|
1716
|
-
prepared = _prepare_markdown_content(text, self._display._escape_xml)
|
|
1717
|
-
prepared_for_display = self._close_incomplete_code_blocks(prepared)
|
|
1718
|
-
|
|
1719
|
-
content_height = self._truncator.measure_rendered_height(
|
|
1720
|
-
prepared_for_display, console.console, CODE_STYLE
|
|
1721
|
-
)
|
|
1722
|
-
budget_height = min(
|
|
1723
|
-
content_height + MARKDOWN_STREAM_HEIGHT_FUDGE, max_allowed_height
|
|
1724
|
-
)
|
|
1725
|
-
|
|
1726
|
-
if budget_height > self._max_render_height:
|
|
1727
|
-
self._max_render_height = budget_height
|
|
1728
|
-
|
|
1729
|
-
padding_lines = max(0, self._max_render_height - content_height)
|
|
1730
|
-
if padding_lines:
|
|
1731
|
-
prepared_for_display = prepared_for_display + ("\n" * padding_lines)
|
|
1732
|
-
|
|
1733
|
-
content = Markdown(prepared_for_display, code_theme=CODE_STYLE)
|
|
1734
|
-
|
|
1735
|
-
# Combine header and content using Group
|
|
1736
|
-
from rich.console import Group
|
|
1706
|
+
if self._async_mode and self._queue is not None:
|
|
1707
|
+
self._enqueue_chunk(chunk)
|
|
1708
|
+
return
|
|
1737
1709
|
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
combined = Group(header_with_spacing, content)
|
|
1742
|
-
self._live.update(combined)
|
|
1710
|
+
if self._handle_chunk(chunk):
|
|
1711
|
+
self._render_current_buffer()
|
|
1743
1712
|
|
|
1744
1713
|
def _build_header(self) -> Text:
|
|
1745
1714
|
"""Build the header bar as a Text renderable.
|
|
@@ -1776,11 +1745,7 @@ class _StreamingMessageHandle:
|
|
|
1776
1745
|
|
|
1777
1746
|
return combined
|
|
1778
1747
|
|
|
1779
|
-
def
|
|
1780
|
-
"""Start live rendering and pause progress display if needed."""
|
|
1781
|
-
if self._live_started:
|
|
1782
|
-
return
|
|
1783
|
-
|
|
1748
|
+
def _pause_progress_display(self) -> None:
|
|
1784
1749
|
if self._progress_display and not self._progress_paused:
|
|
1785
1750
|
try:
|
|
1786
1751
|
self._progress_display.pause()
|
|
@@ -1788,6 +1753,25 @@ class _StreamingMessageHandle:
|
|
|
1788
1753
|
except Exception:
|
|
1789
1754
|
self._progress_paused = False
|
|
1790
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
|
+
|
|
1791
1775
|
if self._live and not self._live_started:
|
|
1792
1776
|
self._live.__enter__()
|
|
1793
1777
|
self._live_started = True
|
|
@@ -1859,6 +1843,16 @@ class _StreamingMessageHandle:
|
|
|
1859
1843
|
prefer_recent=True, # Streaming mode
|
|
1860
1844
|
)
|
|
1861
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
|
+
|
|
1862
1856
|
def finalize(self, _message: "PromptMessageExtended | str") -> None:
|
|
1863
1857
|
if not self._active or self._finalized:
|
|
1864
1858
|
return
|
|
@@ -1867,18 +1861,41 @@ class _StreamingMessageHandle:
|
|
|
1867
1861
|
self.close()
|
|
1868
1862
|
|
|
1869
1863
|
def close(self) -> None:
|
|
1870
|
-
if
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
self._live_started = False
|
|
1874
|
-
if self._progress_display and self._progress_paused:
|
|
1875
|
-
try:
|
|
1876
|
-
self._progress_display.resume()
|
|
1877
|
-
except Exception:
|
|
1878
|
-
pass
|
|
1879
|
-
finally:
|
|
1880
|
-
self._progress_paused = False
|
|
1864
|
+
if not self._active:
|
|
1865
|
+
return
|
|
1866
|
+
|
|
1881
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()
|
|
1882
1899
|
self._max_render_height = 0
|
|
1883
1900
|
|
|
1884
1901
|
def _extract_trailing_paragraph(self, text: str) -> str:
|
|
@@ -1958,3 +1975,253 @@ class _StreamingMessageHandle:
|
|
|
1958
1975
|
expanded_len = len(line.expandtabs())
|
|
1959
1976
|
total += max(1, math.ceil(expanded_len / width)) if expanded_len else 1
|
|
1960
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
|
+
)
|