refactor: unify system tools as resources
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user