diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 88c2ee0c76..49b158bb58 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -339,7 +339,10 @@ def _payload_chars(value: Any, image_cost: int) -> int: if value is None: return 0 if isinstance(value, dict): - if value.get("type") in _IMAGE_PART_TYPES and any(k in value for k in ("image_url", "image", "source", "file_id")): + part_type = value.get("type") + # JSON-Schema nodes may hold a sub-schema (``properties.type``) or a multi-type list + # under the "type" key; only scalar content-part types can ever match (#104793). + if isinstance(part_type, str) and part_type in _IMAGE_PART_TYPES and any(k in value for k in ("image_url", "image", "source", "file_id")): return _image_part_chars(value, image_cost) return sum(len(str(k)) + 6 + _payload_chars(v, image_cost) for k, v in value.items()) if isinstance(value, list): diff --git a/tests/agent/test_context_estimator_multimodal.py b/tests/agent/test_context_estimator_multimodal.py index 8a320f4eee..a8661e4768 100644 --- a/tests/agent/test_context_estimator_multimodal.py +++ b/tests/agent/test_context_estimator_multimodal.py @@ -31,3 +31,24 @@ def test_text_payloads_and_tool_schemas_keep_the_legacy_estimate(): "tools": [{"type": "function", "function": {"name": "vision", "parameters": {"type": "image"}}}]} legacy = (sum(len(str(m)) for m in payload["messages"]) + len(str(payload["tools"]))) // 4 assert abs(estimate_request_context_tokens(payload) - legacy) < legacy * 0.02 + + +def test_schema_nodes_with_structured_type_values_keep_the_legacy_estimate(): + """A JSON-Schema ``properties`` dict whose ``type`` KEY holds a sub-schema dict, or a multi-type + list like ``["string", "null"]``, is payload data — not an image part. The membership test must + not raise ``TypeError: unhashable type`` before the request reaches the provider (#104793).""" + def _tools(param_name): + return [{"type": "function", "function": {"name": "memory_search", "parameters": {"type": "object", "properties": { + param_name: {"type": "string", "enum": ["episode", "semantic"]}, + "maybe": {"type": ["string", "null"]}, + }}}}] + messages = [{"role": "user", "content": "hi"}] + payload = {"messages": messages, "tools": _tools("type")} + renamed = {"messages": messages, "tools": _tools("kind")} # equal-length key: identical walk + assert estimate_request_context_tokens(payload) == estimate_request_context_tokens(renamed) + # A real image part in the same request is still priced at the learned cost. + chat = {"messages": [{"role": "user", "content": [ + {"type": "text", "text": "look"}, + {"type": "image_url", "image_url": {"url": _B64}}, + ]}], "tools": _tools("type")} + assert DEFAULT_IMAGE_TOKEN_COST <= estimate_request_context_tokens(chat) < DEFAULT_IMAGE_TOKEN_COST + 400