From patchwork Sat Apr 4 21:28:49 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Simon Glass X-Patchwork-Id: 2128 Return-Path: X-Original-To: u-boot-concept@u-boot.org Delivered-To: u-boot-concept@u-boot.org DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1775338304; bh=BBvnOvdlOMMORU3UYO8mO6r9LU+j64AoTqSua1vZKuQ=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=OPL6+t0NAsKdSvpxuTI9t2j6nuldSm2yIzAx/LWS2US04XxnzwV9ob9QUP9MjvD89 50+3qDuhKYOV6bj804TMnoLwJKPGQPsLYy5KsEDISAGWLj5OzzP2HTC1p0XzPeN4g2 EUmmi+6VVikZd5Iw/INNQlhLF2OqSN7ZU8RBIjeGdzkLZW0ThweXUQgOvthw5tYj65 H4c9vMla0Cu0ciKkb7CO7KEikklr6yzjo0KPvBn6mluQM1H0eaGKVLwxVUAcLpjJhA bJNCEuPIHAduBKwHeN/bV8Yj46dctX7yKMEiaNtLQHEoTPnfUS/OMVkQ+UvuTSIJ1S Mg4BlmyruExGA== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 5F1B96A382 for ; Sat, 4 Apr 2026 15:31:44 -0600 (MDT) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10024) with ESMTP id UnarMr17zyUj for ; Sat, 4 Apr 2026 15:31:44 -0600 (MDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1775338304; bh=BBvnOvdlOMMORU3UYO8mO6r9LU+j64AoTqSua1vZKuQ=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=OPL6+t0NAsKdSvpxuTI9t2j6nuldSm2yIzAx/LWS2US04XxnzwV9ob9QUP9MjvD89 50+3qDuhKYOV6bj804TMnoLwJKPGQPsLYy5KsEDISAGWLj5OzzP2HTC1p0XzPeN4g2 EUmmi+6VVikZd5Iw/INNQlhLF2OqSN7ZU8RBIjeGdzkLZW0ThweXUQgOvthw5tYj65 H4c9vMla0Cu0ciKkb7CO7KEikklr6yzjo0KPvBn6mluQM1H0eaGKVLwxVUAcLpjJhA bJNCEuPIHAduBKwHeN/bV8Yj46dctX7yKMEiaNtLQHEoTPnfUS/OMVkQ+UvuTSIJ1S Mg4BlmyruExGA== Received: from mail.u-boot.org (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 4B8286A375 for ; Sat, 4 Apr 2026 15:31:44 -0600 (MDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1775338301; bh=+A7kMRpr/e1dfAIitRKcuV0bnZGuBGRYZizH8fRZDIM=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=nThro970wugsbaR0nKQqk6VkbRsNuLQWi1ZVZePjgo/3/cx1D256HNvK5x/NTCH6u V4isnRGjCghAZmSsLXSXIipVWXZ02k99OGGaIgTOJIma8tLyBHtXmjOv4WdpHf+cAC U4yGfHTqrm47W19pNbJVn65Im++mm0Fd+6jFa+XOtSO+t8Tx9/LLHbS5SPiUFyHRon 9dJmnNCZQykPc2YADn6xzFRYCNf8R7FNKQMIruFvtimiqOqrl+Hof4SFf+HyQC0EmJ Gw7iuru6GMZYsRm/5dUtkFuDiannF6IV60CPrRUEtqafECoqhOQsX/eu1wIBYdlWbv T7yfXBemgJ4fw== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id EA8E26A369; Sat, 4 Apr 2026 15:31:41 -0600 (MDT) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10026) with ESMTP id B8LMGm0M5AO9; Sat, 4 Apr 2026 15:31:41 -0600 (MDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1775338297; bh=NsydEK3mnjiQetXngqacDBASsFPHrs0UpsCMaNvDxVg=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=cqKBSMKQcVK5NQi7EnvzrQ57KLnd6MNCgNu6kE7MAlKMQIl1ESwPybePhm336BTPV IfZAfMyz+sIWql0kspgbvu8cOqL0Y2A3mktyIS9dz+5V+Rctago7hggdSnbtiDj94g D+aJg1KcZ8UazdfGLZ+WkK8fhQL2sjR02URdQwLvC/19au9cthc8aQN37KZcK8c368 UQNvPlWq4mGgoSIKfeVwhgVO5OE8sS7GX0O992GEIUL8BqyuqpTBP7zwAFoQkLMZWL bnFO3BS/YTlMsjINaj/tUkHD7GjGaeinXKBIt9UZAx9wQtDaGm5T5CgQYiTiKFXwcS etAT26kEmxfCA== Received: from u-boot.org (unknown [73.34.74.121]) by mail.u-boot.org (Postfix) with ESMTPSA id 776C16869D; Sat, 4 Apr 2026 15:31:37 -0600 (MDT) From: Simon Glass To: U-Boot Concept Date: Sat, 4 Apr 2026 15:28:49 -0600 Message-ID: <20260404213020.372253-14-sjg@u-boot.org> X-Mailer: git-send-email 2.43.0 In-Reply-To: <20260404213020.372253-1-sjg@u-boot.org> References: <20260404213020.372253-1-sjg@u-boot.org> MIME-Version: 1.0 Message-ID-Hash: Q7WKIKU74WCGP62GCY6C5SYINNNTGR4S X-Message-ID-Hash: Q7WKIKU74WCGP62GCY6C5SYINNNTGR4S X-MailFrom: sjg@u-boot.org X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Simon Glass X-Mailman-Version: 3.3.10 Precedence: list Subject: [Concept] [PATCH 13/37] u_boot_pylib: Extract Claude agent utilities from pickman List-Id: Discussion and patches related to U-Boot Concept Archived-At: List-Archive: List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Simon Glass The Claude Agent SDK helper functions (check_available(), run_agent_collect(), etc.) are useful beyond just pickman. Move them to a shared u_boot_pylib.claude module so that other tools like patman can also use Claude agents without duplicating the code. Update pickman/agent.py to import from the shared module instead of defining its own copies. Add unit tests for the new module covering availability detection, output collection and error handling. Signed-off-by: Simon Glass --- tools/pickman/agent.py | 58 +++------------- tools/pickman/ftest.py | 43 ++++++------ tools/u_boot_pylib/__init__.py | 4 +- tools/u_boot_pylib/__main__.py | 4 +- tools/u_boot_pylib/claude.py | 64 +++++++++++++++++ tools/u_boot_pylib/test_claude.py | 111 ++++++++++++++++++++++++++++++ 6 files changed, 211 insertions(+), 73 deletions(-) create mode 100644 tools/u_boot_pylib/claude.py create mode 100644 tools/u_boot_pylib/test_claude.py diff --git a/tools/pickman/agent.py b/tools/pickman/agent.py index 7e482a7b8ee..f20e0f2fda5 100644 --- a/tools/pickman/agent.py +++ b/tools/pickman/agent.py @@ -27,8 +27,14 @@ SIGNAL_SUCCESS = 'success' SIGNAL_APPLIED = 'already_applied' SIGNAL_CONFLICT = 'conflict' -# Maximum buffer size for agent responses -MAX_BUFFER_SIZE = 10 * 1024 * 1024 # 10MB +# Import common Claude agent utilities from shared module +from u_boot_pylib.claude import ( + AGENT_AVAILABLE, MAX_BUFFER_SIZE, check_available, run_agent_collect, +) + +ClaudeAgentOptions = None +if AGENT_AVAILABLE: + from u_boot_pylib.claude import ClaudeAgentOptions # pylint: disable=C0412 # Commits that need special handling (regenerate instead of cherry-pick) # These run savedefconfig on all boards and depend on target branch @@ -37,54 +43,6 @@ QCONFIG_SUBJECTS = [ 'configs: Resync with savedefconfig', ] -# Check if claude_agent_sdk is available -try: - from claude_agent_sdk import query, ClaudeAgentOptions - AGENT_AVAILABLE = True -except ImportError: - AGENT_AVAILABLE = False - - -def check_available(): - """Check if the Claude Agent SDK is available - - Returns: - bool: True if available, False otherwise - """ - if not AGENT_AVAILABLE: - tout.error('Claude Agent SDK not available') - tout.error('Install with: pip install claude-agent-sdk') - return False - return True - - -async def run_agent_collect(prompt, options): - """Run a Claude agent and collect its conversation log - - Sends the prompt to a Claude agent, streams output to stdout and - collects all text blocks into a conversation log. - - Args: - prompt (str): The prompt to send to the agent - options (ClaudeAgentOptions): Agent configuration - - Returns: - tuple: (success, conversation_log) where success is bool and - conversation_log is the agent's output text - """ - conversation_log = [] - try: - async for message in query(prompt=prompt, options=options): - if hasattr(message, 'content'): - for block in message.content: - if hasattr(block, 'text'): - print(block.text) - conversation_log.append(block.text) - return True, '\n\n'.join(conversation_log) - except (RuntimeError, ValueError, OSError) as exc: - tout.error(f'Agent failed: {exc}') - return False, '\n\n'.join(conversation_log) - def is_qconfig_commit(subject): """Check if a commit subject indicates a qconfig resync commit diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 261ca4cd2d5..e5c1ceb1359 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -3121,7 +3121,8 @@ class TestRunAgentCollect(unittest.TestCase): async def fake_query(**kwargs): yield msg - with mock.patch.object(agent, 'query', fake_query, create=True): + with mock.patch('u_boot_pylib.claude.query', fake_query, + create=True): with terminal.capture(): opts = mock.MagicMock() success, log = asyncio.run( @@ -3141,7 +3142,8 @@ class TestRunAgentCollect(unittest.TestCase): yield msg raise RuntimeError('agent crashed') - with mock.patch.object(agent, 'query', fake_query, create=True): + with mock.patch('u_boot_pylib.claude.query', fake_query, + create=True): with terminal.capture(): opts = mock.MagicMock() success, log = asyncio.run( @@ -3157,7 +3159,8 @@ class TestRunAgentCollect(unittest.TestCase): async def fake_query(**kwargs): yield msg - with mock.patch.object(agent, 'query', fake_query, create=True): + with mock.patch('u_boot_pylib.claude.query', fake_query, + create=True): with terminal.capture(): opts = mock.MagicMock() success, log = asyncio.run( @@ -6610,14 +6613,14 @@ class TestResolveSubtreeConflicts(unittest.TestCase): """Test successful conflict resolution.""" mock_collect = mock.AsyncMock(return_value=(True, 'resolved')) with terminal.capture(): - with mock.patch.object(agent, 'AGENT_AVAILABLE', True): - with mock.patch.object(agent, 'run_agent_collect', - mock_collect): - with mock.patch.object(agent, 'ClaudeAgentOptions', - create=True): - success, log = agent.resolve_subtree_conflicts( - 'dts', 'v6.15-dts', 'dts/upstream', - '/tmp/test') + with mock.patch('u_boot_pylib.claude.AGENT_AVAILABLE', True), \ + mock.patch.object(agent, 'run_agent_collect', + mock_collect), \ + mock.patch.object(agent, 'ClaudeAgentOptions', + create=True): + success, log = agent.resolve_subtree_conflicts( + 'dts', 'v6.15-dts', 'dts/upstream', + '/tmp/test') self.assertTrue(success) self.assertEqual(log, 'resolved') @@ -6625,20 +6628,20 @@ class TestResolveSubtreeConflicts(unittest.TestCase): """Test failed conflict resolution.""" mock_collect = mock.AsyncMock(return_value=(False, 'failed')) with terminal.capture(): - with mock.patch.object(agent, 'AGENT_AVAILABLE', True): - with mock.patch.object(agent, 'run_agent_collect', - mock_collect): - with mock.patch.object(agent, 'ClaudeAgentOptions', - create=True): - success, log = agent.resolve_subtree_conflicts( - 'dts', 'v6.15-dts', 'dts/upstream', - '/tmp/test') + with mock.patch('u_boot_pylib.claude.AGENT_AVAILABLE', True), \ + mock.patch.object(agent, 'run_agent_collect', + mock_collect), \ + mock.patch.object(agent, 'ClaudeAgentOptions', + create=True): + success, log = agent.resolve_subtree_conflicts( + 'dts', 'v6.15-dts', 'dts/upstream', + '/tmp/test') self.assertFalse(success) def test_sdk_unavailable(self): """Test returns failure when SDK is not available.""" with terminal.capture(): - with mock.patch.object(agent, 'AGENT_AVAILABLE', False): + with mock.patch('u_boot_pylib.claude.AGENT_AVAILABLE', False): success, log = agent.resolve_subtree_conflicts( 'dts', 'v6.15-dts', 'dts/upstream', '/tmp/test') self.assertFalse(success) diff --git a/tools/u_boot_pylib/__init__.py b/tools/u_boot_pylib/__init__.py index 807a62e0743..c176e332a51 100644 --- a/tools/u_boot_pylib/__init__.py +++ b/tools/u_boot_pylib/__init__.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: GPL-2.0+ -__all__ = ['command', 'cros_subprocess', 'gitutil', 'terminal', 'test_util', - 'tools', 'tout'] +__all__ = ['claude', 'command', 'cros_subprocess', 'gitutil', 'terminal', + 'test_util', 'tools', 'tout'] diff --git a/tools/u_boot_pylib/__main__.py b/tools/u_boot_pylib/__main__.py index 6b9f4f3d950..5687f9b51a5 100755 --- a/tools/u_boot_pylib/__main__.py +++ b/tools/u_boot_pylib/__main__.py @@ -28,12 +28,14 @@ def run_tests(): help='Verbose output') args = parser.parse_args() + from u_boot_pylib import test_claude + to_run = args.testname if args.testname not in [None, 'test'] else None result = test_util.run_test_suites( 'u_boot_pylib', False, args.verbose, False, False, None, to_run, None, ['u_boot_pylib.terminal', 'u_boot_pylib.gitutil', - cros_subprocess.TestSubprocess]) + cros_subprocess.TestSubprocess, test_claude.TestClaude]) sys.exit(0 if result.wasSuccessful() else 1) diff --git a/tools/u_boot_pylib/claude.py b/tools/u_boot_pylib/claude.py new file mode 100644 index 00000000000..29dff1d1d4b --- /dev/null +++ b/tools/u_boot_pylib/claude.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: GPL-2.0+ +# +# Copyright 2025 Canonical Ltd. +# Written by Simon Glass +# + +"""Common Claude Agent SDK utilities. + +Provides shared functions for running Claude agents across tools that need +AI assistance (e.g. pickman, patman review). +""" + +from u_boot_pylib import tout + +# Maximum buffer size for agent responses +MAX_BUFFER_SIZE = 10 * 1024 * 1024 # 10MB + +# Check if claude_agent_sdk is available +try: + from claude_agent_sdk import query, ClaudeAgentOptions + AGENT_AVAILABLE = True +except ImportError: + AGENT_AVAILABLE = False + + +def check_available(): + """Check if the Claude Agent SDK is available + + Returns: + bool: True if available, False otherwise + """ + if not AGENT_AVAILABLE: + tout.error('Claude Agent SDK not available') + tout.error('Install with: pip install claude-agent-sdk') + return False + return True + + +async def run_agent_collect(prompt, options): + """Run a Claude agent and collect its conversation log + + Sends the prompt to a Claude agent, streams output to stdout and + collects all text blocks into a conversation log. + + Args: + prompt (str): The prompt to send to the agent + options (ClaudeAgentOptions): Agent configuration + + Returns: + tuple: (success, conversation_log) where success is bool and + conversation_log is the agent's output text + """ + conversation_log = [] + try: + async for message in query(prompt=prompt, options=options): + if hasattr(message, 'content'): + for block in message.content: + if hasattr(block, 'text'): + print(block.text) + conversation_log.append(block.text) + return True, '\n\n'.join(conversation_log) + except (RuntimeError, ValueError, OSError) as exc: + tout.error(f'Agent failed: {exc}') + return False, '\n\n'.join(conversation_log) diff --git a/tools/u_boot_pylib/test_claude.py b/tools/u_boot_pylib/test_claude.py new file mode 100644 index 00000000000..c564dddb70e --- /dev/null +++ b/tools/u_boot_pylib/test_claude.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: GPL-2.0+ +# +# Copyright 2025 Canonical Ltd. +# + +"""Tests for the Claude Agent SDK utilities module.""" + +import asyncio +import unittest +from unittest.mock import MagicMock + +from u_boot_pylib import claude +from u_boot_pylib import terminal + + +class TestClaude(unittest.TestCase): + """Tests for u_boot_pylib.claude""" + + def test_check_available_when_sdk_missing(self): + """check_available() returns False when SDK is not installed""" + if not claude.AGENT_AVAILABLE: + with terminal.capture(): + self.assertFalse(claude.check_available()) + + def test_check_available_when_sdk_present(self): + """check_available() returns True when SDK is installed""" + old = claude.AGENT_AVAILABLE + try: + claude.AGENT_AVAILABLE = True + self.assertTrue(claude.check_available()) + finally: + claude.AGENT_AVAILABLE = old + + def test_max_buffer_size(self): + """MAX_BUFFER_SIZE is defined and reasonable""" + self.assertEqual(claude.MAX_BUFFER_SIZE, 10 * 1024 * 1024) + + def _setup_claude_with_mock_query(self, mock_query): + """Inject a mock query function into the claude module""" + claude.query = mock_query + + def test_run_agent_collect_success(self): + """run_agent_collect() collects text from agent messages""" + block1 = MagicMock() + block1.text = 'Hello' + msg1 = MagicMock() + msg1.content = [block1] + + block2 = MagicMock() + block2.text = 'World' + msg2 = MagicMock() + msg2.content = [block2] + + # pylint: disable=W0613 + async def mock_query(**kwargs): + for msg in [msg1, msg2]: + yield msg + + self._setup_claude_with_mock_query(mock_query) + loop = asyncio.new_event_loop() + with terminal.capture(): + success, log = loop.run_until_complete( + claude.run_agent_collect('test prompt', MagicMock())) + loop.close() + + self.assertTrue(success) + self.assertIn('Hello', log) + self.assertIn('World', log) + + def test_run_agent_collect_handles_error(self): + """run_agent_collect() returns False on agent failure""" + # pylint: disable=W0613 + async def mock_query(**kwargs): + raise RuntimeError('Agent crashed') + yield # pylint: disable=W0101 + + self._setup_claude_with_mock_query(mock_query) + loop = asyncio.new_event_loop() + with terminal.capture(): + success, _ = loop.run_until_complete( + claude.run_agent_collect('test prompt', MagicMock())) + loop.close() + + self.assertFalse(success) + + def test_run_agent_collect_skips_non_text_blocks(self): + """run_agent_collect() ignores blocks without text attribute""" + text_block = MagicMock() + text_block.text = 'Real text' + tool_block = MagicMock(spec=[]) # No text attribute + + msg = MagicMock() + msg.content = [tool_block, text_block] + + # pylint: disable=W0613 + async def mock_query(**kwargs): + yield msg + + self._setup_claude_with_mock_query(mock_query) + loop = asyncio.new_event_loop() + with terminal.capture(): + success, log = loop.run_until_complete( + claude.run_agent_collect('test prompt', MagicMock())) + loop.close() + + self.assertTrue(success) + self.assertIn('Real text', log) + + +if __name__ == '__main__': + unittest.main()