Skip to content
Open
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
149 changes: 149 additions & 0 deletions pydantic_ai_slim/pydantic_ai/_display.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Agent banner display — logo, agent info, and next-steps guide.

Prints a branded banner to stderr the first time any agent runs. Subsequent calls
are no-ops — the banner only prints once per process, regardless of how many
agent instances exist.
"""

from __future__ import annotations

import os
import platform
import sys
from typing import TYPE_CHECKING, Any

from . import __version__

if TYPE_CHECKING:
from .agent import Agent as _Agent

# Track whether the banner has already been displayed (process-wide, once only).
_banner_displayed: bool = False

LOGO_PREAMBLE = r"""
pydantic-ai v{version} • Python {python_version}
"""

LOGO = r"""
/\
/ \
/ \
/ \
/________\
/ || \
/ || \
\_____||_____/
"""

LOGO_TEXT = r"""
[agent]
name: {name}
model: {model}
output_type: {output_type}
tools: {tools}
logfire: {logfire_status}


"""

# line by line join of Logo and text for easier maintenance
LOGO_PLUS_TEXT = '\n'.join(
f'{logo_line}{text_line}'
for logo_line, text_line in zip(
LOGO.splitlines(),
LOGO_TEXT.splitlines(),
)
)

NEXT_STEPS = """
─── Next steps ────────────────────────────────────
• Setup Logfire (Free): https://ai.pydantic.dev/logfire/
• Configure Logfire: logfire.configure(...)
• Instrument this agent: logfire.instrument_pydantic_ai()
• Instrument its calls: .instrument_httpx · .instrument_openai
• Export anywhere: OTLP → Logfire · etc

• Docs: https://ai.pydantic.dev/logfire/
• Turn off this banner: os.environ['PYDANTIC_AI_HIDE_BANNER'] = '1'
Comment thread
strawgate marked this conversation as resolved.

"""

BANNER = f'{LOGO_PREAMBLE}\n{LOGO_PLUS_TEXT}\n{NEXT_STEPS}'


def _get_model_name(agent: _Agent[Any, Any]) -> str:
"""Get a display-friendly model name from the agent."""
model = agent.model
if model is None:
return '(not set)'
if isinstance(model, str):
return model
# Model instance
try:
return model.model_id
except Exception:
return getattr(model, 'model_name', str(model))


def _get_output_type_name(agent: _Agent[Any, Any]) -> str:
"""Get a display-friendly output type name."""
try:
ot = agent.output_type
return getattr(ot, '__name__', str(ot))
except Exception:
return '(unknown)'


def _get_tool_names(agent: _Agent[Any, Any]) -> str:
"""Get comma-separated tool names from the agent's function toolset."""
try:
tools = agent._function_toolset.tools # type: ignore[union-attr]
if tools:
return ', '.join(tools.keys())
return '(none)'
except Exception:
return '(none)'


def _get_logfire_status(agent: _Agent[Any, Any]) -> str:
"""Check whether Logfire instrumentation is configured."""
try:
return 'configured' if agent.instrument else 'not configured'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — Banner mis-reports logfire status when Agent.instrument_all(True) is set

agent.instrument is the public property that returns the raw _instrument field (defaults to None per-instance, see agent/__init__.py:798-801 and :424 where self._instrument = None). The global default set by Agent.instrument_all(...) lives on the class-level Agent._instrument_default and is only consulted by _resolve_instrumentation_settings() (line 2386) — not by agent.instrument.

Concrete trigger:

from pydantic_ai import Agent
Agent.instrument_all(True)
agent = Agent('openai:gpt-5-mini', defer_model_check=True)
agent.display_banner()  # prints "logfire: not configured" — wrong

A user who follows the "instrument this agent" step in the banner's own next-steps list will see the banner say "not configured" even though logfire is wired up for their agent. This is a regression introduced in the "display updates" commit — the prior version (commit 3abade34) used agent._resolve_instrumentation_settings() for exactly this reason:

return 'configured' if settings is not None else 'not configured'
Suggested change
return 'configured' if agent.instrument else 'not configured'
try:
settings = agent._resolve_instrumentation_settings()
return 'configured' if settings is not None else 'not configured'
except Exception:
return 'unknown'

Also worth adding a test that pins this behavior — e.g. test_logfire_status_configured_via_instrument_all that sets Agent.instrument_all(True), constructs an agent, and asserts 'configured' in output.

except Exception:
return 'unknown'


def display_agent_banner(agent: _Agent[Any, Any]) -> None:
"""Display the agent banner to stderr, once per process.

Set ``PYDANTIC_AI_HIDE_BANNER=1`` to suppress the banner.

Args:
agent: The agent to display the banner for.
"""
if os.environ.get('PYDANTIC_AI_HIDE_BANNER'):
return

global _banner_displayed
if _banner_displayed:
return
_banner_displayed = True

name = agent.name or '(unnamed)'
model = _get_model_name(agent)
output_type = _get_output_type_name(agent)
tools = _get_tool_names(agent)
logfire_status = _get_logfire_status(agent)
python_version = platform.python_version()

banner = BANNER.format(
version=__version__,
python_version=python_version,
name=name,
model=model,
output_type=output_type,
tools=tools,
logfire_status=logfire_status,
)

print(f'\n{banner}\n', file=sys.stderr)
23 changes: 23 additions & 0 deletions pydantic_ai_slim/pydantic_ai/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,24 @@ def event_stream_handler(self) -> EventStreamHandler[AgentDepsT] | None:
def __repr__(self) -> str:
return f'{type(self).__name__}(model={self.model!r}, name={self.name!r}, end_strategy={self.end_strategy!r}, model_settings={self.model_settings!r}, output_type={self.output_type!r})'

def display_banner(self) -> None:
Comment thread
strawgate marked this conversation as resolved.
"""Display a banner with logo, agent info, and next steps.

Prints to stderr once per agent instance. Subsequent calls are no-ops.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — Stale docstring says "once per agent instance"

The actual behavior is once per process (driven by _display._banner_displayed: bool). As written, "Subsequent calls are no-ops" is technically true after the first call on any agent, but "once per agent instance" is misleading — a second, brand-new Agent(...) instance will not see the banner either, because the flag is process-wide. The matching docstring in _display.py:117 ("once per process") is the accurate description; align this one with it.

Called automatically on the first `run()` / `run_sync()` / `iter()` call.

Example:
```python
from pydantic_ai import Agent

agent = Agent('openai:gpt-5.2', name='my_agent')
agent.display_banner()
```
"""
from .._display import display_agent_banner

display_agent_banner(self)

@overload
def iter(
self,
Expand Down Expand Up @@ -1048,6 +1066,11 @@ async def main():
if infer_name and self.name is None:
self._infer_name(inspect.currentframe())

# Display agent banner on first run (once per agent instance, process-wide)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — Stale comment: "once per agent instance, process-wide"

Same wording bug as the display_banner docstring above. The implementation is once per process (single boolean _banner_displayed), and the phrase is internally inconsistent. Update to something like # Display agent banner on first run (once per process).

from .._display import display_agent_banner

display_agent_banner(self)

# Tool retries cannot be overridden per run: `int` is treated as the output budget. An explicit
# `retries={'tools': ...}` is rejected so the value isn't silently dropped.
retry_overrides = _normalize_agent_retry_overrides(retries, int_means='output')
Expand Down
150 changes: 150 additions & 0 deletions tests/test_display.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
from __future__ import annotations

import sys
from io import StringIO

import pytest

import pydantic_ai_slim.pydantic_ai._display as _display
from pydantic_ai import Agent


@pytest.fixture(autouse=True)
def _reset_banner_state():
"""Reset the once-per-process banner flag between tests."""
_display._banner_displayed = False
yield
_display._banner_displayed = False


class TestDisplayAgentBanner:
def test_smoke_prints_banner(self):
"""Banner prints to stderr on first call."""
agent = Agent('test', name='my_agent', output_type=bool, defer_model_check=True)

capture = StringIO()
old = sys.stderr
sys.stderr = capture
try:
_display.display_agent_banner(agent)
finally:
sys.stderr = old

output = capture.getvalue()
assert 'my_agent' in output
assert 'test' in output
assert 'bool' in output
assert 'pydantic-ai v' in output
assert 'Next steps' in output

def test_once_per_process(self):
"""Second call is a no-op."""
agent = Agent('test', name='agent1', defer_model_check=True)

capture = StringIO()
old = sys.stderr
sys.stderr = capture
try:
_display.display_agent_banner(agent)
finally:
sys.stderr = old
first = capture.getvalue()
assert first, 'First call should produce output'

capture2 = StringIO()
sys.stderr = capture2
try:
_display.display_agent_banner(agent)
finally:
sys.stderr = old
assert capture2.getvalue() == '', 'Second call should be silent'

def test_hide_banner_env_var(self, monkeypatch: pytest.MonkeyPatch):
"""PYDANTIC_AI_HIDE_BANNER suppresses output."""
_display._banner_displayed = False
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NITPICK — Redundant manual reset of _display._banner_displayed

The autouse _reset_banner_state fixture at lines 12-17 already sets _display._banner_displayed = False both before and after every test in this module, so this line is dead code. Drop it (or, if you want to keep the explicit intent, drop the autouse fixture — but the fixture's name and docstring make the "reset between tests" invariant clearer).

monkeypatch.setenv('PYDANTIC_AI_HIDE_BANNER', '1')

agent = Agent('test', name='hidden', defer_model_check=True)

capture = StringIO()
old = sys.stderr
sys.stderr = capture
try:
_display.display_agent_banner(agent)
finally:
sys.stderr = old
assert capture.getvalue() == '', 'Banner should be suppressed by env var'

def test_agent_display_banner_method(self):
"""Agent.display_banner() calls through to _display.display_agent_banner."""
agent = Agent('test', name='method_test', defer_model_check=True)

capture = StringIO()
old = sys.stderr
sys.stderr = capture
try:
agent.display_banner()
finally:
sys.stderr = old

assert 'method_test' in capture.getvalue()

def test_tools_appear_in_banner(self):
"""Function tools are listed in the banner."""
agent = Agent('test', name='tool_agent', defer_model_check=True)

@agent.tool_plain
def my_tool() -> str:
return 'ok'

capture = StringIO()
old = sys.stderr
sys.stderr = capture
try:
_display.display_agent_banner(agent)
finally:
sys.stderr = old

assert 'my_tool' in capture.getvalue()

def test_logfire_status_not_configured(self):
"""Logfire shows 'not configured' when no instrumentation is set."""
agent = Agent('test', name='no_logfire', defer_model_check=True)

capture = StringIO()
old = sys.stderr
sys.stderr = capture
try:
_display.display_agent_banner(agent)
finally:
sys.stderr = old

assert 'not configured' in capture.getvalue()

def test_model_name_string(self):
"""Model displayed as plain string when set as a string."""
agent = Agent('openai:gpt-5-mini', name='str_model', defer_model_check=True)

capture = StringIO()
old = sys.stderr
sys.stderr = capture
try:
_display.display_agent_banner(agent)
finally:
sys.stderr = old

assert 'openai:gpt-5-mini' in capture.getvalue()

def test_model_name_none(self):
"""Model displayed as '(not set)' when model is None."""
agent = Agent(None, name='no_model', defer_model_check=True)

capture = StringIO()
old = sys.stderr
sys.stderr = capture
try:
_display.display_agent_banner(agent)
finally:
sys.stderr = old

assert '(not set)' in capture.getvalue()
Loading