assistant/backend/browser/structured_output.py

140 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""A single, explicit structured-result transport; never repair action data."""
from __future__ import annotations
import json
from copy import deepcopy
FUNCTION_NAME = "return_structured_output"
class ResponseContractError(ValueError):
"""Only a program-owned reason survives, never response text or values."""
def __init__(self, reason: str):
self.reason = reason
super().__init__(reason)
def _single_action_items(items: dict, schema: dict) -> bool:
"""Recognize the framework's closed, mutually exclusive action objects."""
def resolve(node):
seen = set()
while isinstance(node, dict) and "$ref" in node:
ref = node["$ref"]
if not isinstance(ref, str) or not ref.startswith("#/$defs/") or ref in seen:
return {}
seen.add(ref)
node = schema.get("$defs", {}).get(ref.removeprefix("#/$defs/"), {})
return node if isinstance(node, dict) else {}
action = resolve(items)
branches = action.get("anyOf", action.get("oneOf", [action]))
if not isinstance(branches, list) or not branches:
return False
names = []
for branch in branches:
branch = resolve(branch)
properties = branch.get("properties")
if (branch.get("type") != "object" or branch.get("additionalProperties") is not False
or not isinstance(properties, dict) or len(properties) != 1
or branch.get("required") != list(properties)):
return False
names.extend(properties)
return len(set(names)) == len(names)
def request_contract(output_format) -> tuple[dict, str]:
schema = deepcopy(output_format.model_json_schema())
action = schema.get("properties", {}).get("action") or {}
single_action = action.get("type") == "array" and _single_action_items(action.get("items", {}), schema)
if action.get("type") == "array":
# The pinned framework exports nonstandard `min_items`; our response
# contract also rejects an empty array. Export that actual contract in
# standard JSON Schema rather than relying on prose alone.
action.pop("min_items", None)
action["minItems"] = max(1, action.get("minItems", 0))
if single_action:
# Each closed branch already requires exactly its own action key.
# State the same invariant beside the array item for the provider;
# retain the original union and every parameter schema unchanged.
action["items"].update(minProperties=1, maxProperties=1)
function = {"type": "function", "function": {
"name": FUNCTION_NAME,
"description": "Submit the complete structured result for this step; this function does not execute a browser action.",
"parameters": schema,
}}
instruction = (
"本次结果只通过 return_structured_output 函数的 JSON 参数提交,必须符合该函数的完整 Schema。"
"原有 JSON 输出指引指这个参数对象;不要仅返回自然语言总结、Markdown或单个内层动作。"
"过程说明保持简短,字符串中的引号、换行按 JSON 规则转义;动作参数按现场依据完整填写。"
)
if action.get("type") == "array":
instruction += "action 是必填的非空数组,每个元素是 Schema 中的一种完整动作对象;不能省略action或返回空数组。"
if single_action:
instruction += "每个数组元素只能包含一个Schema声明的动作名键,参数放在该动作名下;多个动作必须拆成多个数组元素,不能把两个动作放进同一个对象。"
return function, instruction
def attach_instruction(messages: list[dict], instruction: str) -> list[dict]:
"""Preserve conversation order, images and the caller's original objects."""
prepared = list(messages)
index = next((i for i, row in enumerate(prepared) if row.get("role") == "system"), None)
if index is None:
return [{"role": "system", "content": instruction}, *prepared]
original = prepared[index]
content = original.get("content")
if isinstance(content, str):
prepared[index] = {**original, "content": content + "\n\n" + instruction}
elif isinstance(content, list):
prepared[index] = {**original, "content": [*content, {"type": "text", "text": instruction}]}
else:
return [{"role": "system", "content": instruction}, *prepared]
return prepared
def _unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ResponseContractError("duplicate_argument_key")
result[key] = value
return result
def _finite_json(_value):
raise ResponseContractError("nonfinite_arguments")
def validated_tool_result(message, output_format):
"""Validate the one requested call, ignoring prose instead of executing it.
No JSON extraction, quote repair, missing-field defaults or action inference
is performed. The caller accounts usage before invoking this function.
"""
calls = getattr(message, "tool_calls", None)
if not calls:
raise ResponseContractError("missing_tool_call")
if not isinstance(calls, list) or len(calls) != 1:
raise ResponseContractError("multiple_tool_calls")
call = calls[0]
function = getattr(call, "function", None)
if getattr(call, "type", None) != "function" or getattr(function, "name", None) != FUNCTION_NAME:
raise ResponseContractError("unexpected_tool_call")
arguments = getattr(function, "arguments", None)
if not isinstance(arguments, str) or not arguments.strip():
raise ResponseContractError("missing_arguments")
try:
parsed = json.loads(arguments, object_pairs_hook=_unique_object, parse_constant=_finite_json)
except ResponseContractError:
raise
except (ValueError, UnicodeError, RecursionError):
raise ResponseContractError("malformed_arguments") from None
if not isinstance(parsed, dict):
raise ResponseContractError("arguments_not_object")
# Validation against the original complete model remains authoritative.
result = output_format.model_validate_json(arguments)
if isinstance(getattr(result, "action", None), list) and not result.action:
raise ResponseContractError("empty_actions")
return result