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.

Files changed (39) hide show
  1. fast_agent/__init__.py +2 -0
  2. fast_agent/agents/agent_types.py +5 -0
  3. fast_agent/agents/llm_agent.py +7 -0
  4. fast_agent/agents/llm_decorator.py +6 -0
  5. fast_agent/agents/mcp_agent.py +134 -10
  6. fast_agent/cli/__main__.py +35 -0
  7. fast_agent/cli/commands/check_config.py +85 -0
  8. fast_agent/cli/commands/go.py +100 -36
  9. fast_agent/cli/constants.py +13 -1
  10. fast_agent/cli/main.py +1 -0
  11. fast_agent/config.py +39 -10
  12. fast_agent/constants.py +8 -0
  13. fast_agent/context.py +24 -15
  14. fast_agent/core/direct_decorators.py +9 -0
  15. fast_agent/core/fastagent.py +101 -1
  16. fast_agent/core/logging/listeners.py +8 -0
  17. fast_agent/interfaces.py +8 -0
  18. fast_agent/llm/fastagent_llm.py +45 -0
  19. fast_agent/llm/memory.py +26 -1
  20. fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
  21. fast_agent/llm/provider/openai/llm_openai.py +184 -18
  22. fast_agent/llm/provider/openai/responses.py +133 -0
  23. fast_agent/resources/setup/agent.py +2 -0
  24. fast_agent/resources/setup/fastagent.config.yaml +6 -0
  25. fast_agent/skills/__init__.py +9 -0
  26. fast_agent/skills/registry.py +200 -0
  27. fast_agent/tools/shell_runtime.py +404 -0
  28. fast_agent/ui/console_display.py +396 -129
  29. fast_agent/ui/elicitation_form.py +76 -24
  30. fast_agent/ui/elicitation_style.py +2 -2
  31. fast_agent/ui/enhanced_prompt.py +81 -25
  32. fast_agent/ui/history_display.py +20 -5
  33. fast_agent/ui/interactive_prompt.py +108 -3
  34. fast_agent/ui/markdown_truncator.py +1 -1
  35. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/METADATA +8 -7
  36. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/RECORD +39 -35
  37. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/WHEEL +0 -0
  38. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/entry_points.txt +0 -0
  39. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.16.dist-info}/licenses/LICENSE +0 -0
@@ -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: List[str] | None = None,
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
- # Build right info
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=tool_args,
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=True,
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._ensure_started()
1634
-
1635
- if self._use_plain_text:
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
- header_with_spacing = header.copy()
1739
- header_with_spacing.append("\n", style="default")
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 _ensure_started(self) -> None:
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 self._live and self._live_started:
1871
- self._live.__exit__(None, None, None)
1872
- self._live = None
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
+ )