From 4c6babcc72714534973fb2d205b0f40712b6d091 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:51:48 -0700 Subject: [PATCH 1/4] fix(agents): inject reasoning_content sequentially to avoid count mismatch The FileBlockSupportFormatter._format method compared assistant message counts before and after parent formatting to inject reasoning_content. The parent formatter drops assistant messages that only contain thinking blocks (no content or tool_calls), so the counts never matched and reasoning_content was always skipped with a warning. This changes the injection to work sequentially: collect reasoning values from input assistant messages in order, then assign them to output assistant messages by index. This handles the count difference from dropped thinking-only messages. Fixes #1532 Co-Authored-By: Claude Opus 4.6 --- src/copaw/agents/model_factory.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/copaw/agents/model_factory.py b/src/copaw/agents/model_factory.py index 658e5c8f7..716bba8eb 100644 --- a/src/copaw/agents/model_factory.py +++ b/src/copaw/agents/model_factory.py @@ -160,26 +160,23 @@ async def _format(self, msgs): tc["extra_content"] = ec if reasoning_contents: - in_assistant = [m for m in msgs if m.role == "assistant"] + # Collect reasoning values in input order for assistant + # messages that have reasoning_content. The parent + # formatter may drop assistant messages that contain + # only thinking blocks (no content/tool_calls), so + # the output count can be smaller than the input count. + # Inject sequentially to avoid the count-mismatch skip. + reasoning_values = [ + reasoning_contents[id(m)] + for m in msgs + if m.role == "assistant" and id(m) in reasoning_contents + ] out_assistant = [ m for m in messages if m.get("role") == "assistant" ] - if len(in_assistant) != len(out_assistant): - logger.warning( - "Assistant message count mismatch after formatting " - "(%d before, %d after). " - "Skipping reasoning_content injection.", - len(in_assistant), - len(out_assistant), - ) - else: - for in_msg, out_msg in zip( - in_assistant, - out_assistant, - ): - reasoning = reasoning_contents.get(id(in_msg)) - if reasoning: - out_msg["reasoning_content"] = reasoning + for i, out_msg in enumerate(out_assistant): + if i < len(reasoning_values): + out_msg["reasoning_content"] = reasoning_values[i] return _strip_top_level_message_name(messages) From ba7119df46dc1235f67240399839717071729473 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:08:18 -0700 Subject: [PATCH 2/4] fix(agents): align reasoning_content injection with surviving messages Predict which assistant messages survive the parent formatter (drop thinking-only messages without tool_calls) and only inject reasoning_content into the correctly aligned output messages. Re-introduce the count mismatch safeguard as a fallback. --- src/copaw/agents/model_factory.py | 45 +++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/src/copaw/agents/model_factory.py b/src/copaw/agents/model_factory.py index 716bba8eb..e8c141553 100644 --- a/src/copaw/agents/model_factory.py +++ b/src/copaw/agents/model_factory.py @@ -160,23 +160,40 @@ async def _format(self, msgs): tc["extra_content"] = ec if reasoning_contents: - # Collect reasoning values in input order for assistant - # messages that have reasoning_content. The parent - # formatter may drop assistant messages that contain - # only thinking blocks (no content/tool_calls), so - # the output count can be smaller than the input count. - # Inject sequentially to avoid the count-mismatch skip. - reasoning_values = [ - reasoning_contents[id(m)] - for m in msgs - if m.role == "assistant" and id(m) in reasoning_contents - ] + # Build a list of reasoning values aligned with surviving + # assistant messages. The parent formatter drops + # thinking-only messages (no content/tool_calls), so we + # predict survivors and collect reasoning only for those. + aligned_reasoning = [] + for m in (msg for msg in msgs if msg.role == "assistant"): + is_thinking_only = ( + isinstance(m.content, list) + and m.content + and all(b.get("type") == "thinking" for b in m.content) + ) + if not ( + is_thinking_only and not getattr(m, "tool_calls", None) + ): + aligned_reasoning.append( + reasoning_contents.get(id(m)), + ) + out_assistant = [ m for m in messages if m.get("role") == "assistant" ] - for i, out_msg in enumerate(out_assistant): - if i < len(reasoning_values): - out_msg["reasoning_content"] = reasoning_values[i] + + if len(aligned_reasoning) != len(out_assistant): + logger.warning( + "Assistant message count mismatch after formatting " + "(%d expected survivors, %d actual). " + "Skipping reasoning_content injection.", + len(aligned_reasoning), + len(out_assistant), + ) + else: + for i, out_msg in enumerate(out_assistant): + if aligned_reasoning[i]: + out_msg["reasoning_content"] = aligned_reasoning[i] return _strip_top_level_message_name(messages) From fe8d8f54a9f51107b19f890649061b052a2a52ae Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:03:44 -0700 Subject: [PATCH 3/4] fix(agents): pass pre-commit checks for reasoning_content injection - Replace X | Y union syntax with Union[str, List[dict]] for mypy v1.7 compatibility (mypy defaults to Python 3.9 syntax rules) - Add pylint disable-next for too-many-statements on factory function Co-Authored-By: Claude Opus 4.6 --- src/copaw/agents/model_factory.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/copaw/agents/model_factory.py b/src/copaw/agents/model_factory.py index e8c141553..0824414d3 100644 --- a/src/copaw/agents/model_factory.py +++ b/src/copaw/agents/model_factory.py @@ -11,7 +11,7 @@ import logging -from typing import Sequence, Tuple, Type, Any +from typing import List, Sequence, Tuple, Type, Any, Union from functools import wraps from agentscope.formatter import FormatterBase, OpenAIChatFormatter @@ -101,6 +101,7 @@ def _get_formatter_for_chat_model( ) +# pylint: disable-next=too-many-statements def _create_file_block_support_formatter( base_formatter_class: Type[FormatterBase], ) -> Type[FormatterBase]: @@ -199,7 +200,7 @@ async def _format(self, msgs): @staticmethod def convert_tool_result_to_string( - output: str | list[dict], + output: Union[str, List[dict]], ) -> tuple[str, Sequence[Tuple[str, dict]]]: """Extend parent class to support file blocks. From ea44e2a3c1998f31cea6d2d0707b7161fe2cf2b0 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:11:13 -0700 Subject: [PATCH 4/4] fix(agents): simplify thinking-only guard per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove redundant `not getattr(m, "tool_calls", None)` check — Msg has no tool_calls attribute so the condition always evaluated to True. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/copaw/agents/model_factory.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/copaw/agents/model_factory.py b/src/copaw/agents/model_factory.py index 0824414d3..a67628e59 100644 --- a/src/copaw/agents/model_factory.py +++ b/src/copaw/agents/model_factory.py @@ -172,9 +172,7 @@ async def _format(self, msgs): and m.content and all(b.get("type") == "thinking" for b in m.content) ) - if not ( - is_thinking_only and not getattr(m, "tool_calls", None) - ): + if not is_thinking_only: aligned_reasoning.append( reasoning_contents.get(id(m)), )