diff --git a/docs/docs/ref/open_telemetry.md b/docs/docs/ref/open_telemetry.md index 406c9e61e..75f044b7f 100644 --- a/docs/docs/ref/open_telemetry.md +++ b/docs/docs/ref/open_telemetry.md @@ -46,4 +46,13 @@ otel: Then, run your agent as normal - telemetry is transmitted by default to `http://localhost:4318/v1/traces`. From the Jaeger UI use the "Services" drop down to select **fast-agent** and click "Find Traces" to view the output. +!!! note "OpenAI Responses WebSocket instrumentation" + With the currently pinned OpenLLMetry OpenAI instrumentation + (`opentelemetry-instrumentation-openai==0.62.1`), Responses API calls using the + WebSocket transport produce fast-agent's agent and root spans, but not the detailed + `openai.response` provider span. To capture provider metadata such as model and + response IDs, token usage, and finish reasons, use the SDK-backed SSE transport by + adding `transport=sse` to the model string, for example + `responses.gpt-5.6-terra?transport=sse`. + For full configuration settings, check the [configuration file reference](config_file/#opentelemetry-settings) diff --git a/pyproject.toml b/pyproject.toml index 5c03c9d7b..202bb973d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fast-agent-mcp" -version = "0.9.16" +version = "0.9.17" description = "Code, Build and Evaluate agents - excellent Model and Skills/MCP/ACP/A2A Support" readme = "README.md" license = { file = "LICENSE" } @@ -26,11 +26,11 @@ dependencies = [ "openai[aiohttp]==2.45.0", "prompt-toolkit==3.0.52", "aiohttp==3.14.1", - "opentelemetry-exporter-otlp-proto-http==1.42.1", - "opentelemetry-instrumentation-openai==0.61.0; python_version >= '3.10' and python_version < '4.0'", - "opentelemetry-instrumentation-anthropic==0.61.0; python_version >= '3.10' and python_version < '4.0'", - "opentelemetry-instrumentation-mcp==0.61.0; python_version >= '3.10' and python_version < '4.0'", - "opentelemetry-instrumentation-google-genai==0.7b1", + "opentelemetry-exporter-otlp-proto-http==1.43.0", + "opentelemetry-instrumentation-openai==0.62.1; python_version >= '3.10' and python_version < '4.0'", + "opentelemetry-instrumentation-anthropic==0.62.1; python_version >= '3.10' and python_version < '4.0'", + "opentelemetry-instrumentation-mcp==0.62.1; python_version >= '3.10' and python_version < '4.0'", + "opentelemetry-instrumentation-google-genai==1.0b1", "google-genai==2.10.0", "deprecated==1.3.1", "a2a-sdk==1.1.0", diff --git a/src/fast_agent/core/logging/listeners.py b/src/fast_agent/core/logging/listeners.py index 344041037..cee0d5ebe 100644 --- a/src/fast_agent/core/logging/listeners.py +++ b/src/fast_agent/core/logging/listeners.py @@ -292,6 +292,9 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": process_wait_seconds=_optional_nonnegative_int( event_data.get("process_wait_seconds") ), + process_yield_reason=_optional_text_or_none( + event_data.get("process_yield_reason") + ), process_has_observed_output=_optional_bool( event_data.get("process_has_observed_output") ), diff --git a/src/fast_agent/event_progress.py b/src/fast_agent/event_progress.py index 347b93bf7..cbaa7bd90 100644 --- a/src/fast_agent/event_progress.py +++ b/src/fast_agent/event_progress.py @@ -48,6 +48,7 @@ class ProgressEvent(BaseModel): process_command: str | None = None process_id: str | None = None process_wait_seconds: int | None = None + process_yield_reason: str | None = None process_has_observed_output: bool | None = None process_seconds_since_last_output: float | None = None process_total_output_bytes: int | None = None diff --git a/src/fast_agent/llm/model_selection.py b/src/fast_agent/llm/model_selection.py index 5845bd1db..e4ca85132 100644 --- a/src/fast_agent/llm/model_selection.py +++ b/src/fast_agent/llm/model_selection.py @@ -626,6 +626,15 @@ def configured_providers( env_key = ProviderKeyManager.get_env_var(provider_name) if config_key or env_key: providers.append(provider) + continue + + # OAuth / external credential stores are valid runtime sources for + # providers that intentionally support them. Keep this narrow so + # fallbacks like generic/ollama or optional HF hub tokens do not + # mark every local provider "configured". + if provider in {Provider.CODEX_RESPONSES, Provider.XAI}: + if ProviderKeyManager._provider_specific_fallback_key(provider_name): + providers.append(provider) return providers diff --git a/src/fast_agent/tools/shell_runtime.py b/src/fast_agent/tools/shell_runtime.py index ae9d46234..ce380d023 100644 --- a/src/fast_agent/tools/shell_runtime.py +++ b/src/fast_agent/tools/shell_runtime.py @@ -253,8 +253,10 @@ class _PollProcessArguments: class _ActiveProcessPoll: tool_use_id: str deadline_at: float + started_at: float last_progress_emitted_at: float = 0.0 pending_progress_task: asyncio.Task[None] | None = None + heartbeat_task: asyncio.Task[None] | None = None @dataclass(slots=True) @@ -768,12 +770,28 @@ def _emit_managed_process_output_progress_now( self, process: _ManagedShellProcess, active_poll: _ActiveProcessPoll, + ) -> None: + self._emit_managed_process_poll_progress_now( + process, + active_poll, + has_fresh_output=True, + log_message="Process output progress", + ) + + def _emit_managed_process_poll_progress_now( + self, + process: _ManagedShellProcess, + active_poll: _ActiveProcessPoll, + *, + has_fresh_output: bool, + log_message: str, ) -> None: if process.active_poll is not active_poll: return now = time.monotonic() active_poll.last_progress_emitted_at = now + seconds_since_last_output = max(now - process.callbacks.last_output_time, 0.0) self._emit_progress_event( action=ProgressAction.CALLING_TOOL, tool_use_id=active_poll.tool_use_id, @@ -783,16 +801,44 @@ def _emit_managed_process_output_progress_now( process_elapsed_seconds=max(now - process.started_at, 0.0), process_command=summarize_command(process.command), process_id=process.process_id, + # Keep the original poll budget stable so the UI countdown track can + # drain against task elapsed time instead of a shrinking remainder. process_wait_seconds=max( - math.ceil(active_poll.deadline_at - now), + math.ceil(active_poll.deadline_at - active_poll.started_at), 0, ), - process_has_observed_output=True, - process_seconds_since_last_output=0.0, + process_has_observed_output=( + True + if has_fresh_output + else process.output_state.had_stream_output + ), + process_seconds_since_last_output=( + 0.0 if has_fresh_output else seconds_since_last_output + ), process_total_output_bytes=process.output_state.lifetime_output_bytes, - log_message="Process output progress", + log_message=log_message, ) + async def _poll_progress_heartbeat( + self, + process: _ManagedShellProcess, + active_poll: _ActiveProcessPoll, + ) -> None: + """Keep the monitoring row alive during quiet waits (e.g. sleep).""" + try: + while process.active_poll is active_poll and not process.task.done(): + await asyncio.sleep(_PROCESS_PROGRESS_EMIT_INTERVAL_SECONDS) + if process.active_poll is not active_poll: + return + self._emit_managed_process_poll_progress_now( + process, + active_poll, + has_fresh_output=False, + log_message="Process poll heartbeat", + ) + except asyncio.CancelledError: + return + def _invalid_execute_result(self, message: str) -> CallToolResult: return _text_result(message, is_error=True) @@ -1695,15 +1741,24 @@ async def poll_process( waited = False output_wake = False + poll_started_at_monotonic = time.monotonic() active_poll = ( _ActiveProcessPoll( tool_use_id=progress_tool_use_id, - deadline_at=time.monotonic() + parsed.wait_sec, + deadline_at=poll_started_at_monotonic + parsed.wait_sec, + started_at=poll_started_at_monotonic, ) if should_wait and progress_tool_use_id is not None else None ) process.active_poll = active_poll + if active_poll is not None: + # Quiet processes (sleep) never stream output; heartbeat keeps + # the live countdown bar visible for the whole wait. + active_poll.heartbeat_task = asyncio.create_task( + self._poll_progress_heartbeat(process, active_poll), + name=f"fast-agent-{process.process_id}-poll-heartbeat", + ) try: if should_wait: waited = True @@ -1715,11 +1770,13 @@ async def poll_process( finally: if process.active_poll is active_poll: process.active_poll = None - if ( - active_poll is not None - and active_poll.pending_progress_task is not None - ): - active_poll.pending_progress_task.cancel() + if active_poll is not None: + if active_poll.pending_progress_task is not None: + active_poll.pending_progress_task.cancel() + if active_poll.heartbeat_task is not None: + active_poll.heartbeat_task.cancel() + with suppress(asyncio.CancelledError): + await active_poll.heartbeat_task async with process.lock: if process.task.done(): @@ -1976,6 +2033,9 @@ async def _call_process_lifecycle_tool( result = await operation metadata = process_result_metadata(result) status = metadata.get("process_status") if metadata is not None else None + yield_reason = ( + metadata.get("process_yield_reason") if metadata is not None else None + ) details = f"{process_id}: {status}" if process_id and status else status self._emit_progress_event( action=ProgressAction.TOOL_PROGRESS, @@ -1984,6 +2044,7 @@ async def _call_process_lifecycle_tool( details=details or ("failed" if result.isError else "completed"), tool_state="failed" if result.isError else "completed", tool_terminal=True, + process_yield_reason=yield_reason, ) return result @@ -2216,6 +2277,7 @@ def _emit_progress_event( process_command: str | None = None, process_id: str | None = None, process_wait_seconds: int | None = None, + process_yield_reason: str | None = None, process_has_observed_output: bool | None = None, process_seconds_since_last_output: float | None = None, process_total_output_bytes: int | None = None, @@ -2246,6 +2308,7 @@ def _emit_progress_event( "process_command": process_command, "process_id": process_id, "process_wait_seconds": process_wait_seconds, + "process_yield_reason": process_yield_reason, "process_has_observed_output": process_has_observed_output, "process_seconds_since_last_output": process_seconds_since_last_output, "process_total_output_bytes": process_total_output_bytes, diff --git a/src/fast_agent/ui/console_display.py b/src/fast_agent/ui/console_display.py index ac89896ed..b658745bd 100644 --- a/src/fast_agent/ui/console_display.py +++ b/src/fast_agent/ui/console_display.py @@ -413,6 +413,7 @@ def show_managed_process_poll( tool_call_id: str | None, ) -> None: """Display a one-line poll heartbeat when live progress is disabled.""" + del wait_sec # retained for call-site compatibility; poll countdown is live-only line = Text("▎", style="magenta") line.append("◀ ", style="dim magenta") if name: @@ -421,7 +422,6 @@ def show_managed_process_poll( line.append("monitoring", style="bold magenta") line.append(" · ", style="dim") line.append(process_id, style="bold") - line.append(" · running", style="dim") if elapsed_seconds is not None: line.append( f" · {format_process_elapsed(elapsed_seconds)}", @@ -431,10 +431,11 @@ def show_managed_process_poll( has_observed_output=has_observed_output, seconds_since_last_output=seconds_since_last_output, ) - if output_activity: - line.append(f" · {output_activity}", style="dim") - if wait_sec is not None and wait_sec > 0: - line.append(f" · poll ≤{wait_sec}s", style="dim") + if output_activity is not None: + line.append( + f" · {output_activity.text}", + style=output_activity.style or "dim", + ) if output_size := format_process_output_size(total_output_bytes): line.append(f" · {output_size}", style="dim") if command: diff --git a/src/fast_agent/ui/model_picker.py b/src/fast_agent/ui/model_picker.py index b596d770e..edda9f68a 100644 --- a/src/fast_agent/ui/model_picker.py +++ b/src/fast_agent/ui/model_picker.py @@ -363,7 +363,8 @@ def _provider_availability(self, option: ProviderOption) -> ProviderAvailability if option.overlay_group and not option.curated_entries: return ProviderAvailability("none yet", "inactive", False) if option.active: - return ProviderAvailability("available", "active", True) + label = option.credential_label or "available" + return ProviderAvailability(label, "active", True) if option.disabled_reason is not None: return ProviderAvailability("disabled", "attention", False) if self._provider_activation_action(option) is not None: diff --git a/src/fast_agent/ui/model_picker_common.py b/src/fast_agent/ui/model_picker_common.py index 4d4df4de1..69c302202 100644 --- a/src/fast_agent/ui/model_picker_common.py +++ b/src/fast_agent/ui/model_picker_common.py @@ -77,6 +77,7 @@ class ProviderOption: display_name: str | None = None overlay_group: bool = False disabled_reason: str | None = None + credential_label: str | None = None @property def option_key(self) -> str: @@ -118,21 +119,7 @@ class ModelPickerSnapshot: def _provider_is_active(provider: Provider, config_payload: dict[str, Any]) -> bool: - if provider == Provider.ANTHROPIC_VERTEX: - ready, _ = anthropic_vertex_ready(config_payload) - return ready - - config_key = ProviderKeyManager.get_config_file_key(provider.config_name, config_payload) - if config_key: - return True - - if ProviderKeyManager.get_env_var(provider.config_name): - return True - - if active_check := _PROVIDER_ACTIVE_CHECKS.get(provider): - return active_check(config_payload) - - return provider in {Provider.FAST_AGENT, Provider.GENERIC} + return provider_credential_summary(provider, config_payload).active def _google_vertex_is_active(config_payload: dict[str, Any]) -> bool: @@ -164,6 +151,73 @@ def _huggingface_hub_is_active(_config_payload: dict[str, Any]) -> bool: } +@dataclass(frozen=True, slots=True) +class ProviderCredentialSummary: + active: bool + label: str | None = None + + +def provider_credential_summary( + provider: Provider, + config_payload: dict[str, Any], +) -> ProviderCredentialSummary: + """Return whether a provider is usable and a short credential source label.""" + if provider == Provider.ANTHROPIC_VERTEX: + ready, _ = anthropic_vertex_ready(config_payload) + return ProviderCredentialSummary(active=ready, label="ADC" if ready else None) + + if ProviderKeyManager.get_config_file_key(provider.config_name, config_payload): + return ProviderCredentialSummary(active=True, label="api key") + + if ProviderKeyManager.get_env_var(provider.config_name): + return ProviderCredentialSummary(active=True, label="env") + + if provider == Provider.CODEX_RESPONSES: + from fast_agent.cli.codex_oauth_display import codex_oauth_source_label + from fast_agent.llm.provider.openai.codex_oauth import get_codex_token_status + + status = get_codex_token_status() + if bool(status.get("present")) and not bool(status.get("expired")): + return ProviderCredentialSummary( + active=True, + label=codex_oauth_source_label(status.get("source")), + ) + return ProviderCredentialSummary(active=False) + + if provider == Provider.XAI: + from fast_agent.llm.provider.openai.xai_oauth import get_xai_token_status + + status = get_xai_token_status() + if bool(status.get("present")) and not bool(status.get("expired")): + source = status.get("source") + if source == "keyring": + label = "OAuth keyring" + elif source == "file": + label = "OAuth file" + else: + label = "OAuth" + return ProviderCredentialSummary(active=True, label=label) + return ProviderCredentialSummary(active=False) + + if provider == Provider.HUGGINGFACE and _huggingface_hub_is_active(config_payload): + return ProviderCredentialSummary(active=True, label="hf login") + + if provider == Provider.GOOGLE and _google_vertex_is_active(config_payload): + return ProviderCredentialSummary(active=True, label="Vertex") + + if provider == Provider.AZURE and _azure_default_credential_is_active(config_payload): + return ProviderCredentialSummary(active=True, label="Azure default") + + if provider in {Provider.FAST_AGENT, Provider.GENERIC}: + return ProviderCredentialSummary(active=True, label="local") + + if active_check := _PROVIDER_ACTIVE_CHECKS.get(provider): + if active_check(config_payload): + return ProviderCredentialSummary(active=True) + + return ProviderCredentialSummary(active=False) + + def _catalog_options_from_entries( entries: tuple[CatalogModelEntry, ...], *, @@ -314,10 +368,18 @@ def build_snapshot( settings = get_settings(str(config_path) if config_path else None) config_payload = settings.model_dump() - active_providers = set(ModelSelectionCatalog.configured_providers(config_payload)) - for provider in PICKER_PROVIDER_ORDER: - if _provider_is_active(provider, config_payload): - active_providers.add(provider) + credential_by_provider = { + provider: provider_credential_summary(provider, config_payload) + for provider in PICKER_PROVIDER_ORDER + } + active_providers = { + provider + for provider, summary in credential_by_provider.items() + if summary.active + } + # Keep catalog-configured providers (env/config) even if they are not in the + # picker order helper path above. + active_providers.update(ModelSelectionCatalog.configured_providers(config_payload)) providers: list[ProviderOption] = [] overlay_registry = _load_overlay_registry_for_snapshot( @@ -372,6 +434,7 @@ def build_snapshot( display_name="llama.cpp", ) ) + summary = credential_by_provider[provider] providers.append( ProviderOption( provider=provider, @@ -383,6 +446,7 @@ def build_snapshot( if provider == Provider.ANTHROPIC_VERTEX and provider not in active_providers else None ), + credential_label=summary.label if provider in active_providers else None, ) ) diff --git a/src/fast_agent/ui/process_poll_display.py b/src/fast_agent/ui/process_poll_display.py index 18be32fe7..b48ea8139 100644 --- a/src/fast_agent/ui/process_poll_display.py +++ b/src/fast_agent/ui/process_poll_display.py @@ -1,26 +1,151 @@ """Compact managed-process polling display helpers.""" +from __future__ import annotations + +import math +from dataclasses import dataclass + from pydantic import ByteSize -from fast_agent.utils.time import format_compact_duration +# Braille dots: +# 1 4 +# 2 5 +# 3 6 +# 7 8 +# Within a cell, remaining fill drains top→bottom on the left column, then +# top→bottom on the right: 1, 2, 3, 7, 4, 5, 6, 8. Across cells, drain is +# right→left (left cells stay full longest; the right edge empties first). +# +# Each sweep drains the 24 physical dots across the three cells. A dot remains +# visible for at most 10 seconds; longer waits use additional full sweeps rather +# than slowing the drain. Blank is reserved for the poll deadline. +_CELL_DOT_BITS: tuple[int, ...] = ( + 0x01, # 1 + 0x02, # 2 + 0x04, # 3 + 0x40, # 7 + 0x08, # 4 + 0x10, # 5 + 0x20, # 6 + 0x80, # 8 +) +_POLL_COUNTDOWN_CELLS = 3 +_DOTS_PER_CELL = len(_CELL_DOT_BITS) # 8 +_POLL_COUNTDOWN_UNITS = _POLL_COUNTDOWN_CELLS * _DOTS_PER_CELL +_MAX_SECONDS_PER_STEP = 10.0 + + +def _cell_glyph(level: int) -> str: + """Glyph for cell fill level 0 (empty) .. 8 (full).""" + if level <= 0: + return " " + if level >= _DOTS_PER_CELL: + return chr(0x28FF) # ⣿ + bits = 0 + for dot_bit in _CELL_DOT_BITS[:level]: + bits |= dot_bit + return chr(0x2800 + bits) + + +def _track_from_remaining_units(remaining_units: int) -> str: + """Build a 3-cell track; drain order is top→bottom, right→left.""" + remaining_units = max(0, min(_POLL_COUNTDOWN_UNITS, remaining_units)) + # Prefer filling left cells so the right edge drains first. + cells: list[str] = [] + left_to_assign = remaining_units + for _ in range(_POLL_COUNTDOWN_CELLS): + level = min(_DOTS_PER_CELL, left_to_assign) + cells.append(_cell_glyph(level)) + left_to_assign -= level + return "".join(cells) + + +def _countdown_cycle_count(wait_seconds: int) -> int: + """How many full 24-dot sweeps keep each dot interval at most 10s.""" + max_one_cycle = _POLL_COUNTDOWN_UNITS * _MAX_SECONDS_PER_STEP + return max(1, math.ceil(wait_seconds / max_one_cycle)) + + +def _remaining_slots_for_wait(*, wait_seconds: int, elapsed_seconds: float) -> int: + """Return remaining dots in the active sweep (0 blank .. 24 full).""" + elapsed = max(elapsed_seconds, 0.0) + if elapsed >= wait_seconds: + return 0 + + cycles = _countdown_cycle_count(wait_seconds) + total_steps = cycles * _POLL_COUNTDOWN_UNITS + step_seconds = wait_seconds / total_steps + completed_steps = min(math.floor(elapsed / step_seconds), total_steps - 1) + remaining_global = total_steps - completed_steps + return (remaining_global - 1) % _POLL_COUNTDOWN_UNITS + 1 + + +def _slots_to_fill_units(remaining_slots: int) -> int: + """Clamp timing slots to the 24 physical Braille dots.""" + return max(0, min(_POLL_COUNTDOWN_UNITS, remaining_slots)) + + +def _remaining_units_for_wait(*, wait_seconds: int, elapsed_seconds: float) -> int: + """Map wait progress onto the 24-dot glyph ladder (multi-sweep aware).""" + return _slots_to_fill_units( + _remaining_slots_for_wait( + wait_seconds=wait_seconds, + elapsed_seconds=elapsed_seconds, + ) + ) + + +@dataclass(frozen=True, slots=True) +class ProcessOutputActivity: + """Compact output-activity chip for process monitoring displays.""" + + text: str + style: str | None = None def format_process_output_activity( *, has_observed_output: bool | None, seconds_since_last_output: float | None, -) -> str | None: +) -> ProcessOutputActivity | None: + """Return a short activity chip that highlights recent output, then goes quiet. + + Recent output is emphasized for a short window, then fades, then collapses to + an untimed ``quiet`` marker. Missing/never-seen output stays silent. + """ if has_observed_output is None or seconds_since_last_output is None: return None if not has_observed_output: return None - duration = format_compact_duration(max(seconds_since_last_output, 0.0)) or "<1s" - if seconds_since_last_output <= 5: - return "output now" - if seconds_since_last_output < 30: - return f"last output {duration} ago" - return f"quiet {duration}" + age = max(seconds_since_last_output, 0.0) + if age <= 5: + return ProcessOutputActivity("output", "bold bright_green") + if age < 30: + return ProcessOutputActivity("output", "green") + return ProcessOutputActivity("quiet") + + +def format_process_poll_countdown_track( + *, + wait_seconds: int | None, + elapsed_seconds: float, +) -> str | None: + """Return a braille track that empties as a poll wait approaches its deadline. + + Drain order is top→bottom within a cell (left column, then right) and + right→left across cells. Each sweep has 24 one-dot steps. Waits longer + than 240s run multiple sweeps so a dot never remains unchanged for more + than 10s. ``None`` falls back to the pulse spinner. + """ + if type(wait_seconds) is not int or wait_seconds <= 0: + return None + + remaining_units = _remaining_units_for_wait( + wait_seconds=wait_seconds, + elapsed_seconds=elapsed_seconds, + ) + return _track_from_remaining_units(remaining_units) def format_process_output_size(total_bytes: int | None) -> str | None: diff --git a/src/fast_agent/ui/rich_progress.py b/src/fast_agent/ui/rich_progress.py index f6eda4530..0de77a58b 100644 --- a/src/fast_agent/ui/rich_progress.py +++ b/src/fast_agent/ui/rich_progress.py @@ -1,12 +1,11 @@ """Rich-based progress display for MCP Agent.""" import json -import math import os import time from contextlib import contextmanager from pathlib import Path -from threading import RLock +from threading import RLock, Timer from typing import Any from rich.console import Console @@ -24,6 +23,7 @@ from fast_agent.ui.process_poll_display import ( format_process_output_activity, format_process_output_size, + format_process_poll_countdown_track, ) from fast_agent.ui.tool_call_ids import format_tool_call_id from fast_agent.utils.time import format_process_elapsed @@ -118,11 +118,26 @@ def render(self, task: "Task") -> Text: if task.finished: spinner_text = self.finished_text else: - rendered = self.spinner.render(task.get_time()) - spinner_text = rendered if isinstance(rendered, Text) else Text(str(rendered)) + spinner_text = self._render_activity_glyph(task) + # Glyph trails the padded label (historical layout). Narrow terminals may + # clip it; countdown still reads from colour/shape when visible. return Text.assemble(description_text, spinner_text) + def _render_activity_glyph(self, task: "Task") -> Text: + """Pulse spinner, or a depleting braille track while a process poll waits.""" + if bool(task.fields.get("is_process_poll")): + wait_seconds = task.fields.get("process_wait_seconds") + countdown = format_process_poll_countdown_track( + wait_seconds=wait_seconds if type(wait_seconds) is int else None, + elapsed_seconds=task.elapsed or 0.0, + ) + if countdown is not None: + return Text(countdown, style="magenta") + + rendered = self.spinner.render(task.get_time()) + return rendered if isinstance(rendered, Text) else Text(str(rendered)) + class DynamicDetailsColumn(ProgressColumn): """Render optional process elapsed time without emitting timer events.""" @@ -139,31 +154,28 @@ def __init__( def render(self, task: "Task") -> Text: details = str(task.fields.get("details") or "").strip() is_process_poll = bool(task.fields.get("is_process_poll")) - parts = [details] - task_elapsed = task.elapsed or 0.0 - if is_process_poll: - parts.append("running") + parts: list[str | Text] = [] + if details: + parts.append(details) + local_tick = self._local_tick_seconds(task) elapsed_base = task.fields.get("process_elapsed_seconds") if isinstance(elapsed_base, (int, float)) and not isinstance(elapsed_base, bool): - elapsed = float(elapsed_base) + task_elapsed - elapsed_text = format_process_elapsed(elapsed) - parts.append(elapsed_text) + parts.append(format_process_elapsed(float(elapsed_base) + local_tick)) if is_process_poll: output_age = task.fields.get("process_seconds_since_last_output") if isinstance(output_age, (int, float)) and not isinstance(output_age, bool): - output_age = float(output_age) + task_elapsed + output_age = float(output_age) + local_tick else: output_age = None output_activity = format_process_output_activity( has_observed_output=task.fields.get("process_has_observed_output"), seconds_since_last_output=output_age, ) - if output_activity: - parts.append(output_activity) - wait_seconds = task.fields.get("process_wait_seconds") - if type(wait_seconds) is int and wait_seconds > 0: - remaining = math.ceil(max(wait_seconds - task_elapsed, 0.0)) - parts.append(f"poll {remaining}s" if remaining else "poll finishing…") + if output_activity is not None: + if output_activity.style: + parts.append(Text(output_activity.text, style=output_activity.style)) + else: + parts.append(output_activity.text) output_size = format_process_output_size( task.fields.get("process_total_output_bytes") ) @@ -172,7 +184,31 @@ def render(self, task: "Task") -> Text: command = task.fields.get("process_command") if isinstance(command, str) and command: parts.append(command) - return Text(" · ".join(part for part in parts if part), style=self.style) + return self._join_detail_parts(parts) + + @staticmethod + def _local_tick_seconds(task: "Task") -> float: + """Seconds since the latest process field snapshot was applied.""" + task_elapsed = task.elapsed or 0.0 + snapshot = task.fields.get("process_snapshot_task_elapsed") + if isinstance(snapshot, (int, float)) and not isinstance(snapshot, bool): + return max(task_elapsed - float(snapshot), 0.0) + return task_elapsed + + def _join_detail_parts(self, parts: list[str | Text]) -> Text: + line = Text(style=self.style) + first = True + for part in parts: + if not part: + continue + if not first: + line.append(" · ", style=self.style) + first = False + if isinstance(part, Text): + line.append_text(part) + else: + line.append(part, style=self.style) + return line class RichProgressDisplay: @@ -555,15 +591,17 @@ def _description_for_event(self, event: ProgressEvent) -> str: if event.action == ProgressAction.TOOL_PROGRESS else self._action_label(event) ) + if self._is_process_poll_event(event): + return f"[{action_style}]▎[dim]{icon}[/dim] {label} " formatted_text = f"▎[dim]{icon}[/dim] {label}".ljust(17 + 11) return f"[{action_style}]{formatted_text}" @staticmethod def _is_process_poll_event(event: ProgressEvent) -> bool: - return event.action == ProgressAction.CALLING_TOOL and matches_tool_name( - event.tool_name, - POLL_PROCESS_TOOL_NAME, - ) + return matches_tool_name(event.tool_name, POLL_PROCESS_TOOL_NAME) and event.action in { + ProgressAction.CALLING_TOOL, + ProgressAction.TOOL_PROGRESS, + } @classmethod def _action_label(cls, event: ProgressEvent) -> str: @@ -681,9 +719,49 @@ def _apply_post_update_lifecycle( self._mark_fatal_error_task(event, task_name=task_name, task_id=task_id) elif should_drop_tool_task: self._drop_task(task_name, task_id) - elif event.action != ProgressAction.TOOL_PROGRESS: + elif event.action != ProgressAction.TOOL_PROGRESS and not self._is_process_poll_event( + event + ): + # Process polls keep a stable start_time so the braille countdown + # track can drain across refresh updates. self._progress.reset(task_id) + def _finish_process_poll_task( + self, + task_name: str, + task_id: TaskID, + task: Task, + ) -> None: + """Show an empty countdown track briefly, then remove the poll row.""" + wait_seconds = task.fields.get("process_wait_seconds") + if type(wait_seconds) is not int or wait_seconds <= 0: + wait_seconds = 1 + # Freeze elapsed past the wait budget so the glyph stays blank. + now = time.time() + if task.start_time is not None: + task.start_time = now - float(wait_seconds) - 0.05 + task.stop_time = now + self._progress.update( + task_id, + is_process_poll=True, + process_wait_seconds=wait_seconds, + process_snapshot_task_elapsed=float(wait_seconds), + description=task.description, + ) + # Force one Live refresh so the empty track paints before teardown. + if self._live_started() and not self._paused: + self._progress.refresh() + + def _drop_later() -> None: + with self._lock: + if self._taskmap.get(task_name) != task_id: + return + self._drop_task(task_name, task_id) + + timer = Timer(0.7, _drop_later) + timer.daemon = True + timer.start() + def _mark_finished_task( self, event: ProgressEvent, @@ -767,6 +845,19 @@ def _apply_update_locked(self, event: ProgressEvent) -> None: is_correlated_tool_event=is_correlated_tool_event, ) + existing = next((item for item in self._progress.tasks if item.id == task_id), None) + finishing_process_poll = ( + should_drop_tool_task + and existing is not None + and bool(existing.fields.get("is_process_poll")) + and event.process_yield_reason == "deadline" + ) + if finishing_process_poll: + # Hold the monitoring row with an empty countdown; skip overwriting + # the description with a terminal "Processing" label. + self._finish_process_poll_task(task_name, task_id, existing) + return + self._progress.update( task_id, **self._update_kwargs_for_event( @@ -775,6 +866,18 @@ def _apply_update_locked(self, event: ProgressEvent) -> None: is_correlated_tool_event=is_correlated_tool_event, ), ) + if self._is_process_poll_event(event): + # Anchor field baselines to the current task clock so local ticks + # between refresh events do not double-count process age. + task = next( + (item for item in self._progress.tasks if item.id == task_id), + None, + ) + if task is not None: + self._progress.update( + task_id, + process_snapshot_task_elapsed=task.elapsed or 0.0, + ) self._apply_post_update_lifecycle( event, task_name=task_name, diff --git a/tests/unit/fast_agent/llm/test_model_selection_catalog.py b/tests/unit/fast_agent/llm/test_model_selection_catalog.py index 7f0c676b0..1e3a2cac7 100644 --- a/tests/unit/fast_agent/llm/test_model_selection_catalog.py +++ b/tests/unit/fast_agent/llm/test_model_selection_catalog.py @@ -234,6 +234,20 @@ def test_configured_providers_reads_environment_keys() -> None: assert Provider.RESPONSES in providers +def test_configured_providers_reads_codex_auth_json_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CODEX_API_KEY", raising=False) + monkeypatch.setattr( + "fast_agent.llm.provider.openai.codex_oauth.get_codex_access_token", + lambda: "codex-token-from-auth-json", + ) + + providers = ModelSelectionCatalog.configured_providers({}) + + assert Provider.CODEX_RESPONSES in providers + + def test_configured_providers_does_not_treat_overlay_only_provider_as_ready( monkeypatch, tmp_path: Path, diff --git a/tests/unit/fast_agent/ui/test_apply_patch_tool_display.py b/tests/unit/fast_agent/ui/test_apply_patch_tool_display.py index 4f203c93e..bb07d3ba7 100644 --- a/tests/unit/fast_agent/ui/test_apply_patch_tool_display.py +++ b/tests/unit/fast_agent/ui/test_apply_patch_tool_display.py @@ -160,10 +160,7 @@ def test_process_lifecycle_tool_calls_use_compact_display() -> None: ) rendered = capture.get() - assert ( - "dev monitoring · process-3 · running · 1m05s · poll ≤5s · uv run worker.py" - in rendered - ) + assert "dev monitoring · process-3 · 1m05s · uv run worker.py" in rendered assert "pid 4321" not in rendered assert "terminate process-4" in rendered assert "'process_id'" not in rendered diff --git a/tests/unit/fast_agent/ui/test_model_picker.py b/tests/unit/fast_agent/ui/test_model_picker.py index 4c93a3a27..9ed0e2804 100644 --- a/tests/unit/fast_agent/ui/test_model_picker.py +++ b/tests/unit/fast_agent/ui/test_model_picker.py @@ -259,6 +259,28 @@ def test_codex_inactive_provider_is_shown_as_auth_on_select() -> None: assert "press Enter to authenticate" in status_line +def test_codex_active_provider_shows_credential_source_label() -> None: + picker = _SplitListPicker(config_path=None, initial_provider="codexresponses") + picker.snapshot = ModelPickerSnapshot( + providers=( + ProviderOption( + provider=Provider.CODEX_RESPONSES, + active=True, + curated_entries=( + CatalogModelEntry(alias="codexplan", model="codexresponses.o4-mini"), + ), + credential_label="Codex auth.json", + ), + ), + config_payload={}, + ) + picker.state.provider_index = 0 + + provider = picker.current_provider + assert picker._provider_availability_label(provider) == "Codex auth.json" + assert picker._provider_availability(provider).available is True + + def test_codex_inactive_picker_current_models_uses_activation_option() -> None: picker = _SplitListPicker(config_path=None, initial_provider="codexresponses") picker.snapshot = _snapshot_with_single_provider( diff --git a/tests/unit/fast_agent/ui/test_model_picker_common.py b/tests/unit/fast_agent/ui/test_model_picker_common.py index 7a440c058..077bcfff5 100644 --- a/tests/unit/fast_agent/ui/test_model_picker_common.py +++ b/tests/unit/fast_agent/ui/test_model_picker_common.py @@ -23,7 +23,6 @@ LLAMACPP_PROVIDER_KEY, ModelOption, ModelPickerSnapshot, - ProviderActivation, ProviderOption, _provider_is_active, build_snapshot, @@ -251,6 +250,72 @@ def test_provider_is_active_accepts_provider_specific_fallbacks() -> None: ) +def test_provider_is_active_for_codex_auth_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "fast_agent.llm.provider.openai.codex_oauth.get_codex_token_status", + lambda: { + "present": True, + "expired": False, + "source": "auth.json", + "expires_at": 1_900_000_000, + }, + ) + + assert _provider_is_active(Provider.CODEX_RESPONSES, {}) + + from fast_agent.ui.model_picker_common import provider_credential_summary + + summary = provider_credential_summary(Provider.CODEX_RESPONSES, {}) + assert summary.active is True + assert summary.label == "Codex auth.json" + + +def test_provider_is_active_for_xai_oauth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "fast_agent.llm.provider.openai.xai_oauth.get_xai_token_status", + lambda: { + "present": True, + "expired": False, + "source": "keyring", + "expires_at": 1_900_000_000, + }, + ) + + assert _provider_is_active(Provider.XAI, {}) + + from fast_agent.ui.model_picker_common import provider_credential_summary + + summary = provider_credential_summary(Provider.XAI, {}) + assert summary.active is True + assert summary.label == "OAuth keyring" + + +def test_build_snapshot_marks_codex_active_from_auth_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "fast_agent.llm.provider.openai.codex_oauth.get_codex_token_status", + lambda: { + "present": True, + "expired": False, + "source": "auth.json", + "expires_at": 1_900_000_000, + }, + ) + monkeypatch.setattr( + "fast_agent.llm.provider.openai.codex_oauth.get_codex_access_token", + lambda: "codex-token", + ) + + option = _provider_option(build_snapshot(config_payload={}), Provider.CODEX_RESPONSES) + assert option.active is True + assert option.credential_label == "Codex auth.json" + + def test_46_models_do_not_report_optional_long_context() -> None: capabilities = model_capabilities("claude-opus-4-6?context=1m") @@ -429,7 +494,7 @@ def test_build_snapshot_uses_xai_brand_casing() -> None: assert Provider.XAI.display_name == "xAI" -def test_build_snapshot_defers_oauth_credential_lookup( +def test_build_snapshot_surfaces_oauth_credential_source( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -450,8 +515,9 @@ def test_build_snapshot_defers_oauth_credential_lookup( if provider.option_key == Provider.XAI.config_name ) - assert option.active is False - assert provider_activation_action(snapshot, Provider.XAI) == ProviderActivation(Provider.XAI) + assert option.active is True + assert option.credential_label == "OAuth file" + assert provider_activation_action(snapshot, Provider.XAI) is None def test_has_explicit_provider_prefix_handles_supported_delimiters() -> None: diff --git a/tests/unit/fast_agent/ui/test_process_poll_display.py b/tests/unit/fast_agent/ui/test_process_poll_display.py index 7ee5d0c85..9238b49af 100644 --- a/tests/unit/fast_agent/ui/test_process_poll_display.py +++ b/tests/unit/fast_agent/ui/test_process_poll_display.py @@ -1,10 +1,16 @@ from fast_agent.ui.process_poll_display import ( + _cell_glyph, + _countdown_cycle_count, + _remaining_slots_for_wait, + _remaining_units_for_wait, + _track_from_remaining_units, format_process_output_activity, format_process_output_size, + format_process_poll_countdown_track, ) -def test_process_output_activity_distinguishes_recent_quiet_and_missing_output() -> None: +def test_process_output_activity_highlights_recent_then_goes_quiet() -> None: assert ( format_process_output_activity( has_observed_output=False, @@ -12,27 +18,30 @@ def test_process_output_activity_distinguishes_recent_quiet_and_missing_output() ) is None ) - assert ( - format_process_output_activity( - has_observed_output=True, - seconds_since_last_output=4, - ) - == "output now" + + hot = format_process_output_activity( + has_observed_output=True, + seconds_since_last_output=4, ) - assert ( - format_process_output_activity( - has_observed_output=True, - seconds_since_last_output=12, - ) - == "last output 12s ago" + assert hot is not None + assert hot.text == "output" + assert hot.style == "bold bright_green" + + warm = format_process_output_activity( + has_observed_output=True, + seconds_since_last_output=12, ) - assert ( - format_process_output_activity( - has_observed_output=True, - seconds_since_last_output=90, - ) - == "quiet 1m30s" + assert warm is not None + assert warm.text == "output" + assert warm.style == "green" + + quiet = format_process_output_activity( + has_observed_output=True, + seconds_since_last_output=90, ) + assert quiet is not None + assert quiet.text == "quiet" + assert quiet.style is None def test_process_output_size_uses_compact_decimal_units() -> None: @@ -42,3 +51,67 @@ def test_process_output_size_uses_compact_decimal_units() -> None: assert format_process_output_size(1_250) == "1.2KB" assert format_process_output_size(12_500) == "12.5KB" assert format_process_output_size(1_250_000) == "1.2MB" + + +def test_cell_glyph_fills_top_to_bottom_left_column_first() -> None: + assert _cell_glyph(0) == " " + assert _cell_glyph(1) == "⠁" # dot 1 + assert _cell_glyph(2) == "⠃" # 1+2 + assert _cell_glyph(4) == "⡇" # left column full + assert _cell_glyph(5) == "⡏" # left full + top-right + assert _cell_glyph(8) == "⣿" + + +def test_process_poll_countdown_track_drains_right_to_left() -> None: + assert format_process_poll_countdown_track(wait_seconds=None, elapsed_seconds=0) is None + assert format_process_poll_countdown_track(wait_seconds=0, elapsed_seconds=0) is None + + full = format_process_poll_countdown_track(wait_seconds=27, elapsed_seconds=0) + still_full = format_process_poll_countdown_track(wait_seconds=27, elapsed_seconds=1) + first_drop = format_process_poll_countdown_track(wait_seconds=27, elapsed_seconds=2) + last_dot = format_process_poll_countdown_track(wait_seconds=27, elapsed_seconds=26) + empty = format_process_poll_countdown_track(wait_seconds=27, elapsed_seconds=27) + overdue = format_process_poll_countdown_track(wait_seconds=30, elapsed_seconds=45) + + assert full == "⣿⣿⣿" + assert still_full == "⣿⣿⣿" + assert empty == " " + assert overdue == " " + assert first_drop is not None and last_dot is not None + # Right cell drops first. + assert first_drop == "⣿⣿" + _cell_glyph(7) + # Final filled step is a single left-column top dot; blank only at deadline. + assert last_dot == _cell_glyph(1) + " " + assert len(full) == 3 + + +def test_countdown_steps_cover_full_range() -> None: + frames = [_track_from_remaining_units(n) for n in range(25)] + assert frames[0] == " " + assert frames[24] == "⣿⣿⣿" + assert frames[23] == "⣿⣿" + _cell_glyph(7) + assert frames[16] == "⣿⣿ " + assert frames[8] == "⣿ " + + +def test_step_timing_uses_24_dots_with_10s_cap_and_rotation() -> None: + # Short waits use one 24-dot sweep. + assert _countdown_cycle_count(50) == 1 + assert _countdown_cycle_count(240) == 1 + # Over 240s: extra sweeps so no dot interval exceeds 10s. + assert _countdown_cycle_count(241) == 2 + assert _countdown_cycle_count(600) == 3 # ceil(600/240) + + assert _remaining_slots_for_wait(wait_seconds=27, elapsed_seconds=0) == 24 + assert _remaining_slots_for_wait(wait_seconds=27, elapsed_seconds=26) == 1 + assert _remaining_slots_for_wait(wait_seconds=27, elapsed_seconds=27) == 0 + assert format_process_poll_countdown_track(wait_seconds=27, elapsed_seconds=26) == ( + _cell_glyph(1) + " " + ) + assert format_process_poll_countdown_track(wait_seconds=27, elapsed_seconds=27) == " " + + assert _remaining_units_for_wait(wait_seconds=600, elapsed_seconds=0) == 24 + mid = _remaining_units_for_wait(wait_seconds=600, elapsed_seconds=300) + assert 0 <= mid <= 24 + assert _remaining_units_for_wait(wait_seconds=600, elapsed_seconds=600) == 0 + assert format_process_poll_countdown_track(wait_seconds=600, elapsed_seconds=600) == " " diff --git a/tests/unit/fast_agent/ui/test_rich_progress.py b/tests/unit/fast_agent/ui/test_rich_progress.py index 11ed8e83a..1c073662c 100644 --- a/tests/unit/fast_agent/ui/test_rich_progress.py +++ b/tests/unit/fast_agent/ui/test_rich_progress.py @@ -14,6 +14,7 @@ from fast_agent.ui.rich_progress import ( DynamicDetailsColumn, RichProgressDisplay, + SpinnerDescriptionColumn, ) from fast_agent.utils.time import format_process_elapsed @@ -424,66 +425,180 @@ def test_poll_process_uses_dense_braille_spinner(self) -> None: assert spinner.name == "braille_dense" assert "⢸⡇ " in spinner.frames - def test_process_elapsed_time_ticks_during_rendering(self) -> None: + def test_process_poll_countdown_track_replaces_pulse_spinner(self) -> None: display = RichProgressDisplay( - console=Console(file=open("/dev/null", "w"), force_terminal=True, width=120), + console=Console(file=open("/dev/null", "w"), force_terminal=True), default_agent_name="test-agent", ) display.start() display.update( _make_event( action=ProgressAction.CALLING_TOOL, - correlation_id="call_abcdef0123456789", + correlation_id="call-poll-countdown", tool_name="poll_process", details="process-4", process_id="process-4", - process_elapsed_seconds=65, - process_command="uv run worker.py", + process_elapsed_seconds=10, process_wait_seconds=30, - process_has_observed_output=True, - process_seconds_since_last_output=4, - process_total_output_bytes=12_500, ) ) - task_id = display._taskmap["test-agent::call_abcdef0123456789"] + task_id = display._taskmap["test-agent::call-poll-countdown"] + task = next(task for task in display._progress.tasks if task.id == task_id) + assert task.start_time is not None + task.start_time -= 10 # 10s into a 30s wait → ~2/3 remaining track + + column = SpinnerDescriptionColumn(spinner_name="braille_dense") + rendered = column.render(task) + assert "Monitoring" in rendered.plain + # The countdown immediately follows the compact monitoring label. + prefix = "▎◀ Monitoring " + assert rendered.plain.startswith(prefix) + assert len(rendered.plain) == len(prefix) + 3 + # ~2/3 of a 30s wait remaining → at least one full cell still lit. + assert "⣿" in rendered.plain + display.stop() + + def test_process_poll_completion_snaps_countdown_empty_before_drop(self) -> None: + display = RichProgressDisplay( + console=Console(file=open("/dev/null", "w"), force_terminal=True), + default_agent_name="test-agent", + ) + display.start() + display.update( + _make_event( + action=ProgressAction.CALLING_TOOL, + correlation_id="call-poll-finish", + tool_name="poll_process", + details="process-4", + process_id="process-4", + process_elapsed_seconds=5, + process_wait_seconds=30, + ) + ) + task_id = display._taskmap["test-agent::call-poll-finish"] task = next(task for task in display._progress.tasks if task.id == task_id) - assert task.fields["target"] == "process-4" assert task.start_time is not None - task.start_time -= 5 + task.start_time -= 10 - rendered = DynamicDetailsColumn().render(task).plain + mid = SpinnerDescriptionColumn(spinner_name="braille_dense").render(task) + assert "Monitoring" in mid.plain + assert "⣿" in mid.plain - assert rendered == ( - "running · 1m10s · last output 9s ago · poll 25s · " - "12.5KB · uv run worker.py" + display.update( + _make_event( + action=ProgressAction.TOOL_PROGRESS, + correlation_id="call-poll-finish", + tool_name="poll_process", + tool_state="completed", + tool_terminal=True, + process_yield_reason="deadline", + ) + ) + # Row is held briefly with an empty track before drop. + assert "test-agent::call-poll-finish" in display._taskmap + finished = next(task for task in display._progress.tasks if task.id == task_id) + empty = SpinnerDescriptionColumn(spinner_name="braille_dense").render(finished) + assert "Monitoring" in empty.plain + # Three blank cells (spaces) for exhausted track. + assert " " in empty.plain + time.sleep(0.85) + assert "test-agent::call-poll-finish" not in display._taskmap + display.stop() + + def test_process_poll_early_completion_drops_without_fake_empty_frame(self) -> None: + display = RichProgressDisplay( + console=Console(file=open("/dev/null", "w"), force_terminal=True), + default_agent_name="test-agent", + ) + display.start() + display.update( + _make_event( + action=ProgressAction.CALLING_TOOL, + correlation_id="call-poll-early", + tool_name="poll_process", + process_id="process-4", + process_wait_seconds=30, + ) + ) + display.update( + _make_event( + action=ProgressAction.TOOL_PROGRESS, + correlation_id="call-poll-early", + tool_name="poll_process", + tool_state="completed", + tool_terminal=True, + process_yield_reason="completion", + ) ) + + assert "test-agent::call-poll-early" not in display._taskmap display.stop() - def test_process_poll_countdown_reports_finishing_at_deadline(self) -> None: + def test_process_poll_refresh_keeps_countdown_start_time(self) -> None: display = RichProgressDisplay( console=Console(file=open("/dev/null", "w"), force_terminal=True), default_agent_name="test-agent", ) display.start() + event = _make_event( + action=ProgressAction.CALLING_TOOL, + correlation_id="call-poll-stable", + tool_name="poll_process", + details="process-4", + process_id="process-4", + process_elapsed_seconds=0, + process_wait_seconds=30, + ) + display.update(event) + task_id = display._taskmap["test-agent::call-poll-stable"] + task = next(task for task in display._progress.tasks if task.id == task_id) + assert task.start_time is not None + original_start = task.start_time + + display.update( + event.model_copy( + update={ + "process_elapsed_seconds": 5, + "process_has_observed_output": True, + "process_seconds_since_last_output": 1, + } + ) + ) + refreshed = next(task for task in display._progress.tasks if task.id == task_id) + assert refreshed.start_time == original_start + display.stop() + + def test_process_elapsed_time_ticks_during_rendering(self) -> None: + display = RichProgressDisplay( + console=Console(file=open("/dev/null", "w"), force_terminal=True, width=120), + default_agent_name="test-agent", + ) + display.start() display.update( _make_event( action=ProgressAction.CALLING_TOOL, - correlation_id="call-poll", + correlation_id="call_abcdef0123456789", tool_name="poll_process", details="process-4", process_id="process-4", process_elapsed_seconds=65, - process_wait_seconds=5, + process_command="uv run worker.py", + process_wait_seconds=30, + process_has_observed_output=True, + process_seconds_since_last_output=4, + process_total_output_bytes=12_500, ) ) - task_id = display._taskmap["test-agent::call-poll"] + task_id = display._taskmap["test-agent::call_abcdef0123456789"] task = next(task for task in display._progress.tasks if task.id == task_id) + assert task.fields["target"] == "process-4" assert task.start_time is not None - task.start_time -= 5 + task.start_time -= 5 # local tick only; process baselines stay fixed - assert DynamicDetailsColumn().render(task).plain == ( - "running · 1m10s · poll finishing…" - ) + rendered = DynamicDetailsColumn().render(task) + # 65s base + 5s local tick; 4s-old output ages into the warm window. + assert rendered.plain == "1m10s · output · 12.5KB · uv run worker.py" + assert any(str(span.style) == "green" for span in rendered.spans) display.stop() def test_process_output_progress_refreshes_live_poll_baselines(self) -> None: @@ -521,9 +636,40 @@ def test_process_output_progress_refreshes_live_poll_baselines(self) -> None: task_id = display._taskmap["test-agent::call-poll"] task = next(task for task in display._progress.tasks if task.id == task_id) - assert DynamicDetailsColumn().render(task).plain == ( - "running · 1m10s · output now · poll 25s · 25.0KB · uv run worker.py" + rendered = DynamicDetailsColumn().render(task) + assert rendered.plain == "1m10s · output · 25.0KB · uv run worker.py" + assert any(str(span.style) == "bold bright_green" for span in rendered.spans) + display.stop() + + def test_process_output_activity_fades_then_goes_quiet(self) -> None: + display = RichProgressDisplay( + console=Console(file=open("/dev/null", "w"), force_terminal=True), + default_agent_name="test-agent", ) + display.start() + display.update( + _make_event( + action=ProgressAction.CALLING_TOOL, + correlation_id="call-poll-quiet", + tool_name="poll_process", + details="process-4", + process_id="process-4", + process_elapsed_seconds=90, + process_has_observed_output=True, + process_seconds_since_last_output=12, + process_total_output_bytes=12_500, + ) + ) + task_id = display._taskmap["test-agent::call-poll-quiet"] + task = next(task for task in display._progress.tasks if task.id == task_id) + + warm = DynamicDetailsColumn().render(task) + assert warm.plain == "1m30s · output · 12.5KB" + assert any(str(span.style) == "green" for span in warm.spans) + + task.fields["process_seconds_since_last_output"] = 90 + quiet = DynamicDetailsColumn().render(task) + assert quiet.plain == "1m30s · quiet · 12.5KB" display.stop() def test_poll_process_keeps_non_default_agent_name(self) -> None: diff --git a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py index 05fcce8d3..8a20aab54 100644 --- a/tests/unit/fast_agent/ui/test_shell_tool_result_display.py +++ b/tests/unit/fast_agent/ui/test_shell_tool_result_display.py @@ -157,8 +157,7 @@ def test_managed_process_poll_uses_shared_elapsed_format() -> None: rendered = " ".join(capture.get().split()) assert "dev" not in rendered assert ( - "process-2 · running · 2h · last output 9s ago · poll ≤50s · " - "12.5KB · uv run worker.py · id: call_…456789" + "process-2 · 2h · output · 12.5KB · uv run worker.py · id: call_…456789" ) in rendered diff --git a/uv.lock b/uv.lock index 1459d9d4f..1ae91df54 100644 --- a/uv.lock +++ b/uv.lock @@ -174,9 +174,9 @@ name = "aiologic" version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/a7/809482759f40079f4c4328c7318bf569ae25d457f5017aad30a1b9aafedc/aiologic-0.17.0.tar.gz", hash = "sha256:65aa058e858c94cd208badb188e7f00b54dcabb3ba85b34f794db98074d108b9", size = 251625, upload-time = "2026-06-14T12:24:35.367Z" } wheels = [ @@ -689,8 +689,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -863,7 +863,7 @@ requires-dist = [{ name = "fast-agent-mcp", editable = "." }] [[package]] name = "fast-agent-mcp" -version = "0.9.16" +version = "0.9.17" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -994,11 +994,11 @@ requires-dist = [ { name = "onnxruntime", marker = "extra == 'privacy'", specifier = ">=1.25" }, { name = "onnxruntime-gpu", marker = "extra == 'privacy-gpu'", specifier = ">=1.25" }, { name = "openai", extras = ["aiohttp"], specifier = "==2.45.0" }, - { name = "opentelemetry-exporter-otlp-proto-http", specifier = "==1.42.1" }, - { name = "opentelemetry-instrumentation-anthropic", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==0.61.0" }, - { name = "opentelemetry-instrumentation-google-genai", specifier = "==0.7b1" }, - { name = "opentelemetry-instrumentation-mcp", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==0.61.0" }, - { name = "opentelemetry-instrumentation-openai", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==0.61.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = "==1.43.0" }, + { name = "opentelemetry-instrumentation-anthropic", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==0.62.1" }, + { name = "opentelemetry-instrumentation-google-genai", specifier = "==1.0b1" }, + { name = "opentelemetry-instrumentation-mcp", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==0.62.1" }, + { name = "opentelemetry-instrumentation-openai", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==0.62.1" }, { name = "pillow", specifier = ">=12.1.1" }, { name = "prompt-toolkit", specifier = "==3.0.52" }, { name = "pydantic", specifier = "==2.13.4" }, @@ -2185,7 +2185,7 @@ name = "nvidia-cublas-cu12" version = "12.9.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc-cu12", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/cb/c0/0a517bfe63ccd3b92eb254d264e28fca3c7cab75d07daea315250fb1bf73/nvidia_cublas_cu12-12.9.2.10-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:e4f53a8ca8c5d6e8c492d0d0a3d565ecb59a751b19cfdaa4f6da0ab2104c1702", size = 581240110, upload-time = "2026-04-08T18:52:31.532Z" }, @@ -2204,7 +2204,7 @@ name = "nvidia-cudnn-cu12" version = "9.23.0.39" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "nvidia-cublas-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/7d/9d/1a383211b0967e702b9e84643986fb31bf35ca07bddc19e0cf139fd3291d/nvidia_cudnn_cu12-9.23.0.39-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:89d53e2a2b0614278afbeda67ac89594bdd74f9f283f22f2d34409d55859846f", size = 720831886, upload-time = "2026-05-30T03:31:06.419Z" }, @@ -2302,31 +2302,31 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, + { url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/c1/e8098490ab15abf116dcaf9fa89ededcb35547c7d08d4b5a62f573dc1e63/opentelemetry_exporter_otlp_proto_common-1.43.0.tar.gz", hash = "sha256:c4e32ba6d6b13bdb2b8f6764c4fd28d00192826561aa04f6d14eedfce7ac076f", size = 20197, upload-time = "2026-06-24T15:20:00.247Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b2/41ebc74ae1d5859901f1b69305de58724bf043381103d6ef413521cbc35a/opentelemetry_exporter_otlp_proto_common-1.43.0-py3-none-any.whl", hash = "sha256:123c3f9cc87218562490c63b36f497bf3a722faf174a515d1443f31ababa6264", size = 17048, upload-time = "2026-06-24T15:19:41.264Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -2337,14 +2337,14 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/92/0b9f56412483a8891d4843890294796c9df8ab42417bd9bad8035d840cb3/opentelemetry_exporter_otlp_proto_http-1.43.0.tar.gz", hash = "sha256:fa8a42bb7d00ee5391f4c0b04d8e6a46c03caa437903296ab73a81dc11ba118f", size = 25406, upload-time = "2026-06-24T15:20:01.515Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/b685ed7af2e17c29ffc8af56f1fa8bc2033258fc30fb0d2b722f49d13ba0/opentelemetry_exporter_otlp_proto_http-1.43.0-py3-none-any.whl", hash = "sha256:647f603aa8efdbdb4dbff842e0729d0406a6fff26b295a72d3d60e7d963b2610", size = 21795, upload-time = "2026-06-24T15:19:43.164Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -2352,14 +2352,14 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/97/02fe6e1c8b1ffac42d0b429c18080edb24e0e0d18c86612edf72b5752382/opentelemetry_instrumentation-0.64b0.tar.gz", hash = "sha256:b47d528dead6271d7743114417eb67fc915bd9258111c48dbf9a4951d2efa88d", size = 41935, upload-time = "2026-06-24T15:19:12.951Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0c/cb9fe342de5299c7af24582eb7d788661cc53a1c4b904da92309caaa9417/opentelemetry_instrumentation-0.64b0-py3-none-any.whl", hash = "sha256:133ab7ffca796557aec059bf6be3190a34b6dea987f25be3d9409e230cbdad8b", size = 35880, upload-time = "2026-06-24T15:18:17.277Z" }, ] [[package]] name = "opentelemetry-instrumentation-anthropic" -version = "0.61.0" +version = "0.62.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -2367,29 +2367,30 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-semantic-conventions-ai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/4b/dc0dcdbe5ef108d5fa19a09c7eadae75d2735eae634bc925ba063a4753e4/opentelemetry_instrumentation_anthropic-0.61.0.tar.gz", hash = "sha256:695f2841d357047a85ed9c8d68a34d1f8bef7925af66758822bf2f5951f28238", size = 700181, upload-time = "2026-05-31T07:28:33.509Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/31/d04233198d5805931e49dfa491f9780f521cb40d37811f704545037697d2/opentelemetry_instrumentation_anthropic-0.62.1.tar.gz", hash = "sha256:72864e9ee1802242bc70b911751eaee03277059846ceab4a1cc04f06b36f38ab", size = 699709, upload-time = "2026-06-28T12:12:11.742Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/ea/86b7bebc2f2ca3c0c8cf4d324d09592b5b1752366c90c1014529c655d621/opentelemetry_instrumentation_anthropic-0.61.0-py3-none-any.whl", hash = "sha256:f260aa8ee70862d1c79fbe72cc66bb187774fef0931b3ab7da894ecc5fed6bb0", size = 19774, upload-time = "2026-05-31T07:27:55.982Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3b/2b6efbfed9aed24415efb0836578f2f0cd8268bb1f46e88ceae0725bef07/opentelemetry_instrumentation_anthropic-0.62.1-py3-none-any.whl", hash = "sha256:0b43186627677c0ace078542c8d79326fecda3cadae9dd158ea78503a9755a5b", size = 19772, upload-time = "2026-06-28T12:11:38.126Z" }, ] [[package]] name = "opentelemetry-instrumentation-google-genai" -version = "0.7b1" +version = "1.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-genai" }, + { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/ca/da1a8d598542a3bd343859efc6e6e89b74b5189887b8f7ef447267e5e9ed/opentelemetry_instrumentation_google_genai-0.7b1.tar.gz", hash = "sha256:dc81249eb8a01184e2b3e0eb207864158e7147e3307b018dc9625b23973457a5", size = 52078, upload-time = "2026-05-19T01:46:07.689Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/13/a2422f9164c09406092576d878d9b23d74b0a2ed75c1a77454c3a0178eb1/opentelemetry_instrumentation_google_genai-1.0b1.tar.gz", hash = "sha256:784b3a473ed51033837114b96854b31a0f0b0fe7c81e132e58a45352eee3b102", size = 104473, upload-time = "2026-07-13T19:01:35.403Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/59/6c89d9c1c72ea321b2e3f6557fa58add59c61d0ba483d90955a7d9af4ec3/opentelemetry_instrumentation_google_genai-0.7b1-py3-none-any.whl", hash = "sha256:b7b3cda48efc9b9550dee76e4437a2f5e3b6877abffc4a6c336966eceb00f8b8", size = 31414, upload-time = "2026-05-19T01:46:06.393Z" }, + { url = "https://files.pythonhosted.org/packages/07/a3/315330fb955d5caea51793c4f49d8904fcade3927b3512fdc0595b3b701a/opentelemetry_instrumentation_google_genai-1.0b1-py3-none-any.whl", hash = "sha256:50789eebf4ce38dc09147e28bdbd96d45550c7f479232c593c0ddbdab37cf97f", size = 28010, upload-time = "2026-07-13T19:01:34.051Z" }, ] [[package]] name = "opentelemetry-instrumentation-mcp" -version = "0.61.0" +version = "0.62.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -2397,14 +2398,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-semantic-conventions-ai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/22/ae0d1fa0c7d16bcaa2924f72989fb4f9d67f503a2918284b3b0c4499ed47/opentelemetry_instrumentation_mcp-0.61.0.tar.gz", hash = "sha256:53406c765b2eda859fd4aeb6429ddcfa0d50344098351f720567b26d39df6e43", size = 120547, upload-time = "2026-05-31T07:28:46.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8d/faa4ae935f8a6b90ec786a08f2e5fc71bb491c513a814f0efd2e05d1cdce/opentelemetry_instrumentation_mcp-0.62.1.tar.gz", hash = "sha256:109c8ef1b5340725e93ed61f21a1a1298bffce77b22dfb5861db36044678d11f", size = 120553, upload-time = "2026-06-28T12:12:24.019Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/ed/8c7618d5888a36a8e7ce8b4f4941d1baa34da35435299303217575b4deb7/opentelemetry_instrumentation_mcp-0.61.0-py3-none-any.whl", hash = "sha256:30db132e0ce6ab597f3b85c60591ee3534c1008e58cd129f2fefba149281babd", size = 10472, upload-time = "2026-05-31T07:28:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/21/32/95042450a28a8c7607e4fad2fce2435ffb9e8c4664309180b7dc01c84f13/opentelemetry_instrumentation_mcp-0.62.1-py3-none-any.whl", hash = "sha256:7ffeee0dbca9002ad8a2364ffeb638f7d66facdf7d2dbbc364dc21af748e4625", size = 10476, upload-time = "2026-06-28T12:11:51.91Z" }, ] [[package]] name = "opentelemetry-instrumentation-openai" -version = "0.61.0" +version = "0.62.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -2412,48 +2413,48 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-semantic-conventions-ai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/76/d37cde51008f47c5864cabb7f6a548c5284a10996bc9febd9f111a214d0c/opentelemetry_instrumentation_openai-0.61.0.tar.gz", hash = "sha256:f1bec3d5afa2430295dfd4e82f6d8a51079b220005e45d53b60de808fd7450bf", size = 7329795, upload-time = "2026-05-31T07:28:50.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/1e/95b11836b677eb7f7edf8c2c48b194f9f4917162d71f187800e9818135dd/opentelemetry_instrumentation_openai-0.62.1.tar.gz", hash = "sha256:c40b1af21c0823feab264040a5ff3e0bae61bf79a45099bd7c2c1825c3e16d93", size = 7329493, upload-time = "2026-06-28T12:12:28.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/1f/66674effc0ea458896f174d735da1d73e1f4bf205ee2beed1968d8d737dc/opentelemetry_instrumentation_openai-0.61.0-py3-none-any.whl", hash = "sha256:3b1c37f53527dfca14bdff3da438aeae7a7f3477fc43c5a94e50f99137c277b9", size = 45502, upload-time = "2026-05-31T07:28:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/40/10/4ad4bd468a7e27742ebe2e28a30e4c0ccacdc9af9a3e024fbb13309d4f53/opentelemetry_instrumentation_openai-0.62.1-py3-none-any.whl", hash = "sha256:b0154b1281f6db73bca08df0486dbaffb554e564da466b1a2ccd7d8278130444", size = 45510, upload-time = "2026-06-28T12:11:55.975Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b9/d357faefb40bda1d4799913e6af611171ff22a2dedcb93576bc92242d056/opentelemetry_proto-1.43.0.tar.gz", hash = "sha256:224778df17e1f3fafeaaa21d874236ca5f6ffc2f86e0899298ec7351aac27924", size = 46481, upload-time = "2026-06-24T15:20:07.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/3e5308cf548b8f72529c7db1afdb3a404211982376a12927fd7759f77bf3/opentelemetry_proto-1.43.0-py3-none-any.whl", hash = "sha256:c58f1f7ef84bc7dc2834016c0c37fe0081dde7ca9f6339be1970fbf9cdaaa90d", size = 72489, upload-time = "2026-06-24T15:19:51.164Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.42.1" +version = "1.43.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/eb/5041074274ac0956b03637cc039d434569112468e875eddfcc9a0674ce06/opentelemetry_sdk-1.43.0.tar.gz", hash = "sha256:d8187c81c162df9913e4003dd6485f7390d9a24fc17026ec7387b8b8218b08e9", size = 254744, upload-time = "2026-06-24T15:20:08.467Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, + { url = "https://files.pythonhosted.org/packages/49/e3/b17be23af124201c9f52eececd4cc8ddfed1597d37b4ee771895d325805c/opentelemetry_sdk-1.43.0-py3-none-any.whl", hash = "sha256:d1323a547c1ce69d6a069a17a44b7da82bb8b332051ecb074041f87642c86823", size = 178852, upload-time = "2026-06-24T15:19:52.169Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.63b1" +version = "0.64b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/30/5f26df29509eccd86b99b481ac9ffa39da49ba9577cc69071c552ae30447/opentelemetry_semantic_conventions-0.64b0.tar.gz", hash = "sha256:72f76fb2d1582d9d033dd1fcd84532e961e6ff3d90d24ba6fabc72975a83864c", size = 148340, upload-time = "2026-06-24T15:20:09.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ca/23ba87a221b574a7c5a99d48849d80bfe8b047624681357e2b002e566187/opentelemetry_semantic_conventions-0.64b0-py3-none-any.whl", hash = "sha256:ea77e85e354b8f604ddbe5f3d9135216f982fa4d77e5859ac30f6d8a50505aa6", size = 203713, upload-time = "2026-06-24T15:19:53.339Z" }, ] [[package]] @@ -2471,16 +2472,17 @@ wheels = [ [[package]] name = "opentelemetry-util-genai" -version = "0.3b0" +version = "1.0b0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/d8/4dd2fb622d26ec45b10ef63eb87fd512f5d7467c7bd35ce390629bd6dff8/opentelemetry_util_genai-0.3b0.tar.gz", hash = "sha256:83e127789a9ad615b8ca65f05fc36955a67ce257b06142bfd46159a3b7ed73d3", size = 31800, upload-time = "2026-02-20T16:16:14.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/1f/c667b221dcb84a8fcb5e3324753e1dfb295938cb71e10c979588605bd66d/opentelemetry_util_genai-1.0b0.tar.gz", hash = "sha256:d5f2ab950d08b965f4f090c415eca6f0b02a87a0f07814534c38ff9bdb407c11", size = 53584, upload-time = "2026-07-09T21:36:24.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/e5/fada54909e445d7b4007f8b96221d571999efeab9446f3127cc1cebe5e07/opentelemetry_util_genai-0.3b0-py3-none-any.whl", hash = "sha256:ebc2b01bcb891ddc7218452470d189d3321cd742653299ff8e7de45debcfb986", size = 28426, upload-time = "2026-02-20T16:16:12.027Z" }, + { url = "https://files.pythonhosted.org/packages/ee/47/a512818b21940a99124432c2ba44c77b20505ef2b716b7bb426a9bdec7da/opentelemetry_util_genai-1.0b0-py3-none-any.whl", hash = "sha256:e6d27cb630bd99a265474a7bf3a91942ef701217906fc3b72aaf6a29526c0cc7", size = 42266, upload-time = "2026-07-09T21:36:23.539Z" }, ] [[package]] @@ -2568,7 +2570,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -3510,8 +3512,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, - { name = "jeepney", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [