"""unify system tools as reusable tool resources Revision ID: 20260804_0012 Revises: 20260804_0011 """ from __future__ import annotations from collections.abc import Sequence import json from alembic import op import sqlalchemy as sa revision: str = "20260804_0012" down_revision: str | Sequence[str] | None = "20260804_0011" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None SYSTEM_TOOLS = { "end_conversation": { "id": "tool_end_call_default", "name": "结束对话", "function_name": "end_conversation", "description": ( "礼貌地结束本次对话。当用户明确告别、表示任务已完成" "或要求挂断时调用。" ), }, "update_state": { "id": "tool_update_state_default", "name": "更新状态", "function_name": "update_state", "description": ( "静默更新本次对话中已经声明并明确授权的动态变量。" "只提交本轮获得或确认的信息,更新后继续当前回答。" ), }, "skip_turn": { "id": "tool_skip_turn_default", "name": "跳过本轮", "function_name": "skip_turn", "description": ( "跳过当前轮次,不生成任何语音回复。" "仅当用户明确要求稍等、话还没说完,或输入可确认只是噪音时调用。" ), }, "request_human_handoff": { "id": "tool_request_human_handoff_default", "name": "转接人工", "function_name": "request_human_handoff", "description": ( "提交人工接管请求。当用户明确要求人工服务、投诉升级或 AI 无法" "解决时调用。该工具只提交请求,不代表人工已经接通;调用后继续" "回复用户并说明正在等待人工响应。" ), }, } def _definition(kind: str) -> dict: config: dict[str, object] = {"kind": kind} if kind == "end_conversation": config.update( { "message_type": "none", "custom_message": "", "capture_reason": True, } ) return {"schema_version": 1, "type": "system", "config": config} def _copy_global_agent_config(data: dict, settings: dict) -> None: """Freeze an inherited Agent before moving its node-local System tools.""" key_pairs = { "defaultLlmResourceId": "llmResourceId", "defaultAsrResourceId": "asrResourceId", "defaultTtsResourceId": "ttsResourceId", } for source, target in key_pairs.items(): data[target] = settings.get(source) for key in ( "visionEnabled", "visionModelResourceId", "knowledgeBaseId", "knowledgeMode", "knowledgeTopN", "knowledgeScoreThreshold", "enableInterrupt", "turnConfig", ): if key in settings: data[key] = settings[key] data["toolIds"] = list(settings.get("toolIds") or []) data["inheritGlobalConfig"] = False def upgrade() -> None: connection = op.get_bind() for kind, spec in SYSTEM_TOOLS.items(): if kind == "end_conversation": connection.execute( sa.text( """ UPDATE tools SET name = :name, function_name = :function_name, type = 'system', description = :description, definition = CAST(:definition AS jsonb), status = 'active', updated_at = now() WHERE id = :id """ ), {**spec, "definition": json.dumps(_definition(kind))}, ) connection.execute( sa.text( """ INSERT INTO tools (id, name, function_name, type, description, definition, secrets, status) VALUES (:id, :name, :function_name, 'system', :description, CAST(:definition AS jsonb), '{}'::jsonb, 'active') ON CONFLICT (function_name) DO NOTHING """ ), {**spec, "definition": json.dumps(_definition(kind))}, ) assistants = connection.execute( sa.text("SELECT id, system_tools, graph FROM assistants") ).mappings().all() for assistant in assistants: assistant_id = str(assistant["id"]) for kind in assistant["system_tools"] or []: spec = SYSTEM_TOOLS.get(str(kind)) if not spec: continue connection.execute( sa.text( """ INSERT INTO assistant_tool_bindings (assistant_id, tool_id) VALUES (:assistant_id, :tool_id) ON CONFLICT DO NOTHING """ ), {"assistant_id": assistant_id, "tool_id": spec["id"]}, ) graph = assistant["graph"] or {} if not isinstance(graph, dict): continue settings = graph.get("settings") or {} changed = False for node in graph.get("nodes") or []: if node.get("type") != "agent": continue data = node.get("data") or {} had_legacy_selection = "systemTools" in data kinds = list(dict.fromkeys(data.pop("systemTools", []) or [])) if had_legacy_selection: node["data"] = data changed = True if not kinds: continue if data.get("inheritGlobalConfig", True): _copy_global_agent_config(data, settings) tool_ids = list(data.get("toolIds") or []) for kind in kinds: spec = SYSTEM_TOOLS.get(str(kind)) if spec and spec["id"] not in tool_ids: tool_ids.append(spec["id"]) connection.execute( sa.text( """ INSERT INTO assistant_tool_bindings (assistant_id, tool_id) VALUES (:assistant_id, :tool_id) ON CONFLICT DO NOTHING """ ), {"assistant_id": assistant_id, "tool_id": spec["id"]}, ) data["toolIds"] = tool_ids node["data"] = data if changed: connection.execute( sa.text("UPDATE assistants SET graph = CAST(:graph AS json) WHERE id = :id"), {"id": assistant_id, "graph": json.dumps(graph)}, ) op.drop_column("assistants", "system_tools") def downgrade() -> None: op.add_column( "assistants", sa.Column( "system_tools", sa.JSON(), server_default=sa.text("'[]'"), nullable=False, ), ) op.execute( """ DELETE FROM assistant_tool_bindings WHERE tool_id IN ( 'tool_update_state_default', 'tool_skip_turn_default', 'tool_request_human_handoff_default' ) """ ) op.execute( """ DELETE FROM tools WHERE id IN ( 'tool_update_state_default', 'tool_skip_turn_default', 'tool_request_human_handoff_default' ) """ ) op.execute( """ UPDATE tools SET function_name = 'end_call' WHERE id = 'tool_end_call_default' """ )