From 01ecc4c0610a221ff46d7fe77ae032e34c4a1367 Mon Sep 17 00:00:00 2001 From: Sebastian Hirt Date: Mon, 6 Oct 2025 22:31:08 +0200 Subject: [PATCH 1/2] feat: optionally show tool calls in CLI --- pydantic_ai_slim/pydantic_ai/_cli.py | 17 +++++++++++++++-- pydantic_ai_slim/pydantic_ai/agent/abstract.py | 9 ++++++++- tests/test_cli.py | 4 ++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 95fcf8b520..e8638e311e 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -18,7 +18,7 @@ from ._run_context import AgentDepsT from .agent import AbstractAgent, Agent from .exceptions import UserError -from .messages import ModelMessage, ModelResponse +from .messages import FunctionToolCallEvent, FunctionToolResultEvent, ModelMessage, ModelResponse from .models import KnownModelName, infer_model from .output import OutputDataT @@ -229,6 +229,7 @@ async def run_chat( config_dir: Path | None = None, deps: AgentDepsT = None, message_history: list[ModelMessage] | None = None, + show_tool_calls: bool = False, ) -> int: prompt_history_path = (config_dir or PYDANTIC_AI_HOME) / PROMPT_HISTORY_FILENAME prompt_history_path.parent.mkdir(parents=True, exist_ok=True) @@ -255,7 +256,7 @@ async def run_chat( return exit_value else: try: - messages = await ask_agent(agent, text, stream, console, code_theme, deps, messages) + messages = await ask_agent(agent, text, stream, console, code_theme, deps, messages, show_tool_calls) except CancelledError: # pragma: no cover console.print('[dim]Interrupted[/dim]') except Exception as e: # pragma: no cover @@ -273,6 +274,7 @@ async def ask_agent( code_theme: str, deps: AgentDepsT = None, messages: list[ModelMessage] | None = None, + show_tool_calls: bool = False, ) -> list[ModelMessage]: status = Status('[dim]Working on it…[/dim]', console=console) @@ -294,6 +296,17 @@ async def ask_agent( async for content in handle_stream.stream_output(debounce_by=None): live.update(Markdown(str(content), code_theme=code_theme)) + elif show_tool_calls and Agent.is_call_tools_node(node): + async with node.stream(agent_run.ctx) as handle_stream: + async for event in handle_stream: + if isinstance(event, FunctionToolCallEvent): + console.print( + Markdown(f'[Tool] {event.part.tool_name!r} called with args={event.part.args}') + ) + elif isinstance(event, FunctionToolResultEvent): + console.print( + Markdown(f'[Tool] {event.result.tool_name!r} returned => {event.result.content}') + ) assert agent_run.result is not None return agent_run.result.all_messages() diff --git a/pydantic_ai_slim/pydantic_ai/agent/abstract.py b/pydantic_ai_slim/pydantic_ai/agent/abstract.py index c5d59f7561..e3317140b5 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/abstract.py +++ b/pydantic_ai_slim/pydantic_ai/agent/abstract.py @@ -1113,6 +1113,7 @@ async def to_cli( deps: AgentDepsT = None, prog_name: str = 'pydantic-ai', message_history: list[_messages.ModelMessage] | None = None, + show_tool_calls: bool = False, ) -> None: """Run the agent in a CLI chat interface. @@ -1120,6 +1121,7 @@ async def to_cli( deps: The dependencies to pass to the agent. prog_name: The name of the program to use for the CLI. Defaults to 'pydantic-ai'. message_history: History of the conversation so far. + show_tool_calls: Whether to show tool calls in the CLI. Example: ```python {title="agent_to_cli.py" test="skip"} @@ -1143,6 +1145,7 @@ async def main(): code_theme='monokai', prog_name=prog_name, message_history=message_history, + show_tool_calls=show_tool_calls, ) def to_cli_sync( @@ -1150,6 +1153,7 @@ def to_cli_sync( deps: AgentDepsT = None, prog_name: str = 'pydantic-ai', message_history: list[_messages.ModelMessage] | None = None, + show_tool_calls: bool = False, ) -> None: """Run the agent in a CLI chat interface with the non-async interface. @@ -1157,6 +1161,7 @@ def to_cli_sync( deps: The dependencies to pass to the agent. prog_name: The name of the program to use for the CLI. Defaults to 'pydantic-ai'. message_history: History of the conversation so far. + show_tool_calls: Whether to show tool calls in the CLI. ```python {title="agent_to_cli_sync.py" test="skip"} from pydantic_ai import Agent @@ -1167,5 +1172,7 @@ def to_cli_sync( ``` """ return get_event_loop().run_until_complete( - self.to_cli(deps=deps, prog_name=prog_name, message_history=message_history) + self.to_cli( + deps=deps, prog_name=prog_name, message_history=message_history, show_tool_calls=show_tool_calls + ) ) diff --git a/tests/test_cli.py b/tests/test_cli.py index e95ff09141..10d686ff2e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -293,6 +293,7 @@ def test_agent_to_cli_sync(mocker: MockerFixture, env: TestEnv): prog_name='pydantic-ai', deps=None, message_history=None, + show_tool_calls=False, ) @@ -309,6 +310,7 @@ async def test_agent_to_cli_async(mocker: MockerFixture, env: TestEnv): prog_name='pydantic-ai', deps=None, message_history=None, + show_tool_calls=False, ) @@ -329,6 +331,7 @@ async def test_agent_to_cli_with_message_history(mocker: MockerFixture, env: Tes prog_name='pydantic-ai', deps=None, message_history=test_messages, + show_tool_calls=False, ) @@ -348,4 +351,5 @@ def test_agent_to_cli_sync_with_message_history(mocker: MockerFixture, env: Test prog_name='pydantic-ai', deps=None, message_history=test_messages, + show_tool_calls=False, ) From 8af30aee1062583b3487d82d317ace0a577b624d Mon Sep 17 00:00:00 2001 From: Sebastian Hirt Date: Tue, 14 Oct 2025 00:23:41 +0200 Subject: [PATCH 2/2] feat: remove option for showing tool calls + prettier tool call output --- pydantic_ai_slim/pydantic_ai/_cli.py | 36 +++++++++++++------ .../pydantic_ai/agent/abstract.py | 9 +---- tests/test_cli.py | 4 --- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index e8638e311e..c265cfab37 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -229,7 +229,6 @@ async def run_chat( config_dir: Path | None = None, deps: AgentDepsT = None, message_history: list[ModelMessage] | None = None, - show_tool_calls: bool = False, ) -> int: prompt_history_path = (config_dir or PYDANTIC_AI_HOME) / PROMPT_HISTORY_FILENAME prompt_history_path.parent.mkdir(parents=True, exist_ok=True) @@ -256,7 +255,7 @@ async def run_chat( return exit_value else: try: - messages = await ask_agent(agent, text, stream, console, code_theme, deps, messages, show_tool_calls) + messages = await ask_agent(agent, text, stream, console, code_theme, deps, messages) except CancelledError: # pragma: no cover console.print('[dim]Interrupted[/dim]') except Exception as e: # pragma: no cover @@ -274,9 +273,12 @@ async def ask_agent( code_theme: str, deps: AgentDepsT = None, messages: list[ModelMessage] | None = None, - show_tool_calls: bool = False, ) -> list[ModelMessage]: - status = Status('[dim]Working on it…[/dim]', console=console) + MODEL_CALL_STATUS_MSG = '[dim]Calling model…[/dim]' + TOOL_EXECUTION_STATUS_MSG = '[dim]Executing tools…[/dim]' + MAX_TOOL_CALL_RESULT_LEN = 100 + MAX_TOOL_CALL_ID_LEN = 5 + status = Status(MODEL_CALL_STATUS_MSG, console=console) if not stream: with status: @@ -287,26 +289,40 @@ async def ask_agent( with status, ExitStack() as stack: async with agent.iter(prompt, message_history=messages, deps=deps) as agent_run: - live = Live('', refresh_per_second=15, console=console, vertical_overflow='ellipsis') + final_output_live = None async for node in agent_run: if Agent.is_model_request_node(node): + status.update(MODEL_CALL_STATUS_MSG) async with node.stream(agent_run.ctx) as handle_stream: status.stop() # stopping multiple times is idempotent - stack.enter_context(live) # entering multiple times is idempotent async for content in handle_stream.stream_output(debounce_by=None): - live.update(Markdown(str(content), code_theme=code_theme)) - elif show_tool_calls and Agent.is_call_tools_node(node): + if final_output_live is None: + final_output_live = Live( + '', refresh_per_second=15, console=console, vertical_overflow='ellipsis' + ) + stack.enter_context(final_output_live) # entering multiple times is idempotent + final_output_live.update(Markdown(str(content), code_theme=code_theme)) + elif Agent.is_call_tools_node(node): + status.update(TOOL_EXECUTION_STATUS_MSG) async with node.stream(agent_run.ctx) as handle_stream: async for event in handle_stream: if isinstance(event, FunctionToolCallEvent): + status.stop() # stopping multiple times is idempotent console.print( - Markdown(f'[Tool] {event.part.tool_name!r} called with args={event.part.args}') + Markdown( + f'[Tool] {event.part.tool_name!r}[{event.part.tool_call_id[-5:]}] called with args={event.part.args}' + ) ) + status.start() elif isinstance(event, FunctionToolResultEvent): + status.stop() # stopping multiple times is idempotent console.print( - Markdown(f'[Tool] {event.result.tool_name!r} returned => {event.result.content}') + Markdown( + f'[Tool] {event.result.tool_name!r}[{event.result.tool_call_id[-MAX_TOOL_CALL_ID_LEN:]}] returned => {event.result.content if len(event.result.content) < MAX_TOOL_CALL_RESULT_LEN else str(event.result.content[:MAX_TOOL_CALL_RESULT_LEN]) + "..."}' + ) ) + status.start() assert agent_run.result is not None return agent_run.result.all_messages() diff --git a/pydantic_ai_slim/pydantic_ai/agent/abstract.py b/pydantic_ai_slim/pydantic_ai/agent/abstract.py index e3317140b5..c5d59f7561 100644 --- a/pydantic_ai_slim/pydantic_ai/agent/abstract.py +++ b/pydantic_ai_slim/pydantic_ai/agent/abstract.py @@ -1113,7 +1113,6 @@ async def to_cli( deps: AgentDepsT = None, prog_name: str = 'pydantic-ai', message_history: list[_messages.ModelMessage] | None = None, - show_tool_calls: bool = False, ) -> None: """Run the agent in a CLI chat interface. @@ -1121,7 +1120,6 @@ async def to_cli( deps: The dependencies to pass to the agent. prog_name: The name of the program to use for the CLI. Defaults to 'pydantic-ai'. message_history: History of the conversation so far. - show_tool_calls: Whether to show tool calls in the CLI. Example: ```python {title="agent_to_cli.py" test="skip"} @@ -1145,7 +1143,6 @@ async def main(): code_theme='monokai', prog_name=prog_name, message_history=message_history, - show_tool_calls=show_tool_calls, ) def to_cli_sync( @@ -1153,7 +1150,6 @@ def to_cli_sync( deps: AgentDepsT = None, prog_name: str = 'pydantic-ai', message_history: list[_messages.ModelMessage] | None = None, - show_tool_calls: bool = False, ) -> None: """Run the agent in a CLI chat interface with the non-async interface. @@ -1161,7 +1157,6 @@ def to_cli_sync( deps: The dependencies to pass to the agent. prog_name: The name of the program to use for the CLI. Defaults to 'pydantic-ai'. message_history: History of the conversation so far. - show_tool_calls: Whether to show tool calls in the CLI. ```python {title="agent_to_cli_sync.py" test="skip"} from pydantic_ai import Agent @@ -1172,7 +1167,5 @@ def to_cli_sync( ``` """ return get_event_loop().run_until_complete( - self.to_cli( - deps=deps, prog_name=prog_name, message_history=message_history, show_tool_calls=show_tool_calls - ) + self.to_cli(deps=deps, prog_name=prog_name, message_history=message_history) ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 10d686ff2e..e95ff09141 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -293,7 +293,6 @@ def test_agent_to_cli_sync(mocker: MockerFixture, env: TestEnv): prog_name='pydantic-ai', deps=None, message_history=None, - show_tool_calls=False, ) @@ -310,7 +309,6 @@ async def test_agent_to_cli_async(mocker: MockerFixture, env: TestEnv): prog_name='pydantic-ai', deps=None, message_history=None, - show_tool_calls=False, ) @@ -331,7 +329,6 @@ async def test_agent_to_cli_with_message_history(mocker: MockerFixture, env: Tes prog_name='pydantic-ai', deps=None, message_history=test_messages, - show_tool_calls=False, ) @@ -351,5 +348,4 @@ def test_agent_to_cli_sync_with_message_history(mocker: MockerFixture, env: Test prog_name='pydantic-ai', deps=None, message_history=test_messages, - show_tool_calls=False, )