fast-agent-mcp 0.3.16__py3-none-any.whl → 0.3.18__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.

@@ -9,6 +9,7 @@ import shlex
9
9
  import subprocess
10
10
  import tempfile
11
11
  from importlib.metadata import version
12
+ from pathlib import Path
12
13
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
13
14
 
14
15
  from prompt_toolkit import PromptSession
@@ -23,7 +24,7 @@ from rich import print as rich_print
23
24
  from fast_agent.agents.agent_types import AgentType
24
25
  from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL, FAST_AGENT_REMOVED_METADATA_CHANNEL
25
26
  from fast_agent.core.exceptions import PromptExitError
26
- from fast_agent.llm.model_info import get_model_info
27
+ from fast_agent.llm.model_info import ModelInfo
27
28
  from fast_agent.mcp.types import McpAgentProtocol
28
29
  from fast_agent.ui.mcp_display import render_mcp_status
29
30
 
@@ -723,18 +724,9 @@ async def get_enhanced_input(
723
724
  # Build TDV capability segment based on model database
724
725
  info = None
725
726
  if llm:
726
- try:
727
- info = get_model_info(llm)
728
- except TypeError:
729
- info = None
727
+ info = ModelInfo.from_llm(llm)
730
728
  if not info and model_name:
731
- try:
732
- info = get_model_info(model_name)
733
- except TypeError:
734
- info = None
735
- except Exception as exc:
736
- print(f"[toolbar debug] get_model_info failed for '{agent_name}': {exc}")
737
- info = None
729
+ info = ModelInfo.from_name(model_name)
738
730
 
739
731
  # Default to text-only if info resolution fails for any reason
740
732
  t, d, v = (True, False, False)
@@ -965,6 +957,28 @@ async def get_enhanced_input(
965
957
  if shell_enabled:
966
958
  modes_display = ", ".join(shell_access_modes or ("direct",))
967
959
  shell_display = f"{modes_display}, {shell_name}" if shell_name else modes_display
960
+
961
+ # Add working directory info
962
+ shell_runtime = getattr(shell_agent, "_shell_runtime", None)
963
+ if shell_runtime:
964
+ working_dir = shell_runtime.working_directory()
965
+ try:
966
+ # Try to show relative to cwd for cleaner display
967
+ working_dir_display = str(working_dir.relative_to(Path.cwd()))
968
+ if working_dir_display == ".":
969
+ # Show last 2 parts of the path (e.g., "source/fast-agent")
970
+ parts = Path.cwd().parts
971
+ if len(parts) >= 2:
972
+ working_dir_display = "/".join(parts[-2:])
973
+ elif len(parts) == 1:
974
+ working_dir_display = parts[0]
975
+ else:
976
+ working_dir_display = str(Path.cwd())
977
+ except ValueError:
978
+ # If not relative to cwd, show absolute path
979
+ working_dir_display = str(working_dir)
980
+ shell_display = f"{shell_display} | cwd: {working_dir_display}"
981
+
968
982
  rich_print(f"[yellow]Shell Access ({shell_display})[/yellow]")
969
983
 
970
984
  rich_print()
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterable, Iterator
4
+
5
+ HTML_ESCAPE_CHARS: dict[str, str] = {
6
+ "&": "&",
7
+ "<": "&lt;",
8
+ ">": "&gt;",
9
+ '"': "&quot;",
10
+ "'": "&#39;",
11
+ }
12
+
13
+
14
+ def _flatten_tokens(tokens: Iterable[Any]) -> Iterator[Any]:
15
+ """Recursively flatten markdown-it token trees."""
16
+ for token in tokens:
17
+ yield token
18
+ if token.children:
19
+ yield from _flatten_tokens(token.children)
20
+
21
+
22
+ def prepare_markdown_content(content: str, escape_xml: bool = True) -> str:
23
+ """Prepare content for markdown rendering, escaping HTML/XML outside code blocks."""
24
+ if not escape_xml or not isinstance(content, str):
25
+ return content
26
+
27
+ from markdown_it import MarkdownIt
28
+
29
+ parser = MarkdownIt()
30
+ try:
31
+ tokens = parser.parse(content)
32
+ except Exception:
33
+ result = content
34
+ for char, replacement in HTML_ESCAPE_CHARS.items():
35
+ result = result.replace(char, replacement)
36
+ return result
37
+
38
+ protected_ranges: list[tuple[int, int]] = []
39
+ lines = content.split("\n")
40
+
41
+ for token in _flatten_tokens(tokens):
42
+ if token.map is not None:
43
+ if token.type in ("fence", "code_block"):
44
+ start_line = token.map[0]
45
+ end_line = token.map[1]
46
+ start_pos = sum(len(line) + 1 for line in lines[:start_line])
47
+ end_pos = sum(len(line) + 1 for line in lines[:end_line])
48
+ protected_ranges.append((start_pos, end_pos))
49
+
50
+ if token.type == "code_inline":
51
+ code_content = token.content
52
+ if code_content:
53
+ pattern = f"`{code_content}`"
54
+ start = 0
55
+ while True:
56
+ pos = content.find(pattern, start)
57
+ if pos == -1:
58
+ break
59
+ in_protected = any(s <= pos < e for s, e in protected_ranges)
60
+ if not in_protected:
61
+ protected_ranges.append((pos, pos + len(pattern)))
62
+ start = pos + len(pattern)
63
+
64
+ import re
65
+
66
+ fence_pattern = r"^```"
67
+ fences = list(re.finditer(fence_pattern, content, re.MULTILINE))
68
+
69
+ if len(fences) % 2 == 1:
70
+ last_fence_pos = fences[-1].start()
71
+ in_protected = any(s <= last_fence_pos < e for s, e in protected_ranges)
72
+ if not in_protected:
73
+ protected_ranges.append((last_fence_pos, len(content)))
74
+
75
+ protected_ranges.sort(key=lambda x: x[0])
76
+
77
+ merged_ranges: list[tuple[int, int]] = []
78
+ for start, end in protected_ranges:
79
+ if merged_ranges and start <= merged_ranges[-1][1]:
80
+ merged_ranges[-1] = (merged_ranges[-1][0], max(end, merged_ranges[-1][1]))
81
+ else:
82
+ merged_ranges.append((start, end))
83
+
84
+ result_segments: list[str] = []
85
+ last_end = 0
86
+
87
+ for start, end in merged_ranges:
88
+ unprotected_text = content[last_end:start]
89
+ for char, replacement in HTML_ESCAPE_CHARS.items():
90
+ unprotected_text = unprotected_text.replace(char, replacement)
91
+ result_segments.append(unprotected_text)
92
+
93
+ result_segments.append(content[start:end])
94
+ last_end = end
95
+
96
+ remainder_text = content[last_end:]
97
+ for char, replacement in HTML_ESCAPE_CHARS.items():
98
+ remainder_text = remainder_text.replace(char, replacement)
99
+ result_segments.append(remainder_text)
100
+
101
+ return "".join(result_segments)
102
+
103
+
104
+ __all__ = ["HTML_ESCAPE_CHARS", "prepare_markdown_content"]
@@ -57,6 +57,7 @@ class CodeBlockInfo:
57
57
  end_pos: int
58
58
  fence_line: int
59
59
  language: str
60
+ fence_text: str | None
60
61
  token: Token
61
62
 
62
63
 
@@ -89,6 +90,13 @@ class MarkdownTruncator:
89
90
  self._last_full_text: str | None = None
90
91
  self._last_truncated_text: str | None = None
91
92
  self._last_terminal_height: int | None = None
93
+ # Markdown parse cache
94
+ self._cache_source: str | None = None
95
+ self._cache_tokens: List[Token] | None = None
96
+ self._cache_lines: List[str] | None = None
97
+ self._cache_safe_points: List[TruncationPoint] | None = None
98
+ self._cache_code_blocks: List[CodeBlockInfo] | None = None
99
+ self._cache_tables: List[TableInfo] | None = None
92
100
 
93
101
  def truncate(
94
102
  self,
@@ -285,6 +293,18 @@ class MarkdownTruncator:
285
293
 
286
294
  return truncated_text
287
295
 
296
+ def _ensure_parse_cache(self, text: str) -> None:
297
+ if self._cache_source == text:
298
+ return
299
+
300
+ tokens = self.parser.parse(text)
301
+ self._cache_source = text
302
+ self._cache_tokens = tokens
303
+ self._cache_lines = text.split("\n")
304
+ self._cache_safe_points = None
305
+ self._cache_code_blocks = None
306
+ self._cache_tables = None
307
+
288
308
  def _find_safe_truncation_points(self, text: str) -> List[TruncationPoint]:
289
309
  """Find safe positions to truncate at (block boundaries).
290
310
 
@@ -294,11 +314,16 @@ class MarkdownTruncator:
294
314
  Returns:
295
315
  List of TruncationPoint objects representing safe truncation positions.
296
316
  """
297
- tokens = self.parser.parse(text)
298
- safe_points = []
317
+ self._ensure_parse_cache(text)
318
+ if self._cache_safe_points is not None:
319
+ return list(self._cache_safe_points)
299
320
 
300
- # Don't flatten - we need to process top-level tokens
301
- lines = text.split("\n")
321
+ assert self._cache_tokens is not None
322
+ assert self._cache_lines is not None
323
+
324
+ safe_points: List[TruncationPoint] = []
325
+ tokens = self._cache_tokens
326
+ lines = self._cache_lines
302
327
 
303
328
  for token in tokens:
304
329
  # We're interested in block-level tokens with map information
@@ -319,13 +344,13 @@ class MarkdownTruncator:
319
344
  is_closing=(token.nesting == 0), # Self-closing or block end
320
345
  )
321
346
  )
322
-
323
- return safe_points
347
+ self._cache_safe_points = safe_points
348
+ return list(safe_points)
324
349
 
325
350
  def _get_code_block_info(self, text: str) -> List[CodeBlockInfo]:
326
351
  """Extract code block positions and metadata using markdown-it.
327
352
 
328
- Uses same technique as _prepare_markdown_content in console_display.py:
353
+ Uses same technique as prepare_markdown_content in markdown_helpers.py:
329
354
  parse once with markdown-it, extract exact positions from tokens.
330
355
 
331
356
  Args:
@@ -334,9 +359,16 @@ class MarkdownTruncator:
334
359
  Returns:
335
360
  List of CodeBlockInfo objects with position and language metadata.
336
361
  """
337
- tokens = self.parser.parse(text)
338
- lines = text.split("\n")
339
- code_blocks = []
362
+ self._ensure_parse_cache(text)
363
+ if self._cache_code_blocks is not None:
364
+ return list(self._cache_code_blocks)
365
+
366
+ assert self._cache_tokens is not None
367
+ assert self._cache_lines is not None
368
+
369
+ tokens = self._cache_tokens
370
+ lines = self._cache_lines
371
+ code_blocks: List[CodeBlockInfo] = []
340
372
 
341
373
  for token in self._flatten_tokens(tokens):
342
374
  if token.type in ("fence", "code_block") and token.map:
@@ -345,6 +377,9 @@ class MarkdownTruncator:
345
377
  start_pos = sum(len(line) + 1 for line in lines[:start_line])
346
378
  end_pos = sum(len(line) + 1 for line in lines[:end_line])
347
379
  language = token.info or "" if hasattr(token, "info") else ""
380
+ fence_text: str | None = None
381
+ if token.type == "fence":
382
+ fence_text = lines[start_line] if 0 <= start_line < len(lines) else None
348
383
 
349
384
  code_blocks.append(
350
385
  CodeBlockInfo(
@@ -352,11 +387,35 @@ class MarkdownTruncator:
352
387
  end_pos=end_pos,
353
388
  fence_line=start_line,
354
389
  language=language,
390
+ fence_text=fence_text,
355
391
  token=token,
356
392
  )
357
393
  )
394
+ self._cache_code_blocks = code_blocks
395
+ return list(code_blocks)
396
+
397
+ def _build_code_block_prefix(self, block: CodeBlockInfo) -> str | None:
398
+ """Construct the opening fence text for a code block if applicable."""
399
+ token = block.token
400
+
401
+ if token.type == "fence":
402
+ if block.fence_text:
403
+ fence_line = block.fence_text
404
+ else:
405
+ markup = getattr(token, "markup", "") or "```"
406
+ info = (getattr(token, "info", "") or "").strip()
407
+ fence_line = f"{markup}{info}" if info else markup
408
+ return fence_line if fence_line.endswith("\n") else fence_line + "\n"
358
409
 
359
- return code_blocks
410
+ if token.type == "code_block":
411
+ info = (getattr(token, "info", "") or "").strip()
412
+ if info:
413
+ return f"```{info}\n"
414
+ if block.language:
415
+ return f"```{block.language}\n"
416
+ return "```\n"
417
+
418
+ return None
360
419
 
361
420
  def _get_table_info(self, text: str) -> List[TableInfo]:
362
421
  """Extract table positions and metadata using markdown-it.
@@ -370,9 +429,16 @@ class MarkdownTruncator:
370
429
  Returns:
371
430
  List of TableInfo objects with position and header metadata.
372
431
  """
373
- tokens = self.parser.parse(text)
374
- lines = text.split("\n")
375
- tables = []
432
+ self._ensure_parse_cache(text)
433
+ if self._cache_tables is not None:
434
+ return list(self._cache_tables)
435
+
436
+ assert self._cache_tokens is not None
437
+ assert self._cache_lines is not None
438
+
439
+ tokens = self._cache_tokens
440
+ lines = self._cache_lines
441
+ tables: List[TableInfo] = []
376
442
 
377
443
  for i, token in enumerate(tokens):
378
444
  if token.type == "table_open" and token.map:
@@ -435,8 +501,8 @@ class MarkdownTruncator:
435
501
  header_lines=header_lines,
436
502
  )
437
503
  )
438
-
439
- return tables
504
+ self._cache_tables = tables
505
+ return list(tables)
440
506
 
441
507
  def _find_best_truncation_point(
442
508
  self,
@@ -571,8 +637,8 @@ class MarkdownTruncator:
571
637
  # If truncation happened after the fence line, it scrolled off
572
638
  if truncation_point.char_position > code_block.start_pos:
573
639
  # Check if fence is already at the beginning (avoid duplicates)
574
- fence = f"```{code_block.language}\n"
575
- if not truncated_text.startswith(fence):
640
+ fence = self._build_code_block_prefix(code_block)
641
+ if fence and not truncated_text.startswith(fence):
576
642
  # Fence scrolled off - prepend it
577
643
  return fence + truncated_text
578
644
 
@@ -611,10 +677,8 @@ class MarkdownTruncator:
611
677
  # Truncated within this code block
612
678
  # Simple check: did truncation remove the fence?
613
679
  if truncation_pos > block.start_pos:
614
- # Check if fence is already at the beginning (avoid duplicates)
615
- fence = f"```{block.language}\n"
616
- if not truncated_text.startswith(fence):
617
- # Fence scrolled off - prepend it
680
+ fence = self._build_code_block_prefix(block)
681
+ if fence and not truncated_text.startswith(fence):
618
682
  return fence + truncated_text
619
683
  # Fence still on screen or already prepended
620
684
  return truncated_text
@@ -875,32 +939,26 @@ class MarkdownTruncator:
875
939
  if not truncated_text or truncated_text == original_text:
876
940
  return truncated_text
877
941
 
942
+ original_fragment = truncated_text
943
+
878
944
  # Find where the truncated text starts in the original
879
- truncation_pos = original_text.rfind(truncated_text)
945
+ truncation_pos = original_text.rfind(original_fragment)
880
946
  if truncation_pos == -1:
881
- # Can't find it, return as-is
882
- return truncated_text
947
+ truncation_pos = max(0, len(original_text) - len(original_fragment))
948
+
949
+ code_blocks = self._get_code_block_info(original_text)
950
+ active_block = None
951
+ for block in code_blocks:
952
+ if block.start_pos <= truncation_pos < block.end_pos:
953
+ active_block = block
954
+
955
+ if active_block:
956
+ fence = self._build_code_block_prefix(active_block)
957
+ if fence and not truncated_text.startswith(fence):
958
+ truncated_text = fence + truncated_text
883
959
 
884
- # Check for incomplete code blocks
885
- original_fence_count = original_text[:truncation_pos].count('```')
886
-
887
- # If we removed an odd number of fences, we're inside a code block
888
- if original_fence_count % 2 == 1:
889
- # Find the last opening fence before truncation point
890
- import re
891
- before_truncation = original_text[:truncation_pos]
892
- fences = list(re.finditer(r'^```(\w*)', before_truncation, re.MULTILINE))
893
- if fences:
894
- last_fence = fences[-1]
895
- language = last_fence.group(1) if last_fence.group(1) else ''
896
- fence = f'```{language}\n'
897
- if not truncated_text.startswith(fence):
898
- truncated_text = fence + truncated_text
899
-
900
- # Check for incomplete tables
901
- # Only if we're not inside a code block
902
- if original_fence_count % 2 == 0 and '|' in truncated_text:
903
- # Use the existing table header restoration logic
960
+ # Check for incomplete tables when not inside a code block
961
+ if active_block is None and '|' in truncated_text:
904
962
  tables = self._get_table_info(original_text)
905
963
  for table in tables:
906
964
  if table.thead_end_pos <= truncation_pos < table.tbody_end_pos:
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class MessageType(Enum):
7
+ """Types of messages that can be displayed."""
8
+
9
+ USER = "user"
10
+ ASSISTANT = "assistant"
11
+ SYSTEM = "system"
12
+ TOOL_CALL = "tool_call"
13
+ TOOL_RESULT = "tool_result"
14
+
15
+
16
+ MESSAGE_CONFIGS: dict[MessageType, dict[str, str]] = {
17
+ MessageType.USER: {
18
+ "block_color": "blue",
19
+ "arrow": "▶",
20
+ "arrow_style": "dim blue",
21
+ "highlight_color": "blue",
22
+ },
23
+ MessageType.ASSISTANT: {
24
+ "block_color": "green",
25
+ "arrow": "◀",
26
+ "arrow_style": "dim green",
27
+ "highlight_color": "bright_green",
28
+ },
29
+ MessageType.SYSTEM: {
30
+ "block_color": "yellow",
31
+ "arrow": "●",
32
+ "arrow_style": "dim yellow",
33
+ "highlight_color": "bright_yellow",
34
+ },
35
+ MessageType.TOOL_CALL: {
36
+ "block_color": "magenta",
37
+ "arrow": "◀",
38
+ "arrow_style": "dim magenta",
39
+ "highlight_color": "magenta",
40
+ },
41
+ MessageType.TOOL_RESULT: {
42
+ "block_color": "magenta",
43
+ "arrow": "▶",
44
+ "arrow_style": "dim magenta",
45
+ "highlight_color": "magenta",
46
+ },
47
+ }
48
+
49
+
50
+ __all__ = ["MessageType", "MESSAGE_CONFIGS"]