Implement runtime tool ID and display name mapping in DuplexPipeline. Enhance Assistants and ToolLibrary components to utilize new mappings for improved tool identification and display. Update DebugDrawer to reflect changes in tool display names during interactions.

This commit is contained in:
Xin Wang
2026-02-27 15:50:43 +08:00
parent 0f1165af64
commit b035e023c4
4 changed files with 126 additions and 8 deletions

View File

@@ -94,6 +94,17 @@ def _normalize_runtime_tool_schema(tool_id: str, raw_schema: Any) -> Dict[str, A
return schema 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]]: def _resolve_runtime_tools(db: Session, selected_tool_ids: List[str], warnings: List[str]) -> List[Dict[str, Any]]:
_ensure_tool_resource_schema(db) _ensure_tool_resource_schema(db)
ids = [str(tool_id).strip() for tool_id in selected_tool_ids if str(tool_id).strip()] 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 continue
category = str(resource.category if resource else TOOL_CATEGORY_MAP.get(tool_id, "query")) 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 = ( description = (
str(resource.description or resource.name or "").strip() str(resource.description or resource.name or "").strip()
if resource 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", "executor": "client" if category == "system" else "server",
"function": { "function": {
"name": tool_id, "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, "parameters": schema,
}, },
"displayName": display_name or tool_id,
"toolId": tool_id,
} }
if defaults: if defaults:
runtime_tool["defaultArgs"] = 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]]: def _resolve_runtime_metadata(db: Session, assistant: Assistant) -> tuple[Dict[str, Any], List[str]]:
warnings: List[str] = [] warnings: List[str] = []
metadata: Dict[str, Any] = { metadata: Dict[str, Any] = {
"systemPrompt": assistant.prompt or "", "systemPrompt": _compose_runtime_system_prompt(assistant.prompt),
"firstTurnMode": assistant.first_turn_mode or "bot_first", "firstTurnMode": assistant.first_turn_mode or "bot_first",
"greeting": assistant.opener or "", "greeting": assistant.opener or "",
"generatedOpenerEnabled": bool(assistant.generated_opener_enabled), "generatedOpenerEnabled": bool(assistant.generated_opener_enabled),

View File

@@ -288,6 +288,8 @@ class DuplexPipeline:
self._runtime_tools: List[Any] = list(raw_default_tools) self._runtime_tools: List[Any] = list(raw_default_tools)
self._runtime_tool_executor: Dict[str, str] = {} self._runtime_tool_executor: Dict[str, str] = {}
self._runtime_tool_default_args: Dict[str, Dict[str, Any]] = {} 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._pending_tool_waiters: Dict[str, asyncio.Future] = {}
self._early_tool_results: Dict[str, Dict[str, Any]] = {} self._early_tool_results: Dict[str, Dict[str, Any]] = {}
self._completed_tool_call_ids: set[str] = set() 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_executor = self._resolved_tool_executor_map()
self._runtime_tool_default_args = self._resolved_tool_default_args_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 self._initial_greeting_emitted = False
if self._server_tool_executor is None: if self._server_tool_executor is None:
@@ -411,10 +415,14 @@ class DuplexPipeline:
self._runtime_tools = tools_payload self._runtime_tools = tools_payload
self._runtime_tool_executor = self._resolved_tool_executor_map() self._runtime_tool_executor = self._resolved_tool_executor_map()
self._runtime_tool_default_args = self._resolved_tool_default_args_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: elif "tools" in metadata:
self._runtime_tools = [] self._runtime_tools = []
self._runtime_tool_executor = {} self._runtime_tool_executor = {}
self._runtime_tool_default_args = {} 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"): if self.llm_service and hasattr(self.llm_service, "set_knowledge_config"):
self.llm_service.set_knowledge_config(self._resolved_knowledge_config()) self.llm_service.set_knowledge_config(self._resolved_knowledge_config())
@@ -1496,6 +1504,47 @@ class DuplexPipeline:
result[name] = dict(raw_defaults) result[name] = dict(raw_defaults)
return result 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]: def _resolved_tool_allowlist(self) -> List[str]:
names: set[str] = set() names: set[str] = set()
for item in self._runtime_tools: for item in self._runtime_tools:
@@ -1519,6 +1568,12 @@ class DuplexPipeline:
return str(fn.get("name") or "").strip() return str(fn.get("name") or "").strip()
return "" 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: def _tool_executor(self, tool_call: Dict[str, Any]) -> str:
name = self._tool_name(tool_call) name = self._tool_name(tool_call)
if name and name in self._runtime_tool_executor: 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 "" 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_call_id = str(result.get("tool_call_id") or result.get("id") or "")
tool_name = str(result.get("name") or "unknown_tool") 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) ok = bool(200 <= status_code < 300)
retryable = status_code >= 500 or status_code in {429, 408} retryable = status_code >= 500 or status_code in {429, 408}
error: Optional[Dict[str, Any]] = None error: Optional[Dict[str, Any]] = None
@@ -1568,6 +1624,7 @@ class DuplexPipeline:
return { return {
"tool_call_id": tool_call_id, "tool_call_id": tool_call_id,
"tool_name": tool_name, "tool_name": tool_name,
"tool_display_name": tool_display_name,
"ok": ok, "ok": ok,
"error": error, "error": error,
"status": {"code": status_code, "message": status_message}, "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: async def _emit_tool_result(self, result: Dict[str, Any], source: str) -> None:
tool_name = str(result.get("name") or "unknown_tool") 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 "") 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 = result.get("status") if isinstance(result.get("status"), dict) else {}
status_code = int(status.get("code") or 0) if status else 0 status_code = int(status.get("code") or 0) if status else 0
@@ -1592,6 +1650,7 @@ class DuplexPipeline:
source=source, source=source,
tool_call_id=normalized["tool_call_id"], tool_call_id=normalized["tool_call_id"],
tool_name=normalized["tool_name"], tool_name=normalized["tool_name"],
tool_display_name=normalized["tool_display_name"],
ok=normalized["ok"], ok=normalized["ok"],
error=normalized["error"], error=normalized["error"],
result=result, result=result,
@@ -1733,6 +1792,8 @@ class DuplexPipeline:
enriched_tool_call = dict(tool_call) enriched_tool_call = dict(tool_call)
enriched_tool_call["executor"] = executor enriched_tool_call["executor"] = executor
tool_name = self._tool_name(enriched_tool_call) or "unknown_tool" 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() call_id = str(enriched_tool_call.get("id") or "").strip()
fn_payload = ( fn_payload = (
dict(enriched_tool_call.get("function")) dict(enriched_tool_call.get("function"))
@@ -1764,6 +1825,8 @@ class DuplexPipeline:
trackId=self.track_audio_out, trackId=self.track_audio_out,
tool_call_id=call_id, tool_call_id=call_id,
tool_name=tool_name, tool_name=tool_name,
tool_id=tool_id,
tool_display_name=tool_display_name,
arguments=tool_arguments, arguments=tool_arguments,
executor=executor, executor=executor,
timeout_ms=int(self._TOOL_WAIT_TIMEOUT_SECONDS * 1000), timeout_ms=int(self._TOOL_WAIT_TIMEOUT_SECONDS * 1000),
@@ -1881,6 +1944,7 @@ class DuplexPipeline:
continue continue
executor = str(call.get("executor") or "server").strip().lower() executor = str(call.get("executor") or "server").strip().lower()
tool_name = self._tool_name(call) or "unknown_tool" 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}") logger.info(f"[Tool] execute start name={tool_name} call_id={call_id} executor={executor}")
if executor == "client": if executor == "client":
result = await self._wait_for_single_tool_result(call_id) result = await self._wait_for_single_tool_result(call_id)
@@ -1888,9 +1952,18 @@ class DuplexPipeline:
tool_results.append(result) tool_results.append(result)
continue 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: try:
result = await asyncio.wait_for( result = await asyncio.wait_for(
self._server_tool_executor(call), self._server_tool_executor(call_for_executor),
timeout=self._SERVER_TOOL_TIMEOUT_SECONDS, timeout=self._SERVER_TOOL_TIMEOUT_SECONDS,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:

View File

@@ -2913,6 +2913,7 @@ export const DebugDrawer: React.FC<{
const toolCall = payload?.tool_call || {}; const toolCall = payload?.tool_call || {};
const toolCallId = String(toolCall?.id || '').trim(); const toolCallId = String(toolCall?.id || '').trim();
const toolName = String(toolCall?.function?.name || toolCall?.name || 'unknown_tool'); 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 executor = String(toolCall?.executor || 'server').toLowerCase();
const rawArgs = String(toolCall?.function?.arguments || ''); const rawArgs = String(toolCall?.function?.arguments || '');
const argText = rawArgs.length > 160 ? `${rawArgs.slice(0, 160)}...` : rawArgs; const argText = rawArgs.length > 160 ? `${rawArgs.slice(0, 160)}...` : rawArgs;
@@ -2920,7 +2921,7 @@ export const DebugDrawer: React.FC<{
...prev, ...prev,
{ {
role: 'tool', 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) { 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 statusMessage = String(resultPayload?.status?.message || 'error');
const resultText = const resultText =
statusCode === 200 && typeof resultPayload?.output?.result === 'number' statusCode === 200 && typeof resultPayload?.output?.result === 'number'
? `result ${toolName} = ${resultPayload.output.result}` ? `result ${toolDisplayName} = ${resultPayload.output.result}`
: `result ${toolName} status=${statusCode} ${statusMessage}`; : `result ${toolDisplayName} status=${statusCode} ${statusMessage}`;
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@@ -3025,14 +3026,15 @@ export const DebugDrawer: React.FC<{
if (type === 'assistant.tool_result') { if (type === 'assistant.tool_result') {
const result = payload?.result || {}; const result = payload?.result || {};
const toolName = String(result?.name || 'unknown_tool'); const toolName = String(result?.name || 'unknown_tool');
const toolDisplayName = String(payload?.tool_display_name || toolName);
const statusCode = Number(result?.status?.code || 500); const statusCode = Number(result?.status?.code || 500);
const statusMessage = String(result?.status?.message || 'error'); const statusMessage = String(result?.status?.message || 'error');
const source = String(payload?.source || 'server'); const source = String(payload?.source || 'server');
const output = result?.output; const output = result?.output;
const resultText = const resultText =
statusCode === 200 statusCode === 200
? `result ${toolName} source=${source} ${JSON.stringify(output)}` ? `result ${toolDisplayName} source=${source} ${JSON.stringify(output)}`
: `result ${toolName} source=${source} status=${statusCode} ${statusMessage}`; : `result ${toolDisplayName} source=${source} status=${statusCode} ${statusMessage}`;
setMessages((prev) => [...prev, { role: 'tool', text: resultText }]); setMessages((prev) => [...prev, { role: 'tool', text: resultText }]);
return; return;
} }

View File

@@ -179,6 +179,7 @@ export const ToolLibraryPage: React.FC = () => {
const [editingTool, setEditingTool] = useState<Tool | null>(null); const [editingTool, setEditingTool] = useState<Tool | null>(null);
const [toolName, setToolName] = useState(''); const [toolName, setToolName] = useState('');
const [toolId, setToolId] = useState('');
const [toolDesc, setToolDesc] = useState(''); const [toolDesc, setToolDesc] = useState('');
const [toolCategory, setToolCategory] = useState<'system' | 'query'>('system'); const [toolCategory, setToolCategory] = useState<'system' | 'query'>('system');
const [toolIcon, setToolIcon] = useState('Wrench'); const [toolIcon, setToolIcon] = useState('Wrench');
@@ -209,6 +210,7 @@ export const ToolLibraryPage: React.FC = () => {
const openAdd = () => { const openAdd = () => {
setEditingTool(null); setEditingTool(null);
setToolName(''); setToolName('');
setToolId('');
setToolDesc(''); setToolDesc('');
setToolCategory('system'); setToolCategory('system');
setToolIcon('Wrench'); setToolIcon('Wrench');
@@ -224,6 +226,7 @@ export const ToolLibraryPage: React.FC = () => {
const openEdit = (tool: Tool) => { const openEdit = (tool: Tool) => {
setEditingTool(tool); setEditingTool(tool);
setToolName(tool.name); setToolName(tool.name);
setToolId(tool.id);
setToolDesc(tool.description || ''); setToolDesc(tool.description || '');
setToolCategory(tool.category); setToolCategory(tool.category);
setToolIcon(tool.icon || 'Wrench'); setToolIcon(tool.icon || 'Wrench');
@@ -314,6 +317,10 @@ export const ToolLibraryPage: React.FC = () => {
alert('请填写工具名称'); alert('请填写工具名称');
return; return;
} }
if (!editingTool && toolId.trim() && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(toolId.trim())) {
alert('工具 ID 不合法,请使用字母/数字/下划线,且不能以数字开头');
return;
}
try { try {
setSaving(true); setSaving(true);
@@ -369,6 +376,7 @@ export const ToolLibraryPage: React.FC = () => {
setTools((prev) => prev.map((item) => (item.id === updated.id ? updated : item))); setTools((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
} else { } else {
const created = await createTool({ const created = await createTool({
id: toolId.trim() || undefined,
name: toolName.trim(), name: toolName.trim(),
description: toolDesc, description: toolDesc,
category: toolCategory, category: toolCategory,
@@ -542,6 +550,19 @@ export const ToolLibraryPage: React.FC = () => {
/> />
</div> </div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> ID ()</label>
<Input
value={toolId}
onChange={(e) => setToolId(e.target.value)}
placeholder="例如: voice_message_prompt留空自动生成"
disabled={Boolean(editingTool)}
/>
<p className="text-[11px] text-muted-foreground">
{editingTool ? '已创建工具的 ID 不可修改。' : '建议客户端工具填写稳定 ID避免随机 tool_xxx 导致前端无法识别。'}
</p>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> ( AI )</label> <label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> ( AI )</label>
<textarea <textarea