Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/docs/ref/open_telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/fast_agent/core/logging/listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
),
Expand Down
1 change: 1 addition & 0 deletions src/fast_agent/event_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/fast_agent/llm/model_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 73 additions & 10 deletions src/fast_agent/tools/shell_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions src/fast_agent/ui/console_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)}",
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/fast_agent/ui/model_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading