fix(agent): tolerate schema-valued "type" keys in the watchdog payload estimator (#104793)

The image-part membership test in _payload_chars() ran on every dict in
the request, so a tool JSON Schema with a parameter named "type" (its
value is the sub-schema dict) or a multi-type value like
["string", "null"] raised TypeError: unhashable type before the
request reached the provider. Guard the membership test with
isinstance(str): structured "type" values are payload data, not
content parts, and keep the legacy walk for them.
This commit is contained in:
liuhao1024
2026-09-07 15:10:30 +08:00
committed by Teknium
parent a188c84871
commit 3c9c225425
2 changed files with 25 additions and 1 deletions
+4 -1
View File
@@ -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):
@@ -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