diff --git a/api/app/routers/assistants.py b/api/app/routers/assistants.py index 66bb958..bbed34a 100644 --- a/api/app/routers/assistants.py +++ b/api/app/routers/assistants.py @@ -94,6 +94,17 @@ def _normalize_runtime_tool_schema(tool_id: str, raw_schema: Any) -> Dict[str, A return schema +def _compose_runtime_system_prompt(base_prompt: Optional[str]) -> str: + raw = str(base_prompt or "").strip() + tool_policy = ( + "Tool usage policy:\n" + "- Tool function names/IDs are internal and must never be shown to users.\n" + "- When users ask which tools are available, describe capabilities in natural language.\n" + "- Do not expose raw tool call payloads, IDs, or executor details." + ) + return f"{raw}\n\n{tool_policy}" if raw else tool_policy + + def _resolve_runtime_tools(db: Session, selected_tool_ids: List[str], warnings: List[str]) -> List[Dict[str, Any]]: _ensure_tool_resource_schema(db) ids = [str(tool_id).strip() for tool_id in selected_tool_ids if str(tool_id).strip()] @@ -115,6 +126,11 @@ def _resolve_runtime_tools(db: Session, selected_tool_ids: List[str], warnings: continue category = str(resource.category if resource else TOOL_CATEGORY_MAP.get(tool_id, "query")) + display_name = ( + str(resource.name or tool_id).strip() + if resource + else str(TOOL_REGISTRY.get(tool_id, {}).get("name") or tool_id).strip() + ) description = ( str(resource.description or resource.name or "").strip() if resource @@ -135,9 +151,15 @@ def _resolve_runtime_tools(db: Session, selected_tool_ids: List[str], warnings: "executor": "client" if category == "system" else "server", "function": { "name": tool_id, - "description": description or tool_id, + "description": ( + f"Display name: {display_name}. {description}".strip() + if display_name + else (description or tool_id) + ), "parameters": schema, }, + "displayName": display_name or tool_id, + "toolId": tool_id, } if defaults: runtime_tool["defaultArgs"] = defaults @@ -149,7 +171,7 @@ def _resolve_runtime_tools(db: Session, selected_tool_ids: List[str], warnings: def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[str, Any], List[str]]: warnings: List[str] = [] metadata: Dict[str, Any] = { - "systemPrompt": assistant.prompt or "", + "systemPrompt": _compose_runtime_system_prompt(assistant.prompt), "firstTurnMode": assistant.first_turn_mode or "bot_first", "greeting": assistant.opener or "", "generatedOpenerEnabled": bool(assistant.generated_opener_enabled), diff --git a/engine/core/duplex_pipeline.py b/engine/core/duplex_pipeline.py index 1f2485b..3f5be85 100644 --- a/engine/core/duplex_pipeline.py +++ b/engine/core/duplex_pipeline.py @@ -288,6 +288,8 @@ class DuplexPipeline: self._runtime_tools: List[Any] = list(raw_default_tools) self._runtime_tool_executor: Dict[str, str] = {} self._runtime_tool_default_args: Dict[str, Dict[str, Any]] = {} + self._runtime_tool_id_map: Dict[str, str] = {} + self._runtime_tool_display_names: Dict[str, str] = {} self._pending_tool_waiters: Dict[str, asyncio.Future] = {} self._early_tool_results: Dict[str, Dict[str, Any]] = {} self._completed_tool_call_ids: set[str] = set() @@ -309,6 +311,8 @@ class DuplexPipeline: self._runtime_tool_executor = self._resolved_tool_executor_map() self._runtime_tool_default_args = self._resolved_tool_default_args_map() + self._runtime_tool_id_map = self._resolved_tool_id_map() + self._runtime_tool_display_names = self._resolved_tool_display_name_map() self._initial_greeting_emitted = False if self._server_tool_executor is None: @@ -411,10 +415,14 @@ class DuplexPipeline: self._runtime_tools = tools_payload self._runtime_tool_executor = self._resolved_tool_executor_map() self._runtime_tool_default_args = self._resolved_tool_default_args_map() + self._runtime_tool_id_map = self._resolved_tool_id_map() + self._runtime_tool_display_names = self._resolved_tool_display_name_map() elif "tools" in metadata: self._runtime_tools = [] self._runtime_tool_executor = {} self._runtime_tool_default_args = {} + self._runtime_tool_id_map = {} + self._runtime_tool_display_names = {} if self.llm_service and hasattr(self.llm_service, "set_knowledge_config"): self.llm_service.set_knowledge_config(self._resolved_knowledge_config()) @@ -1496,6 +1504,47 @@ class DuplexPipeline: result[name] = dict(raw_defaults) return result + def _resolved_tool_id_map(self) -> Dict[str, str]: + result: Dict[str, str] = {} + for item in self._runtime_tools: + if not isinstance(item, dict): + continue + fn = item.get("function") + if isinstance(fn, dict) and fn.get("name"): + alias = str(fn.get("name")).strip() + else: + alias = str(item.get("name") or "").strip() + if not alias: + continue + tool_id = str(item.get("toolId") or item.get("tool_id") or alias).strip() + if tool_id: + result[alias] = tool_id + return result + + def _resolved_tool_display_name_map(self) -> Dict[str, str]: + result: Dict[str, str] = {} + for item in self._runtime_tools: + if not isinstance(item, dict): + continue + fn = item.get("function") + if isinstance(fn, dict) and fn.get("name"): + name = str(fn.get("name")).strip() + else: + name = str(item.get("name") or "").strip() + if not name: + continue + display_name = str( + item.get("displayName") + or item.get("display_name") + or name + ).strip() + if display_name: + result[name] = display_name + tool_id = str(item.get("toolId") or item.get("tool_id") or "").strip() + if tool_id: + result[tool_id] = display_name + return result + def _resolved_tool_allowlist(self) -> List[str]: names: set[str] = set() for item in self._runtime_tools: @@ -1519,6 +1568,12 @@ class DuplexPipeline: return str(fn.get("name") or "").strip() return "" + def _tool_id_for_name(self, tool_name: str) -> str: + return str(self._runtime_tool_id_map.get(tool_name) or tool_name).strip() + + def _tool_display_name(self, tool_name: str) -> str: + return str(self._runtime_tool_display_names.get(tool_name) or tool_name).strip() + def _tool_executor(self, tool_call: Dict[str, Any]) -> str: name = self._tool_name(tool_call) if name and name in self._runtime_tool_executor: @@ -1556,6 +1611,7 @@ class DuplexPipeline: status_message = str(status.get("message") or "") if status else "" tool_call_id = str(result.get("tool_call_id") or result.get("id") or "") tool_name = str(result.get("name") or "unknown_tool") + tool_display_name = self._tool_display_name(tool_name) or tool_name ok = bool(200 <= status_code < 300) retryable = status_code >= 500 or status_code in {429, 408} error: Optional[Dict[str, Any]] = None @@ -1568,6 +1624,7 @@ class DuplexPipeline: return { "tool_call_id": tool_call_id, "tool_name": tool_name, + "tool_display_name": tool_display_name, "ok": ok, "error": error, "status": {"code": status_code, "message": status_message}, @@ -1575,6 +1632,7 @@ class DuplexPipeline: async def _emit_tool_result(self, result: Dict[str, Any], source: str) -> None: tool_name = str(result.get("name") or "unknown_tool") + tool_display_name = self._tool_display_name(tool_name) or tool_name call_id = str(result.get("tool_call_id") or result.get("id") or "") status = result.get("status") if isinstance(result.get("status"), dict) else {} status_code = int(status.get("code") or 0) if status else 0 @@ -1592,6 +1650,7 @@ class DuplexPipeline: source=source, tool_call_id=normalized["tool_call_id"], tool_name=normalized["tool_name"], + tool_display_name=normalized["tool_display_name"], ok=normalized["ok"], error=normalized["error"], result=result, @@ -1733,6 +1792,8 @@ class DuplexPipeline: enriched_tool_call = dict(tool_call) enriched_tool_call["executor"] = executor tool_name = self._tool_name(enriched_tool_call) or "unknown_tool" + tool_id = self._tool_id_for_name(tool_name) + tool_display_name = self._tool_display_name(tool_name) or tool_name call_id = str(enriched_tool_call.get("id") or "").strip() fn_payload = ( dict(enriched_tool_call.get("function")) @@ -1764,6 +1825,8 @@ class DuplexPipeline: trackId=self.track_audio_out, tool_call_id=call_id, tool_name=tool_name, + tool_id=tool_id, + tool_display_name=tool_display_name, arguments=tool_arguments, executor=executor, timeout_ms=int(self._TOOL_WAIT_TIMEOUT_SECONDS * 1000), @@ -1881,6 +1944,7 @@ class DuplexPipeline: continue executor = str(call.get("executor") or "server").strip().lower() tool_name = self._tool_name(call) or "unknown_tool" + tool_id = self._tool_id_for_name(tool_name) logger.info(f"[Tool] execute start name={tool_name} call_id={call_id} executor={executor}") if executor == "client": result = await self._wait_for_single_tool_result(call_id) @@ -1888,9 +1952,18 @@ class DuplexPipeline: tool_results.append(result) continue + call_for_executor = dict(call) + fn_for_executor = ( + dict(call_for_executor.get("function")) + if isinstance(call_for_executor.get("function"), dict) + else None + ) + if isinstance(fn_for_executor, dict): + fn_for_executor["name"] = tool_id + call_for_executor["function"] = fn_for_executor try: result = await asyncio.wait_for( - self._server_tool_executor(call), + self._server_tool_executor(call_for_executor), timeout=self._SERVER_TOOL_TIMEOUT_SECONDS, ) except asyncio.TimeoutError: diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index 90fdccc..7a1ba6f 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -2913,6 +2913,7 @@ export const DebugDrawer: React.FC<{ const toolCall = payload?.tool_call || {}; const toolCallId = String(toolCall?.id || '').trim(); const toolName = String(toolCall?.function?.name || toolCall?.name || 'unknown_tool'); + const toolDisplayName = String(payload?.tool_display_name || toolCall?.displayName || toolName); const executor = String(toolCall?.executor || 'server').toLowerCase(); const rawArgs = String(toolCall?.function?.arguments || ''); const argText = rawArgs.length > 160 ? `${rawArgs.slice(0, 160)}...` : rawArgs; @@ -2920,7 +2921,7 @@ export const DebugDrawer: React.FC<{ ...prev, { role: 'tool', - text: `call ${toolName} executor=${executor}${argText ? ` args=${argText}` : ''}`, + text: `call ${toolDisplayName} executor=${executor}${argText ? ` args=${argText}` : ''}`, }, ]); if (executor === 'client' && toolCallId && ws.readyState === WebSocket.OPEN) { @@ -2950,8 +2951,8 @@ export const DebugDrawer: React.FC<{ const statusMessage = String(resultPayload?.status?.message || 'error'); const resultText = statusCode === 200 && typeof resultPayload?.output?.result === 'number' - ? `result ${toolName} = ${resultPayload.output.result}` - : `result ${toolName} status=${statusCode} ${statusMessage}`; + ? `result ${toolDisplayName} = ${resultPayload.output.result}` + : `result ${toolDisplayName} status=${statusCode} ${statusMessage}`; setMessages((prev) => [ ...prev, { @@ -3025,14 +3026,15 @@ export const DebugDrawer: React.FC<{ if (type === 'assistant.tool_result') { const result = payload?.result || {}; const toolName = String(result?.name || 'unknown_tool'); + const toolDisplayName = String(payload?.tool_display_name || toolName); const statusCode = Number(result?.status?.code || 500); const statusMessage = String(result?.status?.message || 'error'); const source = String(payload?.source || 'server'); const output = result?.output; const resultText = statusCode === 200 - ? `result ${toolName} source=${source} ${JSON.stringify(output)}` - : `result ${toolName} source=${source} status=${statusCode} ${statusMessage}`; + ? `result ${toolDisplayName} source=${source} ${JSON.stringify(output)}` + : `result ${toolDisplayName} source=${source} status=${statusCode} ${statusMessage}`; setMessages((prev) => [...prev, { role: 'tool', text: resultText }]); return; } diff --git a/web/pages/ToolLibrary.tsx b/web/pages/ToolLibrary.tsx index cfc097c..4c990e2 100644 --- a/web/pages/ToolLibrary.tsx +++ b/web/pages/ToolLibrary.tsx @@ -179,6 +179,7 @@ export const ToolLibraryPage: React.FC = () => { const [editingTool, setEditingTool] = useState(null); const [toolName, setToolName] = useState(''); + const [toolId, setToolId] = useState(''); const [toolDesc, setToolDesc] = useState(''); const [toolCategory, setToolCategory] = useState<'system' | 'query'>('system'); const [toolIcon, setToolIcon] = useState('Wrench'); @@ -209,6 +210,7 @@ export const ToolLibraryPage: React.FC = () => { const openAdd = () => { setEditingTool(null); setToolName(''); + setToolId(''); setToolDesc(''); setToolCategory('system'); setToolIcon('Wrench'); @@ -224,6 +226,7 @@ export const ToolLibraryPage: React.FC = () => { const openEdit = (tool: Tool) => { setEditingTool(tool); setToolName(tool.name); + setToolId(tool.id); setToolDesc(tool.description || ''); setToolCategory(tool.category); setToolIcon(tool.icon || 'Wrench'); @@ -314,6 +317,10 @@ export const ToolLibraryPage: React.FC = () => { alert('请填写工具名称'); return; } + if (!editingTool && toolId.trim() && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(toolId.trim())) { + alert('工具 ID 不合法,请使用字母/数字/下划线,且不能以数字开头'); + return; + } try { setSaving(true); @@ -369,6 +376,7 @@ export const ToolLibraryPage: React.FC = () => { setTools((prev) => prev.map((item) => (item.id === updated.id ? updated : item))); } else { const created = await createTool({ + id: toolId.trim() || undefined, name: toolName.trim(), description: toolDesc, category: toolCategory, @@ -542,6 +550,19 @@ export const ToolLibraryPage: React.FC = () => { /> +
+ + setToolId(e.target.value)} + placeholder="例如: voice_message_prompt(留空自动生成)" + disabled={Boolean(editingTool)} + /> +

+ {editingTool ? '已创建工具的 ID 不可修改。' : '建议客户端工具填写稳定 ID,避免随机 tool_xxx 导致前端无法识别。'} +

+
+