fix: isolate startup tools and preview dialogs

This commit is contained in:
Xin Wang
2026-08-02 00:07:35 +08:00
parent 0331f8cd07
commit 479a516546
10 changed files with 202 additions and 49 deletions

View File

@@ -108,8 +108,12 @@ class AssistantConfig(BaseModel):
turnConfig: dict = Field(default_factory=dict) turnConfig: dict = Field(default_factory=dict)
startup: dict = Field(default_factory=dict) startup: dict = Field(default_factory=dict)
# Prompt assistant reusable tools. Execution remains type-specific in the pipeline. # ``tools`` is the complete runtime pool (conversation + lifecycle actions).
# ``llm_tool_ids`` limits which tools are advertised to a Prompt model. None
# preserves compatibility for inline/test configs that historically exposed
# every item in ``tools``.
tools: list[RuntimeTool] = Field(default_factory=list) tools: list[RuntimeTool] = Field(default_factory=list)
llm_tool_ids: list[str] | None = None
knowledge_base_id: str | None = None knowledge_base_id: str | None = None
knowledge_base_name: str = "" knowledge_base_name: str = ""
knowledge_base_description: str = "" knowledge_base_description: str = ""

View File

@@ -121,19 +121,10 @@ async def _validate_startup_actions(
actions = body.startup.actions actions = body.startup.actions
if not actions: if not actions:
return return
bound_tool_ids = set(body.tool_ids)
for action in actions: for action in actions:
if action.tool_id not in bound_tool_ids:
raise HTTPException(400, f"启动 Action 必须先绑定工具:{action.tool_id}")
tool = await session.get(Tool, action.tool_id) tool = await session.get(Tool, action.tool_id)
if not tool or tool.status != "active": if not tool or tool.status != "active":
raise HTTPException(400, f"启动 Action 引用了无效工具:{action.tool_id}") raise HTTPException(400, f"启动 Action 引用了无效工具:{action.tool_id}")
if action.phase == "preflight" and tool.type not in {"http", "mcp"}:
raise HTTPException(400, "preflight Action 仅支持 HTTP 或 MCP 工具")
if action.phase == "opening" and tool.type not in {"http", "mcp", "client"}:
raise HTTPException(400, "opening Action 不支持该工具类型")
if tool.type != "client":
continue
runtime_tool = RuntimeTool( runtime_tool = RuntimeTool(
id=tool.id, id=tool.id,
name=tool.name, name=tool.name,
@@ -141,10 +132,20 @@ async def _validate_startup_actions(
type=tool.type, type=tool.type,
definition=tool.definition or {}, definition=tool.definition or {},
) )
if action.phase == "preflight" and runtime_tool.type not in {"http", "mcp"}:
raise HTTPException(400, "preflight Action 仅支持 HTTP 或 MCP 工具")
if action.phase == "opening" and runtime_tool.type not in {
"http",
"mcp",
"client",
}:
raise HTTPException(400, "opening Action 不支持该工具类型")
if runtime_tool.type != "client":
continue
policy = policy_for_tool(runtime_tool) policy = policy_for_tool(runtime_tool)
if action.required and not policy.wait_for_response: if action.required and not policy.wait_for_response:
raise HTTPException(400, "必需的 Client 启动 Action 必须等待客户端响应") raise HTTPException(400, "必需的 Client 启动 Action 必须等待客户端响应")
if tool.function_name != "show_message": if runtime_tool.function_name != "show_message":
continue continue
if policy.response_wait_mode != "session": if policy.response_wait_mode != "session":
raise HTTPException(400, "show_message 启动 Action 必须使用会话内等待") raise HTTPException(400, "show_message 启动 Action 必须使用会话内等待")

View File

@@ -84,8 +84,13 @@ class PromptBrain(BaseBrain):
self._opening_started = False self._opening_started = False
self._opening_finished = not bool(self._startup_actions("opening")) self._opening_finished = not bool(self._startup_actions("opening"))
self._startup_failed = False self._startup_failed = False
llm_tool_ids = (
set(cfg.llm_tool_ids) if cfg.llm_tool_ids is not None else None
)
schemas: list[FunctionSchema] = [] schemas: list[FunctionSchema] = []
for tool in cfg.tools: for tool in cfg.tools:
if llm_tool_ids is not None and tool.id not in llm_tool_ids:
continue
if tool.type == "end_call": if tool.type == "end_call":
schema, handler = self._make_end_call_tool(tool, runtime) schema, handler = self._make_end_call_tool(tool, runtime)
elif tool.type in {"http", "mcp", "client"}: elif tool.type in {"http", "mcp", "client"}:

View File

@@ -90,20 +90,54 @@ def _secret(resource: ModelResource | None, key: str, default: str = "") -> str:
return str((resource.secrets or {}).get(key) or default) return str((resource.secrets or {}).get(key) or default)
async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[RuntimeTool]: async def _bound_tool_ids(session: AsyncSession, assistant_id: str) -> list[str]:
if assistant.type not in {"prompt", "workflow"}: rows = (
return []
tools = (
await session.execute( await session.execute(
select(Tool) select(AssistantToolBinding.tool_id)
.join(AssistantToolBinding, AssistantToolBinding.tool_id == Tool.id) .where(AssistantToolBinding.assistant_id == assistant_id)
.where( .order_by(AssistantToolBinding.created_at, AssistantToolBinding.tool_id)
AssistantToolBinding.assistant_id == assistant.id,
Tool.status == "active",
)
.order_by(AssistantToolBinding.created_at, Tool.id)
) )
).scalars().all() ).scalars().all()
return [str(tool_id) for tool_id in rows]
def _startup_tool_ids(startup: dict | None) -> list[str]:
"""Return lifecycle-only tool references in configured execution order."""
result: list[str] = []
for action in (startup or {}).get("actions") or []:
if not isinstance(action, dict):
continue
tool_id = str(action.get("tool_id") or action.get("toolId") or "").strip()
if tool_id and tool_id not in result:
result.append(tool_id)
return result
def _runtime_tool_ids(llm_tool_ids: list[str], startup: dict | None) -> list[str]:
"""Merge both tool scopes without changing either scope's meaning."""
return list(dict.fromkeys([*llm_tool_ids, *_startup_tool_ids(startup)]))
async def _tools_for(
session: AsyncSession,
assistant: Assistant,
tool_ids: list[str],
) -> list[RuntimeTool]:
if assistant.type not in {"prompt", "workflow"} or not tool_ids:
return []
rows = (
await session.execute(
select(Tool)
.where(
Tool.id.in_(tool_ids),
Tool.status == "active",
)
)
).scalars().all()
tool_by_id = {tool.id: tool for tool in rows}
tools = [tool_by_id[tool_id] for tool_id in tool_ids if tool_id in tool_by_id]
server_ids = { server_ids = {
str(tool.mcp_server_id) str(tool.mcp_server_id)
for tool in tools for tool in tools
@@ -217,6 +251,10 @@ async def resolve_runtime_config(
if kb.status == "active" if kb.status == "active"
} }
llm_tool_ids = await _bound_tool_ids(session, assistant.id)
runtime_tool_ids = _runtime_tool_ids(llm_tool_ids, assistant.startup or {})
runtime_tools = await _tools_for(session, assistant, runtime_tool_ids)
return AssistantConfig( return AssistantConfig(
name=assistant.name, name=assistant.name,
type=assistant.type, type=assistant.type,
@@ -228,7 +266,8 @@ async def resolve_runtime_config(
enableInterrupt=assistant.enable_interrupt, enableInterrupt=assistant.enable_interrupt,
turnConfig=assistant.turn_config or {}, turnConfig=assistant.turn_config or {},
startup=assistant.startup or {}, startup=assistant.startup or {},
tools=await _tools_for(session, assistant), tools=runtime_tools,
llm_tool_ids=llm_tool_ids,
knowledge_base_id=assistant.knowledge_base_id, knowledge_base_id=assistant.knowledge_base_id,
knowledge_base_name=knowledge_base.name if knowledge_base else "", knowledge_base_name=knowledge_base.name if knowledge_base else "",
knowledge_base_description=knowledge_base.description if knowledge_base else "", knowledge_base_description=knowledge_base.description if knowledge_base else "",

View File

@@ -246,6 +246,55 @@ class DifyLLMServiceTests(unittest.IsolatedAsyncioTestCase):
class PromptBrainTests(unittest.IsolatedAsyncioTestCase): class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_startup_only_tool_is_not_registered_with_llm(self):
startup_tool = RuntimeTool(
id="opening_message",
name="开场确认",
function_name="show_message",
type="client",
)
conversation_tool = RuntimeTool(
id="lookup_order",
name="查询订单",
function_name="lookup_order",
type="http",
)
cfg = AssistantConfig(
type="prompt",
tools=[startup_tool, conversation_tool],
llm_tool_ids=[conversation_tool.id],
startup={
"actions": [
{
"id": "opening_message",
"phase": "opening",
"tool_id": startup_tool.id,
"required": True,
}
]
},
)
brain = build_brain(cfg)
llm = FakeLLM()
visible_schemas = []
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=noop_queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=visible_schemas.extend,
call_end=FakeCallEnd(),
),
)
self.assertIn(startup_tool.id, brain._tool_by_id)
self.assertNotIn(startup_tool.function_name, llm.functions)
self.assertIn(conversation_tool.function_name, llm.functions)
self.assertEqual(len(visible_schemas), 1)
async def test_preflight_runs_multiple_server_tools_in_order(self): async def test_preflight_runs_multiple_server_tools_in_order(self):
tools = [ tools = [
RuntimeTool( RuntimeTool(

View File

@@ -6,14 +6,19 @@ from types import SimpleNamespace
from fastapi import HTTPException from fastapi import HTTPException
from routes.assistants import _validate_startup_actions from routes.assistants import _validate_startup_actions
from schemas import AssistantUpsert from schemas import AssistantUpsert
from services.config_resolver import _runtime_tool_ids
def startup_body(*, phase: str = "opening") -> AssistantUpsert: def startup_body(
*,
phase: str = "opening",
bind_tool: bool = False,
) -> AssistantUpsert:
return AssistantUpsert( return AssistantUpsert(
name="启动动作测试", name="启动动作测试",
type="prompt", type="prompt",
runtimeMode="pipeline", runtimeMode="pipeline",
toolIds=["tool_message"], toolIds=["tool_message"] if bind_tool else [],
startup={ startup={
"executionMode": "sequential", "executionMode": "sequential",
"actions": [ "actions": [
@@ -45,20 +50,52 @@ class FakeSession:
self.tool = tool self.tool = tool
async def get(self, _model, tool_id): async def get(self, _model, tool_id):
return self.tool if tool_id == self.tool.id else None return self.tool if self.tool is not None and tool_id == self.tool.id else None
class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase): class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase):
def test_runtime_pool_includes_bound_and_startup_only_tools(self):
self.assertEqual(
_runtime_tool_ids(
["conversation_tool", "shared_tool"],
{
"actions": [
{"tool_id": "opening_tool"},
{"toolId": "shared_tool"},
]
},
),
["conversation_tool", "shared_tool", "opening_tool"],
)
def test_realtime_rejects_startup_actions(self): def test_realtime_rejects_startup_actions(self):
with self.assertRaisesRegex(ValueError, "Realtime"): with self.assertRaisesRegex(ValueError, "Realtime"):
AssistantUpsert( AssistantUpsert(
name="Realtime 启动动作", name="Realtime 启动动作",
type="prompt", type="prompt",
runtimeMode="realtime", runtimeMode="realtime",
toolIds=["tool_message"],
startup=startup_body().startup, startup=startup_body().startup,
) )
async def test_startup_tool_does_not_need_conversation_binding(self):
tool = SimpleNamespace(
id="tool_message",
name="重要提示",
function_name="show_message",
type="client",
status="active",
definition={
"config": {
"wait_for_response": True,
"response_wait_mode": "session",
}
},
)
body = startup_body(bind_tool=False)
await _validate_startup_actions(FakeSession(tool), body)
self.assertEqual(body.tool_ids, [])
async def test_show_message_requires_session_wait(self): async def test_show_message_requires_session_wait(self):
tool = SimpleNamespace( tool = SimpleNamespace(
id="tool_message", id="tool_message",

View File

@@ -645,6 +645,8 @@ function DebugVoicePanel({
const recording = status === "connecting" || status === "connected"; const recording = status === "connecting" || status === "connected";
const [textDraft, setTextDraft] = useState(""); const [textDraft, setTextDraft] = useState("");
const [inputMode, setInputMode] = useState<DebugInputMode>("mic"); const [inputMode, setInputMode] = useState<DebugInputMode>("mic");
const [clientDialogContainer, setClientDialogContainer] =
useState<HTMLDivElement | null>(null);
const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript); const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript);
const idleOrFailed = status === "idle" || status === "failed"; const idleOrFailed = status === "idle" || status === "failed";
const showIdleHub = const showIdleHub =
@@ -682,10 +684,17 @@ function DebugVoicePanel({
]); ]);
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div
ref={setClientDialogContainer}
className="relative isolate flex min-h-0 flex-1 flex-col overflow-hidden"
>
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */} {/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
<audio ref={audioRef} autoPlay playsInline className="hidden" /> <audio ref={audioRef} autoPlay playsInline className="hidden" />
<ClientMessageDialog preview={preview} /> <ClientMessageDialog
preview={preview}
container={clientDialogContainer}
contained
/>
{vision && !showIdleHub ? ( {vision && !showIdleHub ? (
<DebugVisionWorkspace <DebugVisionWorkspace
view={view} view={view}

View File

@@ -135,13 +135,6 @@ export function PromptEditor({
] ]
: otherActions, : otherActions,
}); });
if (
enabled &&
defaultToolId &&
!form.toolIds.includes(defaultToolId)
) {
updateForm("toolIds", [...form.toolIds, defaultToolId]);
}
} }
function updateOpeningMessage( function updateOpeningMessage(
@@ -254,12 +247,7 @@ export function PromptEditor({
value={openingMessage.toolId} value={openingMessage.toolId}
options={showMessageTools} options={showMessageTools}
noneLabel="请选择 show_message 工具" noneLabel="请选择 show_message 工具"
onChange={(toolId) => { onChange={(toolId) => updateOpeningMessage({ toolId })}
updateOpeningMessage({ toolId });
if (toolId && !form.toolIds.includes(toolId)) {
updateForm("toolIds", [...form.toolIds, toolId]);
}
}}
/> />
{showMessageTools.length === 0 && ( {showMessageTools.length === 0 && (
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
@@ -308,7 +296,8 @@ export function PromptEditor({
/> />
</label> </label>
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
Esc
</p> </p>
</div> </div>
)} )}

View File

@@ -120,7 +120,15 @@ function actionVariant(style: MessageActionStyle) {
* Renders messages requested by the agent through the generic Client Tool * Renders messages requested by the agent through the generic Client Tool
* channel. The tool result is held until the user chooses an action. * channel. The tool result is held until the user chooses an action.
*/ */
export function ClientMessageDialog({ preview }: { preview: VoicePreview }) { export function ClientMessageDialog({
preview,
container,
contained = false,
}: {
preview: VoicePreview;
container?: HTMLElement | null;
contained?: boolean;
}) {
const { registerClientTool, status } = preview; const { registerClientTool, status } = preview;
const [message, setMessage] = useState<MessageState | null>(null); const [message, setMessage] = useState<MessageState | null>(null);
const pendingRef = useRef<PendingMessage | null>(null); const pendingRef = useRef<PendingMessage | null>(null);
@@ -163,6 +171,7 @@ export function ClientMessageDialog({ preview }: { preview: VoicePreview }) {
return ( return (
<Dialog <Dialog
modal={!contained}
open={message !== null} open={message !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open && message?.dismissible) complete("dismissed"); if (!open && message?.dismissible) complete("dismissed");
@@ -170,6 +179,8 @@ export function ClientMessageDialog({ preview }: { preview: VoicePreview }) {
> >
<DialogContent <DialogContent
className="gap-5 sm:max-w-md" className="gap-5 sm:max-w-md"
portalContainer={container}
contained={contained}
showCloseButton={message?.dismissible ?? true} showCloseButton={message?.dismissible ?? true}
onEscapeKeyDown={(event) => { onEscapeKeyDown={(event) => {
if (!message?.dismissible) event.preventDefault(); if (!message?.dismissible) event.preventDefault();

View File

@@ -33,13 +33,17 @@ function DialogClose({
function DialogOverlay({ function DialogOverlay({
className, className,
contained = false,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { }: React.ComponentProps<typeof DialogPrimitive.Overlay> & {
contained?: boolean
}) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", contained ? "absolute inset-0" : "fixed inset-0",
"isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className className
)} )}
{...props} {...props}
@@ -51,17 +55,22 @@ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
portalContainer,
contained = false,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean
portalContainer?: Element | DocumentFragment | null
contained?: boolean
}) { }) {
return ( return (
<DialogPortal> <DialogPortal container={portalContainer}>
<DialogOverlay /> <DialogOverlay contained={contained} />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", contained ? "absolute" : "fixed",
"top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-popover p-6 text-sm text-popover-foreground shadow-xl ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className className
)} )}
{...props} {...props}