-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Add display banner #5754
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v2-main
Are you sure you want to change the base?
Add display banner #5754
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' | ||||||||||||||
|
|
||||||||||||||
| """ | ||||||||||||||
|
|
||||||||||||||
| 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' | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM — Banner mis-reports logfire status when
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" — wrongA 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 return 'configured' if settings is not None else 'not configured'
Suggested change
Also worth adding a test that pins this behavior — e.g. |
||||||||||||||
| 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) | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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') | ||
|
|
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NITPICK — Redundant manual reset of The autouse |
||
| 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() | ||
Uh oh!
There was an error while loading. Please reload this page.