mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user