diff --git a/examples/setup/fastagent.config.yaml b/examples/setup/fastagent.config.yaml index 1bace2cb..012caab1 100644 --- a/examples/setup/fastagent.config.yaml +++ b/examples/setup/fastagent.config.yaml @@ -15,6 +15,11 @@ default_model: gpt-5-mini.low # mcp_ui_output_dir: ".fast-agent/ui" # Where to write MCP-UI HTML files (relative to CWD if not absolute) # mcp_ui_mode: enabled +# MCP timeline display (adjust activity window/intervals in MCP UI + fast-agent check) +#mcp_timeline: +# steps: 20 # number of timeline buckets to render +# step_seconds: 30 # seconds per bucket (accepts values like "45s", "2m") + # Logging and Console Configuration: logger: # level: "debug" | "info" | "warning" | "error" diff --git a/src/fast_agent/cli/commands/check_config.py b/src/fast_agent/cli/commands/check_config.py index 4fe754f4..04c6fc25 100644 --- a/src/fast_agent/cli/commands/check_config.py +++ b/src/fast_agent/cli/commands/check_config.py @@ -144,7 +144,7 @@ def get_fastagent_version() -> str: def get_config_summary(config_path: Optional[Path]) -> dict: """Extract key information from the configuration file.""" - from fast_agent.config import Settings + from fast_agent.config import MCPTimelineSettings, Settings # Get actual defaults from Settings class default_settings = Settings() @@ -163,6 +163,10 @@ def get_config_summary(config_path: Optional[Path]) -> dict: "enable_markup": default_settings.logger.enable_markup, }, "mcp_ui_mode": default_settings.mcp_ui_mode, + "timeline": { + "steps": default_settings.mcp_timeline.steps, + "step_seconds": default_settings.mcp_timeline.step_seconds, + }, "mcp_servers": [], } @@ -211,6 +215,22 @@ def get_config_summary(config_path: Optional[Path]) -> dict: if "mcp_ui_mode" in config: result["mcp_ui_mode"] = config["mcp_ui_mode"] + # Get timeline settings + if "mcp_timeline" in config: + try: + timeline_override = MCPTimelineSettings(**(config.get("mcp_timeline") or {})) + except Exception as exc: # pragma: no cover - defensive + console.print( + "[yellow]Warning:[/yellow] Invalid mcp_timeline configuration; " + "using defaults." + ) + console.print(f"[yellow]Details:[/yellow] {exc}") + else: + result["timeline"] = { + "steps": timeline_override.steps, + "step_seconds": timeline_override.step_seconds, + } + # Get MCP server info if "mcp" in config and "servers" in config["mcp"]: for server_name, server_config in config["mcp"]["servers"].items(): @@ -385,6 +405,28 @@ def bool_to_symbol(value): else: mcp_ui_display = f"[green]{mcp_ui_mode}[/green]" + timeline_settings = config_summary.get("timeline", {}) + timeline_steps = timeline_settings.get("steps", 20) + timeline_step_seconds = timeline_settings.get("step_seconds", 30) + + def format_step_interval(seconds: int) -> str: + try: + total = int(seconds) + except (TypeError, ValueError): + return str(seconds) + if total <= 0: + return "0s" + if total % 86400 == 0: + return f"{total // 86400}d" + if total % 3600 == 0: + return f"{total // 3600}h" + if total % 60 == 0: + return f"{total // 60}m" + minutes, secs = divmod(total, 60) + if minutes: + return f"{minutes}m{secs:02d}s" + return f"{secs}s" + # Prepare all settings as pairs settings_data = [ ("Log Level", logger.get("level", "warning (default)")), @@ -395,6 +437,8 @@ def bool_to_symbol(value): ("Show Tools", bool_to_symbol(logger.get("show_tools", True))), ("Truncate Tools", bool_to_symbol(logger.get("truncate_tools", True))), ("Enable Markup", bool_to_symbol(logger.get("enable_markup", True))), + ("Timeline Steps", f"[green]{timeline_steps}[/green]"), + ("Timeline Interval", f"[green]{format_step_interval(timeline_step_seconds)}[/green]"), ] # Add rows in two-column layout, styling some values in green diff --git a/src/fast_agent/config.py b/src/fast_agent/config.py index 35644601..45da93b9 100644 --- a/src/fast_agent/config.py +++ b/src/fast_agent/config.py @@ -49,6 +49,66 @@ class MCPElicitationSettings(BaseModel): model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) +class MCPTimelineSettings(BaseModel): + """Configuration for MCP activity timeline display.""" + + steps: int = 20 + """Number of timeline buckets to render.""" + + step_seconds: int = 30 + """Duration of each timeline bucket in seconds.""" + + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) + + @staticmethod + def _parse_duration(value: str) -> int: + """Parse simple duration strings like '30s', '2m', '1h' into seconds.""" + pattern = re.compile(r"^\s*(\d+)\s*([smhd]?)\s*$", re.IGNORECASE) + match = pattern.match(value) + if not match: + raise ValueError("Expected duration in seconds (e.g. 30, '45s', '2m').") + amount = int(match.group(1)) + unit = match.group(2).lower() + multiplier = { + "": 1, + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + }.get(unit) + if multiplier is None: + raise ValueError("Duration unit must be one of s, m, h, or d.") + return amount * multiplier + + @field_validator("steps", mode="before") + @classmethod + def _coerce_steps(cls, value: Any) -> int: + if isinstance(value, str): + if not value.strip().isdigit(): + raise ValueError("Timeline steps must be a positive integer.") + value = int(value.strip()) + elif isinstance(value, float): + value = int(value) + if not isinstance(value, int): + raise TypeError("Timeline steps must be an integer.") + if value <= 0: + raise ValueError("Timeline steps must be greater than zero.") + return value + + @field_validator("step_seconds", mode="before") + @classmethod + def _coerce_step_seconds(cls, value: Any) -> int: + if isinstance(value, str): + value = cls._parse_duration(value) + elif isinstance(value, (int, float)): + value = int(value) + else: + raise TypeError("Timeline step duration must be a number of seconds.") + if value <= 0: + raise ValueError("Timeline step duration must be greater than zero.") + return value + + class MCPRootSettings(BaseModel): """Represents a root directory configuration for an MCP server.""" @@ -528,6 +588,9 @@ class Settings(BaseSettings): mcp_ui_output_dir: str = ".fast-agent/ui" """Directory where MCP-UI HTML files are written. Relative paths are resolved from CWD.""" + mcp_timeline: MCPTimelineSettings = MCPTimelineSettings() + """Display settings for MCP activity timelines.""" + @classmethod def find_config(cls) -> Path | None: """Find the config file in the current directory or parent directories.""" diff --git a/src/fast_agent/mcp/mcp_aggregator.py b/src/fast_agent/mcp/mcp_aggregator.py index 0114141c..d903d2b3 100644 --- a/src/fast_agent/mcp/mcp_aggregator.py +++ b/src/fast_agent/mcp/mcp_aggregator.py @@ -137,7 +137,7 @@ async def __aenter__(self): server_registry = context.server_registry if server_registry is None: raise RuntimeError("Context is missing server registry for MCP connections") - manager = MCPConnectionManager(server_registry) + manager = MCPConnectionManager(server_registry, context=context) await manager.__aenter__() context._connection_manager = manager self._persistent_connection_manager = cast( diff --git a/src/fast_agent/mcp/mcp_connection_manager.py b/src/fast_agent/mcp/mcp_connection_manager.py index 495421f7..6416e880 100644 --- a/src/fast_agent/mcp/mcp_connection_manager.py +++ b/src/fast_agent/mcp/mcp_connection_manager.py @@ -440,8 +440,24 @@ async def launch_server( logger.debug(f"{server_name}: Found server configuration=", data=config.model_dump()) + timeline_steps = 20 + timeline_seconds = 30 + try: + ctx = self.context + except RuntimeError: + ctx = None + + config_obj = getattr(ctx, "config", None) + timeline_config = getattr(config_obj, "mcp_timeline", None) + if timeline_config: + timeline_steps = getattr(timeline_config, "steps", timeline_steps) + timeline_seconds = getattr(timeline_config, "step_seconds", timeline_seconds) + transport_metrics = ( - TransportChannelMetrics() + TransportChannelMetrics( + bucket_seconds=timeline_seconds, + bucket_count=timeline_steps, + ) if config.transport in ("http", "sse", "stdio") else None ) diff --git a/src/fast_agent/mcp/transport_tracking.py b/src/fast_agent/mcp/transport_tracking.py index bba57b32..933f25fd 100644 --- a/src/fast_agent/mcp/transport_tracking.py +++ b/src/fast_agent/mcp/transport_tracking.py @@ -82,6 +82,8 @@ class ChannelSnapshot(BaseModel): response_count: int = 0 notification_count: int = 0 activity_buckets: list[str] | None = None + activity_bucket_seconds: int | None = None + activity_bucket_count: int | None = None class TransportSnapshot(BaseModel): @@ -95,12 +97,18 @@ class TransportSnapshot(BaseModel): get: ChannelSnapshot | None = None resumption: ChannelSnapshot | None = None stdio: ChannelSnapshot | None = None + activity_bucket_seconds: int | None = None + activity_bucket_count: int | None = None class TransportChannelMetrics: """Aggregates low-level channel events into user-visible metrics.""" - def __init__(self) -> None: + def __init__( + self, + bucket_seconds: int | None = None, + bucket_count: int | None = None, + ) -> None: self._lock = Lock() self._post_modes: set[str] = set() @@ -155,8 +163,22 @@ def __init__(self) -> None: self._response_channel_by_id: dict[RequestId, ChannelName] = {} - self._history_bucket_seconds = 30 - self._history_bucket_count = 20 + try: + seconds = 30 if bucket_seconds is None else int(bucket_seconds) + except (TypeError, ValueError): + seconds = 30 + if seconds <= 0: + seconds = 30 + + try: + count = 20 if bucket_count is None else int(bucket_count) + except (TypeError, ValueError): + count = 20 + if count <= 0: + count = 20 + + self._history_bucket_seconds = seconds + self._history_bucket_count = count self._history_priority = { "error": 5, "disabled": 4, @@ -463,6 +485,8 @@ def _build_post_mode_snapshot(self, mode: str, now: datetime) -> ChannelSnapshot last_message_summary=stats.last_summary, last_message_at=stats.last_at, activity_buckets=self._build_activity_buckets(f"post-{mode}", now), + activity_bucket_seconds=self._history_bucket_seconds, + activity_bucket_count=self._history_bucket_count, ) def snapshot(self) -> TransportSnapshot: @@ -503,6 +527,8 @@ def snapshot(self) -> TransportSnapshot: response_count=self._post_response_count, notification_count=self._post_notification_count, activity_buckets=self._merge_activity_buckets(["post-json", "post-sse"], now), + activity_bucket_seconds=self._history_bucket_seconds, + activity_bucket_count=self._history_bucket_count, ) post_json_snapshot = self._build_post_mode_snapshot("json", now) @@ -543,6 +569,8 @@ def snapshot(self) -> TransportSnapshot: response_count=self._get_response_count, notification_count=self._get_notification_count, activity_buckets=self._build_activity_buckets("get", now), + activity_bucket_seconds=self._history_bucket_seconds, + activity_bucket_count=self._history_bucket_count, ) resumption_snapshot = None @@ -555,6 +583,8 @@ def snapshot(self) -> TransportSnapshot: response_count=self._resumption_response_count, notification_count=self._resumption_notification_count, activity_buckets=self._build_activity_buckets("resumption", now), + activity_bucket_seconds=self._history_bucket_seconds, + activity_bucket_count=self._history_bucket_count, ) stdio_snapshot = None @@ -588,6 +618,8 @@ def snapshot(self) -> TransportSnapshot: response_count=self._stdio_response_count, notification_count=self._stdio_notification_count, activity_buckets=self._build_activity_buckets("stdio", now), + activity_bucket_seconds=self._history_bucket_seconds, + activity_bucket_count=self._history_bucket_count, ) return TransportSnapshot( @@ -597,4 +629,6 @@ def snapshot(self) -> TransportSnapshot: get=get_snapshot, resumption=resumption_snapshot, stdio=stdio_snapshot, + activity_bucket_seconds=self._history_bucket_seconds, + activity_bucket_count=self._history_bucket_count, ) diff --git a/src/fast_agent/ui/mcp_display.py b/src/fast_agent/ui/mcp_display.py index 1bc71363..129a137c 100644 --- a/src/fast_agent/ui/mcp_display.py +++ b/src/fast_agent/ui/mcp_display.py @@ -104,6 +104,38 @@ def _format_compact_duration(seconds: float | None) -> str | None: return f"{days}d{hours:02d}h" +def _format_timeline_label(total_seconds: int) -> str: + total = max(0, int(total_seconds)) + if total == 0: + return "0s" + + days, remainder = divmod(total, 86400) + if days: + if remainder == 0: + return f"{days}d" + hours = remainder // 3600 + if hours == 0: + return f"{days}d" + return f"{days}d{hours}h" + + hours, remainder = divmod(total, 3600) + if hours: + if remainder == 0: + return f"{hours}h" + minutes = remainder // 60 + if minutes == 0: + return f"{hours}h" + return f"{hours}h{minutes:02d}m" + + minutes, seconds = divmod(total, 60) + if minutes: + if seconds == 0: + return f"{minutes}m" + return f"{minutes}m{seconds:02d}s" + + return f"{seconds}s" + + def _summarise_call_counts(call_counts: dict[str, int]) -> str | None: if not call_counts: return None @@ -268,10 +300,28 @@ def _format_label(label: str, width: int = 10) -> str: return f"{label:<{width}}" if len(label) < width else label -def _build_inline_timeline(buckets: Iterable[str]) -> str: +def _build_inline_timeline( + buckets: Iterable[str], + *, + bucket_seconds: int | None = None, + bucket_count: int | None = None, +) -> str: """Build a compact timeline string for inline display.""" - timeline = " [dim]10m[/dim] " - for state in buckets: + bucket_list = list(buckets) + count = bucket_count or len(bucket_list) + if count <= 0: + count = len(bucket_list) or 1 + + seconds = bucket_seconds or 30 + total_window = seconds * count + timeline = f" [dim]{_format_timeline_label(total_window)}[/dim] " + + if len(bucket_list) < count: + bucket_list.extend(["none"] * (count - len(bucket_list))) + elif len(bucket_list) > count: + bucket_list = bucket_list[-count:] + + for state in bucket_list: color = TIMELINE_COLORS.get(state, Colours.NONE) if state in {"idle", "none"}: symbol = SYMBOL_IDLE @@ -337,36 +387,31 @@ def _render_channel_summary(status: ServerStatus, indent: str, total_width: int) # Determine if we're showing stdio or HTTP channels is_stdio = stdio_channel is not None + default_bucket_seconds = getattr(snapshot, "activity_bucket_seconds", None) or 30 + default_bucket_count = getattr(snapshot, "activity_bucket_count", None) or 20 + timeline_header_label = _format_timeline_label(default_bucket_seconds * default_bucket_count) + + # Total characters before the metrics section in each row (excluding indent) + # Structure: "│ " + arrow + " " + label(13) + timeline_label + " " + buckets + " now" + metrics_prefix_width = 22 + len(timeline_header_label) + default_bucket_count + # Get transport type for display transport = transport_value or "unknown" transport_display = transport.upper() if transport != "unknown" else "Channels" # Header with column labels header = Text(indent) - header.append(f"┌ {transport_display} ", style="dim") + header_intro = f"┌ {transport_display} " + header.append(header_intro, style="dim") # Calculate padding needed based on transport display length - # Base structure: "┌ " (2) + transport_display + " " (1) + "─" padding to align with columns - header_prefix_len = 3 + len(transport_display) + header_prefix_len = len(header_intro) + dash_count = max(1, metrics_prefix_width - header_prefix_len + 2) if is_stdio: - # Simplified header for stdio: just activity column - # Need to align with "│ ⇄ STDIO 10m ●●●●●●●●●●●●●●●●●●●● now 29" - # That's: "│ " + arrow + " " + label(13) + "10m " + dots(20) + " now" = 47 chars - # Then: " " + activity(8) = 10 chars - # Total content width = 47 + 10 = 57 chars - # So we need 47 - header_prefix_len dashes before "activity" - dash_count = max(1, 47 - header_prefix_len) header.append("─" * dash_count, style="dim") header.append(" activity", style="dim") else: - # Original header for HTTP channels - # Need to align with the req/resp/notif/ping columns - # Structure: "│ " + arrow + " " + label(13) + "10m " + dots(20) + " now" = 47 chars - # Then: " " + req(5) + " " + resp(5) + " " + notif(5) + " " + ping(5) = 25 chars - # Total content width = 47 + 25 = 72 chars - # So we need 47 - header_prefix_len dashes before the column headers - dash_count = max(1, 47 - header_prefix_len) header.append("─" * dash_count, style="dim") header.append(" req resp notif ping", style="dim") @@ -454,10 +499,24 @@ def _render_channel_summary(status: ServerStatus, indent: str, total_width: int) line.append(f" {label:<13}", style=label_style) # Always show timeline (dim black dots if no data) - line.append("10m ", style="dim") - if channel and channel.activity_buckets: + channel_bucket_seconds = ( + getattr(channel, "activity_bucket_seconds", None) or default_bucket_seconds + ) + bucket_count = ( + len(channel.activity_buckets) + if channel and channel.activity_buckets + else getattr(channel, "activity_bucket_count", None) + ) + if not bucket_count or bucket_count <= 0: + bucket_count = default_bucket_count + total_window_seconds = channel_bucket_seconds * bucket_count + timeline_label = _format_timeline_label(total_window_seconds) + + line.append(f"{timeline_label} ", style="dim") + bucket_states = channel.activity_buckets if channel and channel.activity_buckets else None + if bucket_states: # Show actual activity - for bucket_state in channel.activity_buckets: + for bucket_state in bucket_states: color = timeline_color_map.get(bucket_state, "dim") if bucket_state in {"idle", "none"}: symbol = SYMBOL_IDLE @@ -478,7 +537,7 @@ def _render_channel_summary(status: ServerStatus, indent: str, total_width: int) line.append(symbol, style=f"bold {color}") else: # Show dim dots for no activity - for _ in range(20): + for _ in range(bucket_count): line.append(SYMBOL_IDLE, style="black dim") line.append(" now", style="dim")