fast-agent-mcp 0.3.14__py3-none-any.whl → 0.3.15__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/llm_agent.py +45 -4
- fast_agent/agents/mcp_agent.py +3 -3
- fast_agent/agents/tool_agent.py +33 -19
- fast_agent/agents/workflow/router_agent.py +2 -1
- fast_agent/cli/commands/check_config.py +5 -2
- fast_agent/config.py +2 -2
- fast_agent/core/fastagent.py +14 -1
- fast_agent/core/validation.py +31 -33
- fast_agent/human_input/form_fields.py +4 -1
- fast_agent/interfaces.py +4 -1
- fast_agent/llm/fastagent_llm.py +31 -0
- fast_agent/llm/model_database.py +2 -2
- fast_agent/llm/model_factory.py +4 -1
- fast_agent/mcp/prompt_message_extended.py +2 -2
- fast_agent/resources/setup/fastagent.config.yaml +5 -4
- fast_agent/ui/console_display.py +654 -69
- fast_agent/ui/elicitation_form.py +23 -1
- fast_agent/ui/enhanced_prompt.py +49 -3
- fast_agent/ui/markdown_truncator.py +942 -0
- fast_agent/ui/mcp_display.py +2 -2
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/streaming_buffer.py +449 -0
- {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.15.dist-info}/METADATA +4 -3
- {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.15.dist-info}/RECORD +27 -24
- {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.15.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.15.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.15.dist-info}/licenses/LICENSE +0 -0
fast_agent/ui/mcp_display.py
CHANGED
|
@@ -225,7 +225,7 @@ def _format_capability_shorthand(
|
|
|
225
225
|
elif instructions_enabled is False:
|
|
226
226
|
entries.append(("In", "red", False))
|
|
227
227
|
elif instructions_enabled is None and not template_expected:
|
|
228
|
-
entries.append(("In", "
|
|
228
|
+
entries.append(("In", "warn", False))
|
|
229
229
|
elif instructions_enabled is None:
|
|
230
230
|
entries.append(("In", True, False))
|
|
231
231
|
elif template_expected:
|
|
@@ -793,7 +793,7 @@ async def render_mcp_status(agent, indent: str = "") -> None:
|
|
|
793
793
|
if instr_available and status.instructions_enabled is False:
|
|
794
794
|
state_segments.append(Text("instructions disabled", style=Colours.TEXT_ERROR))
|
|
795
795
|
elif instr_available and not template_expected:
|
|
796
|
-
state_segments.append(Text("
|
|
796
|
+
state_segments.append(Text("instr. not in sysprompt", style=Colours.TEXT_WARNING))
|
|
797
797
|
|
|
798
798
|
if status.spoofing_enabled:
|
|
799
799
|
state_segments.append(Text("client spoof", style=Colours.TEXT_WARNING))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""High performance truncation for plain text streaming displays."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PlainTextTruncator:
|
|
9
|
+
"""Trim plain text content to fit within a target terminal window."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, target_height_ratio: float = 0.7) -> None:
|
|
12
|
+
if not 0 < target_height_ratio <= 1:
|
|
13
|
+
raise ValueError("target_height_ratio must be between 0 and 1")
|
|
14
|
+
self.target_height_ratio = target_height_ratio
|
|
15
|
+
|
|
16
|
+
def truncate(self, text: str, *, terminal_height: int, terminal_width: int) -> str:
|
|
17
|
+
"""Return the most recent portion of text that fits the terminal window.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
text: Full text buffer accumulated during streaming.
|
|
21
|
+
terminal_height: Terminal height in rows.
|
|
22
|
+
terminal_width: Terminal width in columns.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Tail portion of the text that fits within the target height ratio.
|
|
26
|
+
"""
|
|
27
|
+
if not text:
|
|
28
|
+
return text
|
|
29
|
+
|
|
30
|
+
if terminal_height <= 0 or terminal_width <= 0:
|
|
31
|
+
return text
|
|
32
|
+
|
|
33
|
+
target_rows = max(1, int(terminal_height * self.target_height_ratio))
|
|
34
|
+
width = max(1, terminal_width)
|
|
35
|
+
|
|
36
|
+
idx = len(text)
|
|
37
|
+
rows_used = 0
|
|
38
|
+
start_idx = 0
|
|
39
|
+
|
|
40
|
+
while idx > 0 and rows_used < target_rows:
|
|
41
|
+
prev_newline = text.rfind("\n", 0, idx)
|
|
42
|
+
line_start = prev_newline + 1 if prev_newline != -1 else 0
|
|
43
|
+
line = text[line_start:idx]
|
|
44
|
+
expanded = line.expandtabs()
|
|
45
|
+
line_len = len(expanded)
|
|
46
|
+
line_rows = max(1, math.ceil(line_len / width)) if line_len else 1
|
|
47
|
+
|
|
48
|
+
if rows_used + line_rows >= target_rows:
|
|
49
|
+
rows_remaining = target_rows - rows_used
|
|
50
|
+
if rows_remaining <= 0:
|
|
51
|
+
start_idx = idx
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
if line_rows <= rows_remaining:
|
|
55
|
+
start_idx = line_start
|
|
56
|
+
else:
|
|
57
|
+
approx_chars = width * rows_remaining
|
|
58
|
+
keep_chars = min(len(line), approx_chars)
|
|
59
|
+
start_idx = idx - keep_chars
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
rows_used += line_rows
|
|
63
|
+
start_idx = line_start
|
|
64
|
+
if prev_newline == -1:
|
|
65
|
+
break
|
|
66
|
+
idx = prev_newline
|
|
67
|
+
|
|
68
|
+
return text[start_idx:]
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""Streaming buffer for markdown content with intelligent truncation.
|
|
2
|
+
|
|
3
|
+
This module provides a simple, robust streaming buffer that:
|
|
4
|
+
1. Accumulates streaming chunks from LLM responses
|
|
5
|
+
2. Truncates to fit terminal height (keeps most recent content)
|
|
6
|
+
3. Preserves markdown context when truncating:
|
|
7
|
+
- Code blocks: retains opening ```language fence
|
|
8
|
+
- Tables: retains header + separator rows
|
|
9
|
+
- Code blocks: adds closing ``` if unclosed
|
|
10
|
+
|
|
11
|
+
Design Philosophy
|
|
12
|
+
=================
|
|
13
|
+
KISS (Keep It Simple, Stupid):
|
|
14
|
+
- No binary search (streaming is linear)
|
|
15
|
+
- No dual modes (streaming always keeps recent content)
|
|
16
|
+
- Parse once per truncation (not per chunk)
|
|
17
|
+
- Position-based tracking (clear, testable)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from math import ceil
|
|
22
|
+
from typing import List, Optional
|
|
23
|
+
|
|
24
|
+
from markdown_it import MarkdownIt
|
|
25
|
+
from markdown_it.token import Token
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CodeBlock:
|
|
30
|
+
"""Position and metadata for a code block."""
|
|
31
|
+
|
|
32
|
+
start_pos: int # Character position where block starts
|
|
33
|
+
end_pos: int # Character position where block ends
|
|
34
|
+
language: str # Language identifier (e.g., "python")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Table:
|
|
39
|
+
"""Position and metadata for a table."""
|
|
40
|
+
|
|
41
|
+
start_pos: int # Character position where table starts
|
|
42
|
+
end_pos: int # Character position where table ends
|
|
43
|
+
header_lines: List[str] # Header row + separator (e.g., ["| A | B |", "|---|---|"])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StreamBuffer:
|
|
47
|
+
"""Buffer for streaming markdown content with smart truncation.
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
buffer = StreamBuffer()
|
|
51
|
+
for chunk in stream:
|
|
52
|
+
buffer.append(chunk)
|
|
53
|
+
display_text = buffer.get_display_text(terminal_height)
|
|
54
|
+
render(display_text)
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self):
|
|
58
|
+
"""Initialize the stream buffer."""
|
|
59
|
+
self._chunks: List[str] = []
|
|
60
|
+
self._parser = MarkdownIt().enable("table")
|
|
61
|
+
|
|
62
|
+
def append(self, chunk: str) -> None:
|
|
63
|
+
"""Add a chunk to the buffer.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
chunk: Text chunk from streaming response
|
|
67
|
+
"""
|
|
68
|
+
if chunk:
|
|
69
|
+
self._chunks.append(chunk)
|
|
70
|
+
|
|
71
|
+
def get_full_text(self) -> str:
|
|
72
|
+
"""Get the complete buffered text.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Full concatenated text from all chunks
|
|
76
|
+
"""
|
|
77
|
+
return "".join(self._chunks)
|
|
78
|
+
|
|
79
|
+
def get_display_text(
|
|
80
|
+
self,
|
|
81
|
+
terminal_height: int,
|
|
82
|
+
target_ratio: float = 0.7,
|
|
83
|
+
terminal_width: Optional[int] = None,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Get text for display, truncated to fit terminal.
|
|
86
|
+
|
|
87
|
+
This applies intelligent truncation when content exceeds terminal height:
|
|
88
|
+
1. Keeps most recent content (last N lines)
|
|
89
|
+
2. Preserves code block fences if truncated mid-block
|
|
90
|
+
3. Preserves table headers if truncated in table data
|
|
91
|
+
4. Adds closing fence if code block is unclosed
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
terminal_height: Height of terminal in lines
|
|
95
|
+
target_ratio: Keep this multiple of terminal height (default 1.5)
|
|
96
|
+
terminal_width: Optional terminal width for estimating wrapped lines
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Text ready for display (truncated if needed)
|
|
100
|
+
"""
|
|
101
|
+
full_text = self.get_full_text()
|
|
102
|
+
if not full_text:
|
|
103
|
+
return full_text
|
|
104
|
+
return self._truncate_for_display(
|
|
105
|
+
full_text, terminal_height, target_ratio, terminal_width
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def clear(self) -> None:
|
|
109
|
+
"""Clear the buffer."""
|
|
110
|
+
self._chunks.clear()
|
|
111
|
+
|
|
112
|
+
def _truncate_for_display(
|
|
113
|
+
self,
|
|
114
|
+
text: str,
|
|
115
|
+
terminal_height: int,
|
|
116
|
+
target_ratio: float,
|
|
117
|
+
terminal_width: Optional[int],
|
|
118
|
+
) -> str:
|
|
119
|
+
"""Truncate text to fit display with context preservation.
|
|
120
|
+
|
|
121
|
+
Algorithm:
|
|
122
|
+
1. If text fits, return as-is
|
|
123
|
+
2. Otherwise, keep last N lines (where N = terminal_height * target_ratio)
|
|
124
|
+
3. Parse markdown to find code blocks and tables
|
|
125
|
+
4. If we truncated mid-code-block, prepend opening fence
|
|
126
|
+
5. If we truncated mid-table-data, prepend table header
|
|
127
|
+
6. If code block is unclosed, append closing fence
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
text: Full markdown text
|
|
131
|
+
terminal_height: Terminal height in lines
|
|
132
|
+
target_ratio: Multiplier for target line count
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Truncated text with preserved context
|
|
136
|
+
"""
|
|
137
|
+
lines = text.split("\n")
|
|
138
|
+
|
|
139
|
+
if target_ratio <= 1:
|
|
140
|
+
extra_lines = 0
|
|
141
|
+
else:
|
|
142
|
+
extra_lines = int(ceil(terminal_height * (target_ratio - 1)))
|
|
143
|
+
raw_target_lines = terminal_height + extra_lines
|
|
144
|
+
|
|
145
|
+
# Estimate how many rendered lines the text will occupy
|
|
146
|
+
if terminal_width and terminal_width > 0:
|
|
147
|
+
# Treat each logical line as taking at least one row, expanding based on width
|
|
148
|
+
display_counts = self._estimate_display_counts(lines, terminal_width)
|
|
149
|
+
total_display_lines = sum(display_counts)
|
|
150
|
+
else:
|
|
151
|
+
display_counts = None
|
|
152
|
+
total_display_lines = len(lines)
|
|
153
|
+
|
|
154
|
+
# Fast path: no truncation needed if content still fits the viewport
|
|
155
|
+
if total_display_lines <= terminal_height:
|
|
156
|
+
# Still need to check for unclosed code blocks
|
|
157
|
+
return self._add_closing_fence_if_needed(text)
|
|
158
|
+
|
|
159
|
+
# Determine how many display lines we want to keep after truncation
|
|
160
|
+
desired_display_lines = min(total_display_lines, raw_target_lines)
|
|
161
|
+
if desired_display_lines > terminal_height:
|
|
162
|
+
window_lines = max(1, terminal_height // 5) # keep ~20% headroom
|
|
163
|
+
desired_display_lines = max(terminal_height, desired_display_lines - window_lines)
|
|
164
|
+
else:
|
|
165
|
+
desired_display_lines = terminal_height
|
|
166
|
+
|
|
167
|
+
# Determine how many logical lines we can keep based on estimated display rows
|
|
168
|
+
if display_counts:
|
|
169
|
+
running_total = 0
|
|
170
|
+
start_index = len(lines) - 1
|
|
171
|
+
for idx in range(len(lines) - 1, -1, -1):
|
|
172
|
+
running_total += display_counts[idx]
|
|
173
|
+
start_index = idx
|
|
174
|
+
if running_total >= desired_display_lines:
|
|
175
|
+
break
|
|
176
|
+
else:
|
|
177
|
+
start_index = max(len(lines) - desired_display_lines, 0)
|
|
178
|
+
|
|
179
|
+
# Compute character position where truncation occurs
|
|
180
|
+
truncation_pos = sum(len(line) + 1 for line in lines[:start_index])
|
|
181
|
+
truncated_text = text[truncation_pos:]
|
|
182
|
+
|
|
183
|
+
if terminal_width and terminal_width > 0:
|
|
184
|
+
truncated_text, truncation_pos = self._trim_within_line_if_needed(
|
|
185
|
+
text=text,
|
|
186
|
+
truncated_text=truncated_text,
|
|
187
|
+
truncation_pos=truncation_pos,
|
|
188
|
+
terminal_width=terminal_width,
|
|
189
|
+
max_display_lines=desired_display_lines,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Parse markdown structures once
|
|
193
|
+
code_blocks = self._find_code_blocks(text)
|
|
194
|
+
tables = self._find_tables(text)
|
|
195
|
+
|
|
196
|
+
# Preserve code block context if needed
|
|
197
|
+
truncated_text = self._preserve_code_block_context(
|
|
198
|
+
text, truncated_text, truncation_pos, code_blocks
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Preserve table context if needed
|
|
202
|
+
truncated_text = self._preserve_table_context(
|
|
203
|
+
text, truncated_text, truncation_pos, tables
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Add closing fence if code block is unclosed
|
|
207
|
+
truncated_text = self._add_closing_fence_if_needed(truncated_text)
|
|
208
|
+
|
|
209
|
+
return truncated_text
|
|
210
|
+
|
|
211
|
+
def _find_code_blocks(self, text: str) -> List[CodeBlock]:
|
|
212
|
+
"""Find all code blocks in text using markdown-it parser.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
text: Markdown text to analyze
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
List of CodeBlock objects with position information
|
|
219
|
+
"""
|
|
220
|
+
tokens = self._parser.parse(text)
|
|
221
|
+
lines = text.split("\n")
|
|
222
|
+
blocks = []
|
|
223
|
+
|
|
224
|
+
for token in self._flatten_tokens(tokens):
|
|
225
|
+
if token.type in ("fence", "code_block") and token.map:
|
|
226
|
+
start_line = token.map[0]
|
|
227
|
+
end_line = token.map[1]
|
|
228
|
+
start_pos = sum(len(line) + 1 for line in lines[:start_line])
|
|
229
|
+
end_pos = sum(len(line) + 1 for line in lines[:end_line])
|
|
230
|
+
language = getattr(token, "info", "") or ""
|
|
231
|
+
|
|
232
|
+
blocks.append(
|
|
233
|
+
CodeBlock(start_pos=start_pos, end_pos=end_pos, language=language)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return blocks
|
|
237
|
+
|
|
238
|
+
def _find_tables(self, text: str) -> List[Table]:
|
|
239
|
+
"""Find all tables in text using markdown-it parser.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
text: Markdown text to analyze
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
List of Table objects with position and header information
|
|
246
|
+
"""
|
|
247
|
+
tokens = self._parser.parse(text)
|
|
248
|
+
lines = text.split("\n")
|
|
249
|
+
tables = []
|
|
250
|
+
|
|
251
|
+
for i, token in enumerate(tokens):
|
|
252
|
+
if token.type == "table_open" and token.map:
|
|
253
|
+
# Find tbody within this table to extract header
|
|
254
|
+
tbody_start_line = None
|
|
255
|
+
|
|
256
|
+
# Look ahead for tbody
|
|
257
|
+
for j in range(i + 1, len(tokens)):
|
|
258
|
+
if tokens[j].type == "tbody_open" and tokens[j].map:
|
|
259
|
+
tbody_start_line = tokens[j].map[0]
|
|
260
|
+
break
|
|
261
|
+
elif tokens[j].type == "table_close":
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
if tbody_start_line is not None:
|
|
265
|
+
table_start_line = token.map[0]
|
|
266
|
+
table_end_line = token.map[1]
|
|
267
|
+
|
|
268
|
+
# Calculate positions
|
|
269
|
+
start_pos = sum(len(line) + 1 for line in lines[:table_start_line])
|
|
270
|
+
end_pos = sum(len(line) + 1 for line in lines[:table_end_line])
|
|
271
|
+
|
|
272
|
+
# Header lines = everything before tbody (header row + separator)
|
|
273
|
+
header_lines = lines[table_start_line:tbody_start_line]
|
|
274
|
+
|
|
275
|
+
tables.append(
|
|
276
|
+
Table(start_pos=start_pos, end_pos=end_pos, header_lines=header_lines)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return tables
|
|
280
|
+
|
|
281
|
+
def _preserve_code_block_context(
|
|
282
|
+
self, original_text: str, truncated_text: str, truncation_pos: int, code_blocks: List[CodeBlock]
|
|
283
|
+
) -> str:
|
|
284
|
+
"""Prepend code block opening fence if truncation removed it.
|
|
285
|
+
|
|
286
|
+
When we truncate mid-code-block, we need to preserve the opening fence
|
|
287
|
+
so the remaining code still renders with syntax highlighting.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
original_text: Full original text
|
|
291
|
+
truncated_text: Text after truncation
|
|
292
|
+
truncation_pos: Character position where truncation happened
|
|
293
|
+
code_blocks: List of code blocks in original text
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Truncated text with fence prepended if needed
|
|
297
|
+
"""
|
|
298
|
+
for block in code_blocks:
|
|
299
|
+
# Check if we truncated within this code block
|
|
300
|
+
if block.start_pos < truncation_pos < block.end_pos:
|
|
301
|
+
# We're inside this block - did we remove the opening fence?
|
|
302
|
+
if truncation_pos > block.start_pos:
|
|
303
|
+
fence = f"```{block.language}\n"
|
|
304
|
+
# Avoid duplicates
|
|
305
|
+
if not truncated_text.startswith(fence):
|
|
306
|
+
return fence + truncated_text
|
|
307
|
+
# Found the relevant block, no need to check others
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
return truncated_text
|
|
311
|
+
|
|
312
|
+
def _preserve_table_context(
|
|
313
|
+
self, original_text: str, truncated_text: str, truncation_pos: int, tables: List[Table]
|
|
314
|
+
) -> str:
|
|
315
|
+
"""Prepend table header if truncation removed it.
|
|
316
|
+
|
|
317
|
+
When we truncate table data rows, we need to preserve the header
|
|
318
|
+
(header row + separator) so the remaining rows have context.
|
|
319
|
+
|
|
320
|
+
Design Point #4: Keep the 3 lines marking beginning of table:
|
|
321
|
+
- Newline before table (if present)
|
|
322
|
+
- Header row (e.g., "| Name | Size |")
|
|
323
|
+
- Separator (e.g., "|------|------|")
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
original_text: Full original text
|
|
327
|
+
truncated_text: Text after truncation
|
|
328
|
+
truncation_pos: Character position where truncation happened
|
|
329
|
+
tables: List of tables in original text
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Truncated text with header prepended if needed
|
|
333
|
+
"""
|
|
334
|
+
for table in tables:
|
|
335
|
+
# Check if we truncated within this table
|
|
336
|
+
if table.start_pos < truncation_pos < table.end_pos:
|
|
337
|
+
# Check if we removed the header (header is at start of table)
|
|
338
|
+
# If truncation happened after the header, we need to restore it
|
|
339
|
+
lines = original_text.split("\n")
|
|
340
|
+
table_start_line = sum(
|
|
341
|
+
1 for line in original_text[:table.start_pos].split("\n")
|
|
342
|
+
) - 1
|
|
343
|
+
|
|
344
|
+
# Find where the data rows start (after separator)
|
|
345
|
+
# Header lines include header row + separator
|
|
346
|
+
data_start_line = table_start_line + len(table.header_lines)
|
|
347
|
+
data_start_pos = sum(len(line) + 1 for line in lines[:data_start_line])
|
|
348
|
+
|
|
349
|
+
# If we truncated in the data section, restore header
|
|
350
|
+
if truncation_pos >= data_start_pos:
|
|
351
|
+
header_text = "\n".join(table.header_lines) + "\n"
|
|
352
|
+
return header_text + truncated_text
|
|
353
|
+
|
|
354
|
+
# Found the relevant table, no need to check others
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
return truncated_text
|
|
358
|
+
|
|
359
|
+
def _add_closing_fence_if_needed(self, text: str) -> str:
|
|
360
|
+
"""Add closing ``` fence if code block is unclosed.
|
|
361
|
+
|
|
362
|
+
Design Point #5: Add closing fence to bottom if we detect unclosed block.
|
|
363
|
+
This ensures partial code blocks render correctly during streaming.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
text: Markdown text to check
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
Text with closing fence added if needed
|
|
370
|
+
"""
|
|
371
|
+
# Count opening vs closing fences
|
|
372
|
+
import re
|
|
373
|
+
|
|
374
|
+
opening_fences = len(re.findall(r"^```", text, re.MULTILINE))
|
|
375
|
+
closing_fences = len(re.findall(r"^```\s*$", text, re.MULTILINE))
|
|
376
|
+
|
|
377
|
+
# If odd number of fences, we have an unclosed block
|
|
378
|
+
if opening_fences > closing_fences:
|
|
379
|
+
# Check if text already ends with a closing fence
|
|
380
|
+
if not re.search(r"```\s*$", text):
|
|
381
|
+
return text + "\n```\n"
|
|
382
|
+
|
|
383
|
+
return text
|
|
384
|
+
|
|
385
|
+
def _flatten_tokens(self, tokens: List[Token]) -> List[Token]:
|
|
386
|
+
"""Flatten nested token tree.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
tokens: List of tokens from markdown-it
|
|
390
|
+
|
|
391
|
+
Yields:
|
|
392
|
+
Flattened tokens
|
|
393
|
+
"""
|
|
394
|
+
for token in tokens:
|
|
395
|
+
is_fence = token.type == "fence"
|
|
396
|
+
is_image = token.tag == "img"
|
|
397
|
+
if token.children and not (is_image or is_fence):
|
|
398
|
+
yield from self._flatten_tokens(token.children)
|
|
399
|
+
else:
|
|
400
|
+
yield token
|
|
401
|
+
|
|
402
|
+
def _estimate_display_counts(self, lines: List[str], terminal_width: int) -> List[int]:
|
|
403
|
+
"""Estimate how many terminal rows each logical line will occupy."""
|
|
404
|
+
return [
|
|
405
|
+
max(1, ceil(len(line) / terminal_width)) if line else 1
|
|
406
|
+
for line in lines
|
|
407
|
+
]
|
|
408
|
+
|
|
409
|
+
def _estimate_display_lines(self, text: str, terminal_width: int) -> int:
|
|
410
|
+
"""Estimate how many terminal rows the given text will occupy."""
|
|
411
|
+
if not text:
|
|
412
|
+
return 0
|
|
413
|
+
lines = text.split("\n")
|
|
414
|
+
return sum(self._estimate_display_counts(lines, terminal_width))
|
|
415
|
+
|
|
416
|
+
def _trim_within_line_if_needed(
|
|
417
|
+
self,
|
|
418
|
+
text: str,
|
|
419
|
+
truncated_text: str,
|
|
420
|
+
truncation_pos: int,
|
|
421
|
+
terminal_width: int,
|
|
422
|
+
max_display_lines: int,
|
|
423
|
+
) -> tuple[str, int]:
|
|
424
|
+
"""Trim additional characters when a single line exceeds the viewport."""
|
|
425
|
+
current_pos = truncation_pos
|
|
426
|
+
current_text = truncated_text
|
|
427
|
+
estimated_lines = self._estimate_display_lines(current_text, terminal_width)
|
|
428
|
+
|
|
429
|
+
while estimated_lines > max_display_lines and current_pos < len(text):
|
|
430
|
+
excess_display = estimated_lines - max_display_lines
|
|
431
|
+
chars_to_trim = excess_display * terminal_width
|
|
432
|
+
if chars_to_trim <= 0:
|
|
433
|
+
break
|
|
434
|
+
|
|
435
|
+
candidate_pos = min(len(text), current_pos + chars_to_trim)
|
|
436
|
+
|
|
437
|
+
# Prefer trimming at the next newline to keep markdown structures intact
|
|
438
|
+
newline_pos = text.find("\n", current_pos, candidate_pos)
|
|
439
|
+
if newline_pos != -1:
|
|
440
|
+
candidate_pos = newline_pos + 1
|
|
441
|
+
|
|
442
|
+
if candidate_pos <= current_pos:
|
|
443
|
+
break
|
|
444
|
+
|
|
445
|
+
current_pos = candidate_pos
|
|
446
|
+
current_text = text[current_pos:]
|
|
447
|
+
estimated_lines = self._estimate_display_lines(current_text, terminal_width)
|
|
448
|
+
|
|
449
|
+
return current_text, current_pos
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fast-agent-mcp
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.15
|
|
4
4
|
Summary: Define, Prompt and Test MCP enabled Agents and Workflows
|
|
5
5
|
Author-email: Shaun Smith <fastagent@llmindset.co.uk>
|
|
6
6
|
License: Apache License
|
|
@@ -219,8 +219,8 @@ Requires-Dist: email-validator>=2.2.0
|
|
|
219
219
|
Requires-Dist: fastapi>=0.115.6
|
|
220
220
|
Requires-Dist: google-genai>=1.33.0
|
|
221
221
|
Requires-Dist: keyring>=24.3.1
|
|
222
|
-
Requires-Dist: mcp==1.
|
|
223
|
-
Requires-Dist: openai>=2.
|
|
222
|
+
Requires-Dist: mcp==1.18.0
|
|
223
|
+
Requires-Dist: openai>=2.3.0
|
|
224
224
|
Requires-Dist: opentelemetry-distro>=0.55b0
|
|
225
225
|
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.7.0
|
|
226
226
|
Requires-Dist: opentelemetry-instrumentation-anthropic>=0.43.1; python_version >= '3.10' and python_version < '4.0'
|
|
@@ -234,6 +234,7 @@ Requires-Dist: pyperclip>=1.9.0
|
|
|
234
234
|
Requires-Dist: pyyaml>=6.0.2
|
|
235
235
|
Requires-Dist: rich>=14.1.0
|
|
236
236
|
Requires-Dist: tensorzero>=2025.7.5
|
|
237
|
+
Requires-Dist: textual>=6.2.1
|
|
237
238
|
Requires-Dist: typer>=0.15.1
|
|
238
239
|
Description-Content-Type: text/markdown
|
|
239
240
|
|