diff --git a/pydantic_ai_slim/pydantic_ai/_display.py b/pydantic_ai_slim/pydantic_ai/_display.py new file mode 100644 index 0000000000..256a0b4ed9 --- /dev/null +++ b/pydantic_ai_slim/pydantic_ai/_display.py @@ -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' + +""" + +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' + 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) diff --git a/pydantic_ai_slim/pydantic_ai/agent/__init__.py b/pydantic_ai_slim/pydantic_ai/agent/__init__.py index 8239d586e3..1909ea9a17 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/__init__.py +++ b/pydantic_ai_slim/pydantic_ai/agent/__init__.py @@ -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: + """Display a banner with logo, agent info, and next steps. + + Prints to stderr once per agent instance. Subsequent calls are no-ops. + 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, @@ -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) + 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') diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000000..6034276980 --- /dev/null +++ b/tests/test_display.py @@ -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 + 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()