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

Prints a branded banner to stderr the first time an agent runs. Subsequent calls
for the same agent instance are no-ops. Each unique agent instance gets its own
banner.
"""

from __future__ import annotations

import platform
import sys
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any

from . import __version__

if TYPE_CHECKING:
from .agent import Agent as _Agent

# Track which agent instances have already been displayed (process-wide).
_displayed_agent_ids: set[int] = set()

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 'str'
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 — Fallback output_type: 'str' is misleading

The exception path returns the literal string 'str'. If agent.output_type ever raises (e.g. a property error on a user subclass), the banner tells the user the agent outputs a str, which is likely not true — it could be a structured Pydantic model, an int, a custom class, anything. The fallback should be neutral ('(unknown)', `'<?>') so the displayed value is never silently wrong. A real failure here should also be loud enough to investigate, not hidden behind a plausible-looking default.



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:
settings = agent._resolve_instrumentation_settings()
return 'configured' if settings is not None else 'not configured'
except Exception:
return 'unknown'


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

Args:
agent: The agent to display the banner for.
"""
import os

if os.environ.get('PYDANTIC_AI_HIDE_BANNER'):
return

agent_id = id(agent)
Comment thread
strawgate marked this conversation as resolved.
Outdated
if agent_id in _displayed_agent_ids:
return
_displayed_agent_ids.add(agent_id)

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()
timestamp = datetime.now(tz=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
Comment thread
strawgate marked this conversation as resolved.
Outdated

banner = BANNER.format(
version=__version__,
python_version=python_version,
timestamp=timestamp,
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 @@ -991,6 +991,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 @@ -1162,6 +1180,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
Loading