From patchwork Sun Feb 22 15:42: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: 1930 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=1771775055; bh=uOtDpFMQU2TR1ZSFWv7f5zRnpJCDl5Zq6IlyOZt/bxY=; 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=rfRR+CUbKbSjt4bCuZ4Oo4Kos6lRc3S7MLUdyLUAITdGiIx3Ar1uyZzJf27JIaMCe pZ0zdA9IuJVzwCTiWuwlRKLZbh9Iqro7OBGVpNP29y6kV/WsvlVKTm6+hQXebMiiyH r8Ix1cq9jNifsV9dQDGuvXL7Eb9Xtn5MPTohYkZh+pBCV+6tz/+vi0Z1Vcw401nGUP 22KjN55/opZqkTrgT7NoZyXSuXp+B2FrQu5P3OUSuQ3yLusvN27TFqztC2zvCYYT3h gGyzl2UUg4xzW8DLqFsuFDewGXJ3RfGD8Hev3z7F3GN9dND47/KBTLFri2izhoR//J i1d0AqX5NnQqA== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 3C57169C5E for ; Sun, 22 Feb 2026 08:44:15 -0700 (MST) 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 BFIzruSNUWig for ; Sun, 22 Feb 2026 08:44:15 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1771775055; bh=uOtDpFMQU2TR1ZSFWv7f5zRnpJCDl5Zq6IlyOZt/bxY=; 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=rfRR+CUbKbSjt4bCuZ4Oo4Kos6lRc3S7MLUdyLUAITdGiIx3Ar1uyZzJf27JIaMCe pZ0zdA9IuJVzwCTiWuwlRKLZbh9Iqro7OBGVpNP29y6kV/WsvlVKTm6+hQXebMiiyH r8Ix1cq9jNifsV9dQDGuvXL7Eb9Xtn5MPTohYkZh+pBCV+6tz/+vi0Z1Vcw401nGUP 22KjN55/opZqkTrgT7NoZyXSuXp+B2FrQu5P3OUSuQ3yLusvN27TFqztC2zvCYYT3h gGyzl2UUg4xzW8DLqFsuFDewGXJ3RfGD8Hev3z7F3GN9dND47/KBTLFri2izhoR//J i1d0AqX5NnQqA== Received: from mail.u-boot.org (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 2887769D3F for ; Sun, 22 Feb 2026 08:44:15 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1771775052; bh=MmzpRytZLqKPbiDBv6ZIIx71ZHPdTZz2mo1QK0R7DUc=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=JCzXtsNEz93HYGwLT0PJ6GRc+Fm1W2JJTGNvtiDO0EMG4uf9oDxwH+QkMVJ+l4rzG Wo8Xf1hJmXTZNP8aMHP/B36t9jMQZT81MO/KUIZe6a4UmNUHD/nJoN2WJ+6iad7kW8 PTOClepGyGCpDBoUesQXg75IL6VochAqPx9iIJTKoqDCwrMpIzOJd4FkOHFwx8Vnaq MzGRbbxdGqcKJKTeANLnCG5YTbK258nzdjDI+RJsdge8mZjvDo4QPNpOiT+AynIvSm gvh/Mjf6ezLEASWDwmPak7DA24D3OXcXM7YeKCuN3WyXMrBz/8stbfMo0J5qN6F/xB Wp9KGyAqo6bDA== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 52A4269D53; Sun, 22 Feb 2026 08:44:12 -0700 (MST) 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 rlSpG8RiiiOK; Sun, 22 Feb 2026 08:44:12 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1771775044; bh=KMGs2zsHh5vqTvkqegdVJ7tZLlQ/d/NiXu3qSGJiREI=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=lVrPXIodesdLHBnz0exNVjFSur5GK2FLXME2OcO5T58zHiFETo0sGeH7pARi8wdaX qdPm7EeH/KgL8coOn2qoHtFCaDxQpymmfN/xiD0HdhaIrE3nYnD2uwsFtIWuLRJbOg 6Qg5MeYXUrJj0taPesgk/29044Gqjyai73mXF512wv/JuR6V6TtCwOmkNk5ALUwu0R 7JaTq3eINPsF82ey4XcnN92Camkb8tyoOweGyRd70BEoqyjGYAMXm+iDlAnDLoW8Wl 2sRPUDnEsbZAsvYRwDJkJ/hdowV3qTktSaKS3sCP/QgEovjLcpqpsqFOsJl01EWJng U6OmfSoynsm4g== Received: from u-boot.org (unknown [73.34.74.121]) by mail.u-boot.org (Postfix) with ESMTPSA id 35DA369D3F; Sun, 22 Feb 2026 08:44:04 -0700 (MST) From: Simon Glass To: U-Boot Concept Date: Sun, 22 Feb 2026 08:42:49 -0700 Message-ID: <20260222154303.2851319-10-sjg@u-boot.org> X-Mailer: git-send-email 2.43.0 In-Reply-To: <20260222154303.2851319-1-sjg@u-boot.org> References: <20260222154303.2851319-1-sjg@u-boot.org> MIME-Version: 1.0 Message-ID-Hash: LQVEZDC4IJLMJFSFRL7BNXCFUUT7FPPV X-Message-ID-Hash: LQVEZDC4IJLMJFSFRL7BNXCFUUT7FPPV 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 , "Claude Opus 4 . 6" X-Mailman-Version: 3.3.10 Precedence: list Subject: [Concept] [PATCH 09/16] pickman: Create a function to run an agent 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 agent-message-streaming pattern (async iteration, text extraction and conversation-log collection) is duplicated in run() and run_review_agent() Extract it into a shared run_agent_collect() helper. Co-developed-by: Claude Opus 4.6 Signed-off-by: Simon Glass --- tools/pickman/agent.py | 55 ++++++++++++++++++++----------------- tools/pickman/ftest.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 25 deletions(-) diff --git a/tools/pickman/agent.py b/tools/pickman/agent.py index 85f8efee1df..63952c1c005 100644 --- a/tools/pickman/agent.py +++ b/tools/pickman/agent.py @@ -55,6 +55,34 @@ def check_available(): 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 @@ -228,19 +256,7 @@ this means the series was already applied via a different path. In this case: tout.info(f'Starting Claude agent to cherry-pick {len(commits)} commits...') tout.info('') - conversation_log = [] - try: - async for message in query(prompt=prompt, options=options): - # Print agent output and capture it - 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) + return await run_agent_collect(prompt, options) def read_signal_file(repo_path=None): @@ -492,18 +508,7 @@ async def run_review_agent(mr_iid, branch_name, comments, remote, tout.info(f'Starting Claude agent to {task_desc}...') tout.info('') - 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) + return await run_agent_collect(prompt, options) # pylint: disable=too-many-arguments diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index de6bce40614..42ce05962e2 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -6,6 +6,7 @@ # pylint: disable=too-many-lines """Tests for pickman.""" +import asyncio import argparse import os import shutil @@ -2971,6 +2972,67 @@ class TestExecuteApply(unittest.TestCase): dbs.close() +class TestRunAgentCollect(unittest.TestCase): + """Tests for run_agent_collect function.""" + + def test_success(self): + """Test successful agent run collects text blocks.""" + block1 = mock.MagicMock() + block1.text = 'hello' + block2 = mock.MagicMock() + block2.text = 'world' + msg = mock.MagicMock() + msg.content = [block1, block2] + + async def fake_query(**kwargs): + yield msg + + with mock.patch.object(agent, 'query', fake_query, create=True): + with terminal.capture(): + opts = mock.MagicMock() + success, log = asyncio.run( + agent.run_agent_collect('prompt', opts)) + + self.assertTrue(success) + self.assertEqual(log, 'hello\n\nworld') + + def test_failure(self): + """Test agent failure returns False with partial log.""" + block = mock.MagicMock() + block.text = 'partial' + msg = mock.MagicMock() + msg.content = [block] + + async def fake_query(**kwargs): + yield msg + raise RuntimeError('agent crashed') + + with mock.patch.object(agent, 'query', fake_query, create=True): + with terminal.capture(): + opts = mock.MagicMock() + success, log = asyncio.run( + agent.run_agent_collect('prompt', opts)) + + self.assertFalse(success) + self.assertEqual(log, 'partial') + + def test_no_content(self): + """Test messages without content are skipped.""" + msg = mock.MagicMock(spec=[]) + + async def fake_query(**kwargs): + yield msg + + with mock.patch.object(agent, 'query', fake_query, create=True): + with terminal.capture(): + opts = mock.MagicMock() + success, log = asyncio.run( + agent.run_agent_collect('prompt', opts)) + + self.assertTrue(success) + self.assertEqual(log, '') + + class TestSignalFile(unittest.TestCase): """Tests for signal file handling."""