140 lines
6.2 KiB
Python
140 lines
6.2 KiB
Python
"""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
|