From b70be5568190d12f4a8e214e2881ae70ad35eebf Mon Sep 17 00:00:00 2001 From: Elliot Slusky <44592435+ElliotSlusky@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:51:52 -0700 Subject: [PATCH] fix(openhands): handle none content in token estimates (#612) Closes #607. Assistant tool-call turns can carry content=None, which crashed token estimation (len(m.content)) and think-tag stripping. Normalize with 'content or ""', route native OpenHands truncation through the shared estimate_prompt_tokens, and add tests for the estimator, the truncation helper, and an end-to-end tool-call run with None content. --- src/openjarvis/agents/native_openhands.py | 10 ++--- src/openjarvis/engine/_base.py | 2 +- tests/agents/test_native_openhands.py | 47 ++++++++++++++++++++++- tests/engine/test_base.py | 17 ++++++++ 4 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 tests/engine/test_base.py diff --git a/src/openjarvis/agents/native_openhands.py b/src/openjarvis/agents/native_openhands.py index d20f2a99..5358fa3a 100644 --- a/src/openjarvis/agents/native_openhands.py +++ b/src/openjarvis/agents/native_openhands.py @@ -19,6 +19,7 @@ from openjarvis.agents.prompt_loader import ( from openjarvis.core.events import EventBus from openjarvis.core.registry import AgentRegistry from openjarvis.core.types import Message, Role, ToolCall, ToolResult +from openjarvis.engine._base import estimate_prompt_tokens from openjarvis.engine._stubs import InferenceEngine from openjarvis.tools._stubs import BaseTool, build_tool_descriptions @@ -116,8 +117,7 @@ class NativeOpenHandsAgent(ToolUsingAgent): max_prompt_tokens: int = 3000, ) -> list[Message]: """Truncate messages if estimated token count exceeds limit.""" - total_chars = sum(len(m.content) for m in messages) - estimated_tokens = total_chars // 4 + estimated_tokens = estimate_prompt_tokens(messages) if estimated_tokens <= max_prompt_tokens: return messages # Find the last user message and truncate its content @@ -125,7 +125,7 @@ class NativeOpenHandsAgent(ToolUsingAgent): if messages[i].role == Role.USER: excess_tokens = estimated_tokens - max_prompt_tokens excess_chars = excess_tokens * 4 - original = messages[i].content + original = messages[i].content or "" if len(original) > excess_chars + 200: truncated = original[: len(original) - excess_chars] messages[i] = Message( @@ -258,7 +258,7 @@ class NativeOpenHandsAgent(ToolUsingAgent): # still emitted before re-raising. self._emit_turn_end(turns=1, error=True) raise - content = self._strip_think_tags(result.get("content", "")) + content = self._strip_think_tags(result.get("content") or "") usage = result.get("usage", {}) self._emit_turn_end(turns=1) return AgentResult( @@ -315,7 +315,7 @@ class NativeOpenHandsAgent(ToolUsingAgent): for k in total_usage: total_usage[k] += usage.get(k, 0) - content = result.get("content", "") + content = result.get("content") or "" # Strip think tags so they don't interfere with parsing content = self._strip_think_tags(content) last_content = content diff --git a/src/openjarvis/engine/_base.py b/src/openjarvis/engine/_base.py index abbad3e7..97f9a23e 100644 --- a/src/openjarvis/engine/_base.py +++ b/src/openjarvis/engine/_base.py @@ -55,7 +55,7 @@ def estimate_prompt_tokens(messages: Sequence[Message]) -> int: Uses ~4 characters per token (standard BPE average for English) plus a small per-message overhead for role markers and separators. """ - total_chars = sum(len(m.content) for m in messages) + total_chars = sum(len(m.content or "") for m in messages) # ~4 tokens overhead per message for role markers / separators overhead = len(messages) * 4 return max(1, total_chars // 4 + overhead) diff --git a/tests/agents/test_native_openhands.py b/tests/agents/test_native_openhands.py index f42113b7..221f8bfc 100644 --- a/tests/agents/test_native_openhands.py +++ b/tests/agents/test_native_openhands.py @@ -8,7 +8,7 @@ from openjarvis.agents._stubs import AgentContext from openjarvis.agents.native_openhands import NativeOpenHandsAgent from openjarvis.core.events import EventBus, EventType from openjarvis.core.registry import AgentRegistry -from openjarvis.core.types import Conversation, Message, Role, ToolResult +from openjarvis.core.types import Conversation, Message, Role, ToolCall, ToolResult from openjarvis.tools._stubs import BaseTool, ToolSpec # --------------------------------------------------------------------------- @@ -118,6 +118,51 @@ class TestNativeOpenHandsRegistration: class TestNativeOpenHandsAgent: + def test_truncate_handles_none_content_tool_call_turn(self): + """Tool-call assistant turns may carry content=None.""" + engine = MagicMock() + engine.engine_id = "mock" + agent = NativeOpenHandsAgent(engine, "test-model") + messages = [ + Message(role=Role.USER, content="hi"), + Message( + role=Role.ASSISTANT, + content=None, # type: ignore[arg-type] + tool_calls=[ToolCall(id="call_1", name="calculator", arguments="{}")], + ), + ] + + assert agent._truncate_if_needed(messages) == messages + + def test_native_tool_call_with_none_content_does_not_crash(self): + """Native tool-call responses may omit assistant text content.""" + engine = MagicMock() + engine.engine_id = "mock" + engine.generate.side_effect = [ + _engine_response( + None, + tool_calls=[ + { + "id": "call_1", + "name": "calculator", + "arguments": '{"expression": "2+2"}', + } + ], + ), + _engine_response("The result is 4."), + ] + agent = NativeOpenHandsAgent( + engine, + "test-model", + tools=[_CalculatorStub()], + ) + + result = agent.run("What is 2+2?") + + assert result.content == "The result is 4." + assert result.turns == 2 + assert [tr.content for tr in result.tool_results] == ["4"] + def test_simple_response(self): """No code -> direct answer.""" engine = MagicMock() diff --git a/tests/engine/test_base.py b/tests/engine/test_base.py new file mode 100644 index 00000000..397c18eb --- /dev/null +++ b/tests/engine/test_base.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from openjarvis.core.types import Message, Role, ToolCall +from openjarvis.engine._base import estimate_prompt_tokens + + +def test_estimate_prompt_tokens_handles_none_content_tool_call_turn() -> None: + messages = [ + Message(role=Role.USER, content="hi"), + Message( + role=Role.ASSISTANT, + content=None, # type: ignore[arg-type] + tool_calls=[ToolCall(id="call_1", name="lookup", arguments="{}")], + ), + ] + + assert estimate_prompt_tokens(messages) == 8