refactor: unify system tools as resources

This commit is contained in:
Xin Wang
2026-08-04 17:05:26 +08:00
parent d1b05f16c7
commit 74a8be2357
26 changed files with 788 additions and 507 deletions

View File

@@ -21,7 +21,7 @@ from pipecat.frames.frames import (
)
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from schemas import AssistantUpsert, REALTIME_CAPABLE_TYPES
from schemas import AssistantUpsert, REALTIME_CAPABLE_TYPES, SystemToolConfig
from services.brains import BrainRuntime, SPECS, build_brain
from services.brains.base import GREETING_CONTEXT_MARKER
from services.brains.dify_llm import (
@@ -104,6 +104,17 @@ async def noop_queue_frame(_frame):
return None
def system_runtime_tool(kind: str) -> RuntimeTool:
return RuntimeTool(
id=f"tool_{kind}",
name=kind,
function_name=kind,
type="system",
description=f"{kind} system tool",
definition={"type": "system", "config": {"kind": kind}},
)
class BrainRegistryTests(unittest.TestCase):
def test_capability_matrix(self):
self.assertEqual(
@@ -154,45 +165,18 @@ class BrainRegistryTests(unittest.TestCase):
)
self.assertIn("user_name", assistant.dynamic_variable_definitions)
def test_system_tools_are_prompt_pipeline_only(self):
assistant = AssistantUpsert(
name="prompt",
type="prompt",
systemTools=["end_conversation", "skip_turn", "end_conversation"],
)
self.assertEqual(assistant.system_tools, ["end_conversation", "skip_turn"])
workflow = AssistantUpsert(
name="workflow",
type="workflow",
systemTools=["update_state"],
graph={},
)
self.assertEqual(workflow.system_tools, [])
def test_system_tool_config_accepts_all_platform_actions(self):
for kind in (
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
):
self.assertEqual(SystemToolConfig(kind=kind).kind, kind)
def test_system_tool_config_rejects_unknown_kind(self):
with self.assertRaises(ValueError):
AssistantUpsert(
name="realtime prompt",
type="prompt",
runtimeMode="realtime",
systemTools=["skip_turn"],
)
def test_system_tools_reject_unknown_kind(self):
with self.assertRaises(ValueError):
AssistantUpsert(
name="prompt",
type="prompt",
systemTools=["update_state", "magic"],
)
def test_prompt_update_state_requires_a_declared_variable(self):
with self.assertRaisesRegex(ValueError, "必须声明至少一个动态变量"):
AssistantUpsert(
name="prompt",
type="prompt",
systemTools=["update_state"],
)
SystemToolConfig(kind="magic")
def test_workflow_keeps_dynamic_variables_and_tool_bindings(self):
assistant = AssistantUpsert(
@@ -916,11 +900,11 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
"default": None,
}
},
system_tools=[
"end_conversation",
"update_state",
"skip_turn",
"request_human_handoff",
tools=[
system_runtime_tool("end_conversation"),
system_runtime_tool("update_state"),
system_runtime_tool("skip_turn"),
system_runtime_tool("request_human_handoff"),
],
)
brain = build_brain(cfg)
@@ -971,7 +955,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_end_conversation_ends_call_after_generated_speech(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["end_conversation"],
tools=[system_runtime_tool("end_conversation")],
)
brain = build_brain(cfg)
llm = FakeLLM()
@@ -1009,7 +993,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_end_conversation_finishes_when_no_speech(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["end_conversation"],
tools=[system_runtime_tool("end_conversation")],
)
brain = build_brain(cfg)
llm = FakeLLM()
@@ -1039,7 +1023,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
dynamic_variable_definitions={
"user_name": {"type": "string", "required": False, "default": None}
},
system_tools=["update_state"],
tools=[system_runtime_tool("update_state")],
)
brain = build_brain(cfg)
llm = FakeLLM()
@@ -1083,7 +1067,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_update_state_rejects_undeclared_variable(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["update_state"],
tools=[system_runtime_tool("update_state")],
)
brain = build_brain(cfg)
llm = FakeLLM()
@@ -1108,7 +1092,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_skip_turn_suppresses_response(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["skip_turn"],
tools=[system_runtime_tool("skip_turn")],
)
brain = build_brain(cfg)
llm = FakeLLM()
@@ -1134,7 +1118,7 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_request_human_handoff_keeps_call_available(self):
cfg = AssistantConfig(
type="prompt",
system_tools=["request_human_handoff"],
tools=[system_runtime_tool("request_human_handoff")],
)
brain = build_brain(cfg)
llm = FakeLLM()
@@ -1299,10 +1283,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
"id": "agent",
"type": "agent",
"data": {
"systemTools": [
"update_state",
"skip_turn",
"request_human_handoff",
"inheritGlobalConfig": False,
"toolIds": [
"tool_update_state",
"tool_skip_turn",
"tool_request_human_handoff",
],
"stateVariableNames": ["customer_name"],
},
@@ -1322,6 +1307,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
"default": None,
},
},
tools=[
system_runtime_tool("update_state"),
system_runtime_tool("skip_turn"),
system_runtime_tool("request_human_handoff"),
],
),
{},
assistant_id="asst_workflow_system_tools",
@@ -1462,7 +1452,10 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
set_tools=lambda _tools: None,
call_end=call_end,
)
tool = brain._workflow_end_conversation_tool()
tool = brain._workflow_end_conversation_tool(
system_runtime_tool("end_conversation"),
"agent",
)
await brain.on_assistant_text_start("turn-1")
result = await tool.handler({"reason": "用户告别"}, None)

View File

@@ -1,12 +1,18 @@
from __future__ import annotations
import asyncio
import unittest
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import AsyncMock
from models import AssistantConfig, RuntimeModelResource
from fastapi import HTTPException
from routes.assistants import _validate_workflow, _validate_workflow_references
from routes.assistants import (
_validate_system_tool_selection,
_validate_workflow,
_validate_workflow_references,
)
from schemas import AssistantUpsert
from services.pipecat.service_factory import config_with_resource
from services.node_specs import graph_references, normalize_graph, validate_graph
@@ -363,7 +369,6 @@ class WorkflowGraphTests(unittest.TestCase):
agent = next(node for node in graph["nodes"] if node["id"] == "agent")
agent["data"].update(
{
"systemTools": ["update_state", "skip_turn"],
"stateVariableNames": ["customer", "order_status"],
}
)
@@ -374,7 +379,7 @@ class WorkflowGraphTests(unittest.TestCase):
"defaultTtsResourceId": "tts_global",
"visionEnabled": True,
"visionModelResourceId": "vision_global",
"toolIds": ["tool_global"],
"toolIds": ["tool_global", "update_state", "skip_turn"],
"knowledgeBaseId": "kb_global",
"knowledgeMode": "on_demand",
"knowledgeTopN": 8,
@@ -395,8 +400,10 @@ class WorkflowGraphTests(unittest.TestCase):
"vision_global",
)
self.assertTrue(engine.uses_vision())
self.assertEqual(inherited.tool_ids, ("tool_global",))
self.assertEqual(inherited.system_tools, ("update_state", "skip_turn"))
self.assertEqual(
inherited.tool_ids,
("tool_global", "update_state", "skip_turn"),
)
self.assertEqual(
inherited.state_variable_names,
("customer", "order_status"),
@@ -412,7 +419,7 @@ class WorkflowGraphTests(unittest.TestCase):
{
"inheritGlobalConfig": False,
"llmResourceId": "llm_agent",
"toolIds": ["tool_agent"],
"toolIds": ["tool_agent", "update_state", "skip_turn"],
"knowledgeBaseId": "",
"visionEnabled": False,
"visionModelResourceId": "",
@@ -428,8 +435,10 @@ class WorkflowGraphTests(unittest.TestCase):
self.assertFalse(custom.vision_enabled)
self.assertIsNone(custom.vision_model_resource_id)
self.assertFalse(engine.uses_vision())
self.assertEqual(custom.tool_ids, ("tool_agent",))
self.assertEqual(custom.system_tools, ("update_state", "skip_turn"))
self.assertEqual(
custom.tool_ids,
("tool_agent", "update_state", "skip_turn"),
)
self.assertEqual(custom.knowledge_mode, "disabled")
self.assertTrue(custom.enable_interrupt)
self.assertEqual(
@@ -463,7 +472,6 @@ class WorkflowGraphTests(unittest.TestCase):
agent = next(node for node in graph["nodes"] if node["id"] == "agent")
agent["data"].update(
{
"systemTools": ["update_state"],
"stateVariableNames": ["customer"],
}
)
@@ -497,8 +505,8 @@ class WorkflowGraphTests(unittest.TestCase):
def test_agent_update_state_tool_requires_an_authorized_variable(self):
graph = valid_graph()
graph["settings"]["toolIds"] = ["tool_update_state"]
agent = next(node for node in graph["nodes"] if node["id"] == "agent")
agent["data"]["systemTools"] = ["update_state"]
body = AssistantUpsert(
name="缺少授权",
type="workflow",
@@ -512,8 +520,19 @@ class WorkflowGraphTests(unittest.TestCase):
graph=graph,
)
_validate_workflow(body)
system_tool = SimpleNamespace(
id="tool_update_state",
status="active",
type="system",
definition={"config": {"kind": "update_state"}},
)
result = SimpleNamespace(
scalars=lambda: SimpleNamespace(all=lambda: [system_tool])
)
session = SimpleNamespace(execute=AsyncMock(return_value=result))
with self.assertRaisesRegex(HTTPException, "必须授权至少一个变量"):
_validate_workflow(body)
asyncio.run(_validate_system_tool_selection(session, body))
def test_vision_resource_creates_isolated_runtime_config(self):
base = AssistantConfig(type="workflow", model="text-only")