feat: organize tools and enable qwen realtime calls

This commit is contained in:
Xin Wang
2026-08-14 00:15:00 +08:00
parent eb111a3890
commit 5be21f5ccc
71 changed files with 5771 additions and 2026 deletions
+343 -94
View File
@@ -2,7 +2,7 @@
import uuid
from models import VISION_PROMPT_MODES, RuntimeTool
from models import VISION_PROMPT_MODES, RuntimeTool, RuntimeToolBinding
from db.models import (
Assistant,
AssistantModelBinding,
@@ -10,6 +10,7 @@ from db.models import (
KnowledgeBase,
ModelResource,
Tool,
InterfaceDefinition,
)
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
@@ -20,8 +21,10 @@ from schemas import (
from services.auth import require_admin
from services.masking import mask, resolve_incoming_key
from services.node_specs import graph_references, normalize_graph, validate_graph
from services.system_tools import system_tool_kind
from services.state_expressions import StateExpressionError, validate_update_rules
from services.system_tools import RESERVED_TOOL_NAMES
from services.tool_policy import policy_for_tool
from services.tool_executor import ToolExecutor
from services.workflow_engine import WorkflowEngine
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,10 +37,69 @@ router = APIRouter(
CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent")
async def _validate_prompt_realtime_tool_capabilities(
session: AsyncSession,
body: AssistantUpsert,
) -> None:
"""Gate Prompt tools by the selected realtime interface definition."""
if body.type != "prompt" or body.runtime_mode != "realtime":
return
resource_id = str(body.model_resource_ids.get("Realtime") or "")
resource = await session.get(ModelResource, resource_id) if resource_id else None
if not resource or not resource.enabled or resource.capability != "Realtime":
raise HTTPException(400, "Prompt Realtime 必须选择可用的 Realtime 模型")
definition = await session.get(InterfaceDefinition, resource.interface_type)
features = (
((definition.field_schema or {}).get("features") or {}).get(
"realtimePromptTools"
)
if definition and definition.enabled
else None
) or {}
external_types = set(features.get("externalTypes") or [])
built_ins = set(features.get("builtIns") or [])
tool_ids = [binding.tool_id for binding in body.tool_bindings]
rows = (
(
await session.execute(select(Tool).where(Tool.id.in_(tool_ids)))
).scalars().all()
if tool_ids
else []
)
unsupported_external = sorted(
{tool.type for tool in rows if tool.type not in external_types}
)
requested_built_ins = {
key
for key, value in body.built_in_tools.model_dump(
by_alias=True,
exclude_none=True,
).items()
if value is not None
}
unsupported_built_ins = sorted(requested_built_ins - built_ins)
if unsupported_external or unsupported_built_ins:
details = []
if unsupported_external:
details.append("外部工具:" + ",".join(unsupported_external))
if unsupported_built_ins:
details.append("内建工具:" + ",".join(unsupported_built_ins))
raise HTTPException(
400,
f"Realtime 接口 {resource.interface_type} 不支持 " + ";".join(details),
)
def _validate_workflow(body: AssistantUpsert) -> None:
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
if body.type != "workflow":
return
if body.graph and str(body.graph.get("specVersion") or "") != "4":
raise HTTPException(
400,
"Workflow API 仅接受 specVersion=4;旧图请先执行数据库迁移",
)
body.graph = normalize_graph(body.graph or {})
errors = validate_graph(body.graph)
settings = body.graph.get("settings") or {}
@@ -48,28 +110,6 @@ def _validate_workflow(body: AssistantUpsert) -> None:
bound_realtime_id = str(body.model_resource_ids.get("Realtime") or "")
if graph_realtime_id != bound_realtime_id:
errors.append("工作流 Realtime 模型与 Assistant Realtime 绑定不一致")
declared_variables = set(body.dynamic_variable_definitions)
for node in body.graph.get("nodes") or []:
node_id = str(node.get("id") or "")
node_type = node.get("type")
data = node.get("data") or {}
if node_type == "update_state":
unknown = sorted(
set((data.get("assignments") or {}).keys()) - declared_variables
)
if unknown:
errors.append(
f"Update State 节点 {node_id} 引用了未声明变量:"
+ ",".join(unknown)
)
elif node_type == "agent":
authorized = set(data.get("stateVariableNames") or [])
unknown = sorted(authorized - declared_variables)
if unknown:
errors.append(
f"Agent 节点 {node_id} 授权了未声明变量:"
+ ",".join(unknown)
)
if errors:
raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
# Graph settings are the source of truth. The flat flag is only a session
@@ -78,8 +118,6 @@ def _validate_workflow(body: AssistantUpsert) -> None:
body.vision_model_resource_id = None
body.vision_prompt_mode = "runtime"
body.vision_prompt = ""
refs = graph_references(body.graph)
body.tool_ids = list(dict.fromkeys([*body.tool_ids, *sorted(refs["tools"])]))
async def _validate_workflow_references(
@@ -156,58 +194,202 @@ async def _validate_workflow_references(
raise HTTPException(400, f"Workflow 引用了无效知识库:{knowledge_id}")
async def _validate_system_tool_selection(
async def _validate_tool_bindings(
session: AsyncSession,
body: AssistantUpsert,
) -> None:
"""Validate System resources in the same selection path as every other tool."""
selected_ids = list(dict.fromkeys(body.tool_ids))
if not selected_ids or body.type not in {"prompt", "workflow"}:
return
rows = (
await session.execute(select(Tool).where(Tool.id.in_(selected_ids)))
).scalars().all()
tools_by_id = {tool.id: tool for tool in rows if tool.status == "active"}
system_kinds = {
tool.id: system_tool_kind(tool.definition or {})
for tool in rows
if tool.status == "active" and tool.type == "system"
}
invalid = [tool_id for tool_id, kind in system_kinds.items() if kind is None]
if invalid:
raise HTTPException(400, "系统工具配置无效: " + ", ".join(invalid))
"""Validate reusable resources and every usage-specific binding."""
raw_bindings: list[tuple[str, dict]] = []
if body.type == "prompt":
if body.runtime_mode == "realtime" and system_kinds:
raise HTTPException(400, "Prompt Realtime 模式暂不支持系统工具")
if (
"update_state" in system_kinds.values()
and not body.dynamic_variable_definitions
):
raise HTTPException(400, "启用更新状态工具前必须声明至少一个动态变量")
return
engine = WorkflowEngine(body.graph)
for node_id, node in engine.nodes.items():
node_type = node.get("type")
data = node.get("data") or {}
if node_type == "agent":
stage = engine.agent_stage_config(node_id)
kinds = {
system_kinds[tool_id]
for tool_id in stage.tool_ids
if tool_id in system_kinds
}
if "update_state" in kinds and not stage.state_variable_names:
raw_bindings.extend(
("Prompt", binding.model_dump()) for binding in body.tool_bindings
)
for action in body.startup.actions:
raw_bindings.append(
(f"Startup {action.id}", action.tool_binding.model_dump())
)
elif body.type == "workflow":
settings = body.graph.get("settings") or {}
raw_bindings.extend(
("Workflow 全局", binding)
for binding in settings.get("toolBindings") or []
)
for node in body.graph.get("nodes") or []:
data = node.get("data") or {}
if node.get("type") == "agent" and not data.get(
"inheritGlobalConfig", True
):
raw_bindings.extend(
(f"Agent {node.get('id')}", binding)
for binding in data.get("toolBindings") or []
)
elif node.get("type") == "action":
raw_bindings.append(
(f"Action {node.get('id')}", data.get("toolBinding") or {})
)
tool_ids = {
str(binding.get("tool_id") or binding.get("toolId") or "")
for _, binding in raw_bindings
if isinstance(binding, dict)
}
tool_ids.discard("")
rows = (
await session.execute(select(Tool).where(Tool.id.in_(tool_ids)))
).scalars().all() if tool_ids else []
tools = {tool.id: tool for tool in rows if tool.status == "active"}
missing = sorted(tool_ids - set(tools))
if missing:
raise HTTPException(400, "工具不存在或未启用: " + ", ".join(missing))
reserved = sorted(
tool.function_name
for tool in tools.values()
if tool.function_name in RESERVED_TOOL_NAMES
)
if reserved:
raise HTTPException(
400,
"外部工具使用了平台保留函数名: " + ", ".join(reserved),
)
declared_variables = set(body.dynamic_variable_definitions)
system_variables = {
"system__agent_id",
"system__conversation_id",
"system__time",
"system__time_utc",
"system__timezone",
"system__agent_turns",
"system__conversation_history",
"system__last_action_status",
"system__last_action_invocation_id",
"system__last_action_duration_ms",
"system__last_action_error",
"system__last_action_error_code",
"system__handoff_status",
}
for scope, raw in raw_bindings:
if not isinstance(raw, dict):
raise HTTPException(400, "工具绑定必须是对象")
binding = RuntimeToolBinding(**raw)
tool = tools.get(binding.tool_id)
if tool is None:
continue
runtime_tool = RuntimeTool(
id=tool.id,
name=tool.name,
function_name=tool.function_name,
type=tool.type,
definition=tool.definition or {},
)
properties, required = ToolExecutor.input_schema(runtime_tool)
unknown = sorted(set(binding.argument_bindings) - set(properties))
if unknown:
raise HTTPException(400, "工具绑定引用了未知参数: " + ", ".join(unknown))
absent = sorted(set(required) - set(binding.argument_bindings))
if absent:
raise HTTPException(400, "工具绑定缺少必填参数: " + ", ".join(absent))
if scope.startswith(("Action ", "Startup ")):
model_parameters = sorted(
name
for name, source in binding.argument_bindings.items()
if str((source or {}).get("source") or "") == "model"
)
if model_parameters:
raise HTTPException(
400,
f"Agent 节点 {node_id} 启用更新状态工具时必须授权至少一个变量",
f"{scope} 是确定性调用,参数不能来自模型: "
+ ", ".join(model_parameters),
)
elif node_type == "action":
tool_id = str(data.get("toolId") or "")
tool = tools_by_id.get(tool_id)
if tool and tool.type == "system":
raise HTTPException(400, f"Action 节点 {node_id} 不能调用系统工具")
variable_sources = {
str(source.get("variable_name") or source.get("variableName") or "")
for source in [
*binding.argument_bindings.values(),
*binding.template_bindings.values(),
]
if str((source or {}).get("source") or "") == "variable"
}
invalid_sources = sorted(variable_sources - declared_variables - system_variables)
if invalid_sources:
raise HTTPException(
400,
f"{scope} 引用了未声明动态变量: " + ", ".join(invalid_sources),
)
config = (tool.definition or {}).get("config") or {}
if tool.type == "client" and not config.get(
"wait_for_response", config.get("waitForResponse", True)
) and binding.result_assignments:
raise HTTPException(
400,
f"{scope} 的 fire-and-forget Client 工具不能配置结果映射",
)
template_inputs = config.get("template_inputs") or []
template_names = {
str(item.get("name")) for item in template_inputs if item.get("name")
}
unknown_templates = sorted(set(binding.template_bindings) - template_names)
missing_templates = sorted(
{
str(item.get("name"))
for item in template_inputs
if item.get("name") and item.get("required", True)
}
- set(binding.template_bindings)
)
if unknown_templates:
raise HTTPException(
400,
"工具绑定引用了未知模板输入: " + ", ".join(unknown_templates),
)
if missing_templates:
raise HTTPException(
400,
"工具绑定缺少模板输入: " + ", ".join(missing_templates),
)
undeclared = sorted(
set(binding.result_assignments) - set(body.dynamic_variable_definitions)
)
if undeclared:
raise HTTPException(400, "结果映射目标变量未声明: " + ", ".join(undeclared))
def _validate_update_state_config(body: AssistantUpsert) -> None:
"""Validate Prompt and Workflow built-ins against declared variables."""
configs: list[tuple[str, dict]] = []
if body.type == "prompt":
configs.append(("Prompt", body.built_in_tools.model_dump()))
elif body.type == "workflow":
settings = body.graph.get("settings") or {}
configs.append(("Workflow 全局", settings.get("builtInTools") or {}))
for node in body.graph.get("nodes") or []:
data = node.get("data") or {}
if node.get("type") == "agent" and not data.get(
"inheritGlobalConfig", True
):
configs.append(
(f"Agent {node.get('id')}", data.get("builtInTools") or {})
)
elif node.get("type") == "update_state":
try:
validate_update_rules(
data.get("updates") or [],
body.dynamic_variable_definitions,
allow_llm=False,
)
except StateExpressionError as exc:
raise HTTPException(400, f"Update State 节点配置无效: {exc}") from exc
for scope, config in configs:
update_state = config.get("update_state") or config.get("updateState")
if not update_state:
continue
try:
validate_update_rules(
update_state.get("updates") or [],
body.dynamic_variable_definitions,
allow_llm=True,
)
except StateExpressionError as exc:
raise HTTPException(400, f"{scope} 更新状态配置无效: {exc}") from exc
async def _validate_startup_actions(
@@ -220,9 +402,10 @@ async def _validate_startup_actions(
if not actions:
return
for action in actions:
tool = await session.get(Tool, action.tool_id)
binding = action.tool_binding
tool = await session.get(Tool, binding.tool_id)
if not tool or tool.status != "active":
raise HTTPException(400, f"启动 Action 引用了无效工具:{action.tool_id}")
raise HTTPException(400, f"启动 Action 引用了无效工具:{binding.tool_id}")
runtime_tool = RuntimeTool(
id=tool.id,
name=tool.name,
@@ -247,8 +430,13 @@ async def _validate_startup_actions(
continue
if policy.response_wait_mode != "session":
raise HTTPException(400, "show_message 启动 Action 必须使用会话内等待")
message = str(action.arguments.get("message") or "").strip()
buttons = action.arguments.get("actions")
literal_arguments = {
name: source.value
for name, source in binding.argument_bindings.items()
if getattr(source, "source", "") == "literal"
}
message = str(literal_arguments.get("message") or "").strip()
buttons = literal_arguments.get("actions")
if not message or len(message) > 2000:
raise HTTPException(400, "show_message 重要信息必须为 1-2000 个字符")
if not isinstance(buttons, list) or not buttons:
@@ -258,7 +446,7 @@ async def _validate_startup_actions(
first_button.get("label") or ""
).strip():
raise HTTPException(400, "show_message 确认按钮必须配置 id 和文字")
if action.required and action.arguments.get("dismissible") is not False:
if action.required and literal_arguments.get("dismissible") is not False:
raise HTTPException(400, "必需的 show_message 启动 Action 不允许跳过确认")
@@ -346,13 +534,30 @@ async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, s
async def _sync_tool_bindings(
session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str]
session: AsyncSession,
assistant_id: str,
assistant_type: str,
tool_bindings,
graph: dict | None = None,
) -> None:
requested = (
list(dict.fromkeys(tool_ids))
if assistant_type in {"prompt", "workflow"}
else []
)
requested_configs = {
binding.tool_id: {
"argument_bindings": {
name: source.model_dump()
for name, source in binding.argument_bindings.items()
},
"template_bindings": {
name: source.model_dump()
for name, source in binding.template_bindings.items()
},
"result_assignments": dict(binding.result_assignments),
}
for binding in tool_bindings
} if assistant_type == "prompt" else {}
if assistant_type == "workflow":
refs = graph_references(graph or {})
requested_configs = {tool_id: {} for tool_id in refs["tools"]}
requested = list(requested_configs)
if requested:
tools = (
await session.execute(select(Tool).where(Tool.id.in_(requested)))
@@ -374,19 +579,31 @@ async def _sync_tool_bindings(
if tool_id not in requested:
await session.delete(binding)
for tool_id in requested:
if tool_id not in existing_by_id:
session.add(AssistantToolBinding(assistant_id=assistant_id, tool_id=tool_id))
if tool_id in existing_by_id:
existing_by_id[tool_id].config = requested_configs[tool_id]
else:
session.add(
AssistantToolBinding(
assistant_id=assistant_id,
tool_id=tool_id,
config=requested_configs[tool_id],
)
)
async def _tool_ids(session: AsyncSession, assistant_id: str) -> list[str]:
async def _tool_bindings(session: AsyncSession, assistant_id: str) -> list[dict]:
rows = (
await session.execute(
select(AssistantToolBinding.tool_id)
select(AssistantToolBinding)
.where(AssistantToolBinding.assistant_id == assistant_id)
.order_by(AssistantToolBinding.created_at)
)
).scalars().all()
return list(rows)
return [
{"tool_id": row.tool_id, **(row.config or {})}
for row in rows
if row.config
]
async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
@@ -406,7 +623,12 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
model_resource_ids=await _resource_ids(session, assistant.id),
knowledge_base_id=assistant.knowledge_base_id,
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
tool_ids=await _tool_ids(session, assistant.id),
tool_bindings=(
await _tool_bindings(session, assistant.id)
if assistant.type == "prompt"
else []
),
built_in_tools=assistant.built_in_tools or {},
prompt=assistant.prompt,
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
analysis_config=assistant.analysis_config or {},
@@ -436,19 +658,29 @@ async def create_assistant(
):
_validate_workflow(body)
await _validate_workflow_references(session, body)
await _validate_system_tool_selection(session, body)
_validate_update_state_config(body)
await _validate_tool_bindings(session, body)
await _validate_prompt_realtime_tool_capabilities(session, body)
await _validate_startup_actions(session, body)
await _validate_vision_model(session, body)
await _validate_analysis_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()
resource_ids = data.pop("model_resource_ids")
tool_ids = data.pop("tool_ids")
tool_bindings = body.tool_bindings
data.pop("tool_bindings")
data["built_in_tools"] = body.built_in_tools.model_dump(exclude_none=True)
assistant = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **data)
session.add(assistant)
await session.flush()
await _sync_bindings(session, assistant.id, resource_ids)
await _sync_tool_bindings(session, assistant.id, assistant.type, tool_ids)
await _sync_tool_bindings(
session,
assistant.id,
assistant.type,
tool_bindings,
body.graph,
)
await session.commit()
await session.refresh(assistant)
return await _to_out(session, assistant)
@@ -488,6 +720,7 @@ async def duplicate_assistant(
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
prompt=source.prompt,
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
built_in_tools=dict(source.built_in_tools or {}),
analysis_config=dict(source.analysis_config or {}),
api_url=source.api_url,
api_key=source.api_key,
@@ -497,8 +730,14 @@ async def duplicate_assistant(
session.add(assistant)
await session.flush()
await _sync_bindings(session, assistant.id, await _resource_ids(session, source.id))
source_bindings = await _tool_bindings(session, source.id)
from schemas import ToolBinding
await _sync_tool_bindings(
session, assistant.id, assistant.type, await _tool_ids(session, source.id)
session,
assistant.id,
assistant.type,
[ToolBinding(**binding) for binding in source_bindings],
assistant.graph,
)
await session.commit()
await session.refresh(assistant)
@@ -516,19 +755,29 @@ async def update_assistant(
raise HTTPException(404, "助手不存在")
_validate_workflow(body)
await _validate_workflow_references(session, body)
await _validate_system_tool_selection(session, body)
_validate_update_state_config(body)
await _validate_tool_bindings(session, body)
await _validate_prompt_realtime_tool_capabilities(session, body)
await _validate_startup_actions(session, body)
await _validate_vision_model(session, body)
await _validate_analysis_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()
resource_ids = data.pop("model_resource_ids")
tool_ids = data.pop("tool_ids")
tool_bindings = body.tool_bindings
data.pop("tool_bindings")
data["built_in_tools"] = body.built_in_tools.model_dump(exclude_none=True)
data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key)
for key, value in data.items():
setattr(assistant, key, value)
await _sync_bindings(session, assistant.id, resource_ids)
await _sync_tool_bindings(session, assistant.id, assistant.type, tool_ids)
await _sync_tool_bindings(
session,
assistant.id,
assistant.type,
tool_bindings,
body.graph,
)
await session.commit()
await session.refresh(assistant)
return await _to_out(session, assistant)