Skip to content

Commit a847090

Browse files
authored
Add tool-call logging with verbose mode (#10)
* Add tool-call logging with verbose mode and Rich console indicator Log each PydanticAI tool invocation via Python logging to stderr and print a dim Rich indicator (⚙ tool_name) in the REPL. A new -v/--verbose flag includes tool arguments in debug-level output. Logging setup is idempotent and isolated in patchwork/logging_config.py. * Fix ruff formatting in cli.py * Gate tool indicator on verbose mode and fix setup_logging level updates - Move console.print of ⚙ tool_name inside the verbose branch so default mode only logs to stderr without changing REPL output - Update setup_logging to always apply the handler level on subsequent calls instead of returning early, so verbose=True is honored even if the logger was previously initialized with verbose=False - Add regression tests for both behaviors
1 parent 1419b2f commit a847090

4 files changed

Lines changed: 208 additions & 3 deletions

File tree

patchwork/cli.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,45 @@
1+
import argparse
12
import asyncio
3+
import json
4+
import logging
5+
from collections.abc import AsyncIterable
26

37
from dotenv import load_dotenv
8+
from pydantic_ai import FunctionToolCallEvent, RunContext
49
from rich.console import Console
510

611
from patchwork.agent import agent
712
from patchwork.deps import PatchworkDeps
13+
from patchwork.logging_config import setup_logging
814
from patchwork.midi import MidiConnection
915
from patchwork.patch_library import PatchLibrary
1016
from patchwork.synth_definitions import load_synth_definitions
1117

1218
console = Console()
1319

1420

15-
async def main():
21+
def _make_event_handler(verbose: bool, logger: logging.Logger):
22+
"""Return an event_stream_handler that logs tool calls."""
23+
24+
async def handle_events(ctx: RunContext[PatchworkDeps], events: AsyncIterable) -> None:
25+
async for event in events:
26+
if isinstance(event, FunctionToolCallEvent):
27+
tool_name = event.part.tool_name
28+
logger.info("tool call: %s", tool_name)
29+
30+
if verbose:
31+
try:
32+
args = event.part.args_as_dict()
33+
except Exception:
34+
args = event.part.args
35+
logger.debug("tool args: %s %s", tool_name, json.dumps(args, default=str))
36+
console.print(f"[dim]⚙ {tool_name}[/dim]")
37+
38+
return handle_events
39+
40+
41+
async def main(verbose: bool = False):
42+
logger = setup_logging(verbose=verbose)
1643
midi = MidiConnection()
1744
synths = load_synth_definitions()
1845

@@ -27,6 +54,8 @@ async def main():
2754
console.print("[dim]no synth definitions found in synths/[/dim]\n")
2855
message_history = []
2956

57+
event_handler = _make_event_handler(verbose, logger)
58+
3059
try:
3160
while True:
3261
try:
@@ -44,22 +73,31 @@ async def main():
4473

4574
try:
4675
async with agent.run_stream(
47-
user_input, message_history=message_history, deps=deps
76+
user_input,
77+
message_history=message_history,
78+
deps=deps,
79+
event_stream_handler=event_handler,
4880
) as result:
4981
async for chunk in result.stream_text(delta=True):
5082
console.print(chunk, end="", markup=False, highlight=False)
5183
console.print() # newline after stream
5284

5385
message_history = result.all_messages()
5486
except Exception as e:
87+
logger.exception("Error during agent run")
5588
console.print(f"\n[bold red]error:[/bold red] {e}")
5689
finally:
5790
midi.close()
5891

5992

6093
def main_cli():
6194
load_dotenv()
62-
asyncio.run(main())
95+
96+
parser = argparse.ArgumentParser(description="patchwork — synth research agent")
97+
parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logging")
98+
args = parser.parse_args()
99+
100+
asyncio.run(main(verbose=args.verbose))
63101

64102

65103
if __name__ == "__main__":

patchwork/logging_config.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import logging
2+
import sys
3+
4+
5+
def setup_logging(verbose: bool = False) -> logging.Logger:
6+
"""Configure the patchwork logger. Idempotent — safe to call multiple times.
7+
8+
Always updates the handler level to match the current ``verbose`` setting,
9+
but never adds duplicate handlers.
10+
"""
11+
logger = logging.getLogger("patchwork")
12+
level = logging.DEBUG if verbose else logging.INFO
13+
14+
if logger.handlers:
15+
logger.handlers[0].setLevel(level)
16+
return logger
17+
18+
handler = logging.StreamHandler(sys.stderr)
19+
handler.setLevel(level)
20+
handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s"))
21+
22+
logger.setLevel(logging.DEBUG)
23+
logger.addHandler(handler)
24+
25+
return logger

tests/test_logging_config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import logging
2+
3+
from patchwork.logging_config import setup_logging
4+
5+
6+
class TestSetupLogging:
7+
def setup_method(self):
8+
"""Remove any existing patchwork logger handlers before each test."""
9+
logger = logging.getLogger("patchwork")
10+
logger.handlers.clear()
11+
12+
def test_returns_logger(self):
13+
logger = setup_logging()
14+
assert isinstance(logger, logging.Logger)
15+
assert logger.name == "patchwork"
16+
17+
def test_adds_stderr_handler(self):
18+
logger = setup_logging()
19+
assert len(logger.handlers) == 1
20+
assert isinstance(logger.handlers[0], logging.StreamHandler)
21+
22+
def test_default_handler_level_is_info(self):
23+
logger = setup_logging(verbose=False)
24+
assert logger.handlers[0].level == logging.INFO
25+
26+
def test_verbose_handler_level_is_debug(self):
27+
logger = setup_logging(verbose=True)
28+
assert logger.handlers[0].level == logging.DEBUG
29+
30+
def test_idempotent_no_duplicate_handlers(self):
31+
logger = setup_logging()
32+
setup_logging()
33+
assert len(logger.handlers) == 1
34+
35+
def test_logger_level_is_debug(self):
36+
logger = setup_logging()
37+
assert logger.level == logging.DEBUG
38+
39+
def test_subsequent_call_updates_handler_level(self):
40+
logger = setup_logging(verbose=False)
41+
assert logger.handlers[0].level == logging.INFO
42+
setup_logging(verbose=True)
43+
assert logger.handlers[0].level == logging.DEBUG
44+
assert len(logger.handlers) == 1

tests/test_tool_logging.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import logging
2+
from unittest.mock import MagicMock
3+
4+
import pytest
5+
6+
from patchwork.cli import _make_event_handler
7+
8+
9+
def _make_mock_tool_call_event(tool_name: str, args: dict | None = None):
10+
"""Create a mock FunctionToolCallEvent."""
11+
from pydantic_ai import FunctionToolCallEvent
12+
from pydantic_ai.messages import ToolCallPart
13+
14+
part = ToolCallPart(tool_name=tool_name, args=args or {})
15+
return FunctionToolCallEvent(part=part)
16+
17+
18+
async def _to_async_iterable(items):
19+
for item in items:
20+
yield item
21+
22+
23+
@pytest.fixture
24+
def logger():
25+
log = logging.getLogger("patchwork.test_tool_logging")
26+
log.handlers.clear()
27+
log.setLevel(logging.DEBUG)
28+
handler = logging.StreamHandler()
29+
handler.setLevel(logging.DEBUG)
30+
log.addHandler(handler)
31+
return log
32+
33+
34+
class TestToolCallEventHandler:
35+
@pytest.mark.asyncio
36+
async def test_logs_tool_name(self, logger, caplog):
37+
handler = _make_event_handler(verbose=False, logger=logger)
38+
event = _make_mock_tool_call_event("send_cc")
39+
ctx = MagicMock()
40+
41+
with caplog.at_level(logging.INFO, logger=logger.name):
42+
await handler(ctx, _to_async_iterable([event]))
43+
44+
assert any("send_cc" in record.message for record in caplog.records)
45+
46+
@pytest.mark.asyncio
47+
async def test_verbose_logs_args(self, logger, caplog):
48+
handler = _make_event_handler(verbose=True, logger=logger)
49+
args = {"synth_id": "minitaur", "param": "cutoff", "value": 64}
50+
event = _make_mock_tool_call_event("send_cc", args)
51+
ctx = MagicMock()
52+
53+
with caplog.at_level(logging.DEBUG, logger=logger.name):
54+
await handler(ctx, _to_async_iterable([event]))
55+
56+
debug_msgs = [r.message for r in caplog.records if r.levelno == logging.DEBUG]
57+
assert any("minitaur" in msg for msg in debug_msgs)
58+
59+
@pytest.mark.asyncio
60+
async def test_ignores_non_tool_events(self, logger, caplog):
61+
handler = _make_event_handler(verbose=False, logger=logger)
62+
ctx = MagicMock()
63+
64+
# A non-FunctionToolCallEvent object
65+
other_event = MagicMock()
66+
other_event.event_kind = "part_start"
67+
68+
with caplog.at_level(logging.INFO, logger=logger.name):
69+
await handler(ctx, _to_async_iterable([other_event]))
70+
71+
tool_records = [r for r in caplog.records if "tool call" in r.message]
72+
assert len(tool_records) == 0
73+
74+
@pytest.mark.asyncio
75+
async def test_handles_multiple_events(self, logger, caplog):
76+
handler = _make_event_handler(verbose=False, logger=logger)
77+
events = [
78+
_make_mock_tool_call_event("list_synths"),
79+
_make_mock_tool_call_event("send_cc"),
80+
]
81+
ctx = MagicMock()
82+
83+
with caplog.at_level(logging.INFO, logger=logger.name):
84+
await handler(ctx, _to_async_iterable(events))
85+
86+
tool_records = [r for r in caplog.records if "tool call" in r.message]
87+
assert len(tool_records) == 2
88+
89+
@pytest.mark.asyncio
90+
async def test_non_verbose_does_not_print_tool_indicator(self, logger, capsys):
91+
handler = _make_event_handler(verbose=False, logger=logger)
92+
event = _make_mock_tool_call_event("send_cc")
93+
ctx = MagicMock()
94+
95+
await handler(ctx, _to_async_iterable([event]))
96+
97+
captured = capsys.readouterr()
98+
assert "⚙" not in captured.out

0 commit comments

Comments
 (0)