F1: add reusable task backend resources and migrate task tools
This commit is contained in:
@@ -50,6 +50,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
|||||||
from routes import (
|
from routes import (
|
||||||
assistants,
|
assistants,
|
||||||
background_tasks,
|
background_tasks,
|
||||||
|
task_backends,
|
||||||
auth,
|
auth,
|
||||||
conversations,
|
conversations,
|
||||||
health,
|
health,
|
||||||
@@ -198,6 +199,7 @@ app.include_router(test_assets.router)
|
|||||||
app.include_router(test_runs.router)
|
app.include_router(test_runs.router)
|
||||||
app.include_router(tools.router)
|
app.include_router(tools.router)
|
||||||
app.include_router(background_tasks.router)
|
app.include_router(background_tasks.router)
|
||||||
|
app.include_router(task_backends.router)
|
||||||
app.include_router(voice_webrtc.router)
|
app.include_router(voice_webrtc.router)
|
||||||
app.include_router(voice_ws.router)
|
app.include_router(voice_ws.router)
|
||||||
|
|
||||||
|
|||||||
+27
-2
@@ -177,6 +177,7 @@ class Assistant(Base):
|
|||||||
prompt: Mapped[str] = mapped_column(Text, default="") # prompt
|
prompt: Mapped[str] = mapped_column(Text, default="") # prompt
|
||||||
dynamic_variable_definitions: Mapped[dict] = mapped_column(JSON, default=dict)
|
dynamic_variable_definitions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
built_in_tools: Mapped[dict] = mapped_column(JSONB, default=dict)
|
built_in_tools: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
task_backend: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
analysis_config: Mapped[dict] = mapped_column(JSONB, default=dict)
|
analysis_config: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt
|
api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt
|
||||||
api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt(打码/哨兵)
|
api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt(打码/哨兵)
|
||||||
@@ -237,6 +238,27 @@ class McpServer(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBackendResource(Base):
|
||||||
|
"""Reusable connection and execution policy, separate from ordinary tools."""
|
||||||
|
|
||||||
|
__tablename__ = "task_backend_resources"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("type IN ('reasoner', 'opencode')", name="ck_task_backend_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128))
|
||||||
|
type: Mapped[str] = mapped_column(String(16))
|
||||||
|
description: Mapped[str] = mapped_column(String(2048), default="")
|
||||||
|
config: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Tool(Base):
|
class Tool(Base):
|
||||||
"""Reusable LLM tool definition; supported types are executed at runtime."""
|
"""Reusable LLM tool definition; supported types are executed at runtime."""
|
||||||
|
|
||||||
@@ -248,7 +270,7 @@ class Tool(Base):
|
|||||||
name="uq_tools_mcp_server_remote_name",
|
name="uq_tools_mcp_server_remote_name",
|
||||||
),
|
),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"type IN ('http', 'mcp', 'client', 'task')",
|
"type IN ('http', 'mcp', 'client')",
|
||||||
name="ck_tools_external_type",
|
name="ck_tools_external_type",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -655,7 +677,10 @@ class BackgroundTask(Base):
|
|||||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
assistant_id: Mapped[str] = mapped_column(String(40), index=True)
|
assistant_id: Mapped[str] = mapped_column(String(40), index=True)
|
||||||
conversation_id: Mapped[str] = mapped_column(String(80), index=True)
|
conversation_id: Mapped[str] = mapped_column(String(80), index=True)
|
||||||
tool_id: Mapped[str] = mapped_column(String(40))
|
tool_id: Mapped[str] = mapped_column(String(40), default="")
|
||||||
|
backend_id: Mapped[str] = mapped_column(String(40), default="")
|
||||||
|
backend_type: Mapped[str] = mapped_column(String(16), default="opencode")
|
||||||
|
deadline_at: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||||
instruction: Mapped[str] = mapped_column(Text)
|
instruction: Mapped[str] = mapped_column(Text)
|
||||||
origin_key: Mapped[str] = mapped_column(String(256), unique=True)
|
origin_key: Mapped[str] = mapped_column(String(256), unique=True)
|
||||||
connection: Mapped[dict] = mapped_column(JSONB)
|
connection: Mapped[dict] = mapped_column(JSONB)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Move task tools into task backend resources without replaying existing tasks."""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20261023_0027"
|
||||||
|
down_revision = "20261023_0026"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"task_backend_resources",
|
||||||
|
sa.Column("id", sa.String(40), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(128), nullable=False),
|
||||||
|
sa.Column("type", sa.String(16), nullable=False),
|
||||||
|
sa.Column("description", sa.String(2048), nullable=False),
|
||||||
|
sa.Column("config", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("secrets", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.CheckConstraint("type IN ('reasoner', 'opencode')", name="ck_task_backend_type"),
|
||||||
|
)
|
||||||
|
op.add_column("assistants", sa.Column("task_backend", postgresql.JSONB(), nullable=True))
|
||||||
|
op.add_column("background_tasks", sa.Column("backend_id", sa.String(40), nullable=False, server_default=""))
|
||||||
|
op.add_column("background_tasks", sa.Column("backend_type", sa.String(16), nullable=False, server_default="opencode"))
|
||||||
|
# NULL preserves pre-migration observation-window semantics for historical tasks.
|
||||||
|
op.add_column("background_tasks", sa.Column("deadline_at", sa.String(40), nullable=True))
|
||||||
|
op.alter_column("background_tasks", "tool_id", server_default="")
|
||||||
|
op.execute("""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT b.assistant_id FROM assistant_tool_bindings b JOIN tools t ON t.id=b.tool_id
|
||||||
|
WHERE t.type='task' GROUP BY b.assistant_id HAVING count(*) > 1
|
||||||
|
) THEN RAISE EXCEPTION 'Task backend migration: an assistant has multiple task bindings; resolve before retrying'; END IF;
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM assistant_tool_bindings b JOIN tools t ON t.id=b.tool_id
|
||||||
|
JOIN assistants a ON a.id=b.assistant_id
|
||||||
|
WHERE t.type='task' AND (a.type != 'prompt' OR a.runtime_mode != 'pipeline')
|
||||||
|
) THEN RAISE EXCEPTION 'Task backend migration: task bindings require Prompt Pipeline'; END IF;
|
||||||
|
END $$
|
||||||
|
""")
|
||||||
|
# Resource IDs may retain the old tool prefix: IDs are local to each resource table.
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO task_backend_resources (id,name,type,description,config,secrets,enabled,created_at,updated_at)
|
||||||
|
SELECT id,name,'opencode',description,
|
||||||
|
jsonb_build_object(
|
||||||
|
'url',definition->'config'->'url',
|
||||||
|
'directory',definition->'config'->'directory',
|
||||||
|
'username',COALESCE(definition->'config'->'username','\"opencode\"'::jsonb),
|
||||||
|
'timeout_seconds',COALESCE(definition->'config'->'timeout_seconds',definition->'config'->'timeoutSeconds','600'::jsonb),
|
||||||
|
'max_concurrency',1,'instructions','','agent','','model_provider_id','','model_id','','skills_note',''
|
||||||
|
), secrets,status='active',created_at,updated_at
|
||||||
|
FROM tools WHERE type='task'
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE assistants a SET task_backend=jsonb_build_object(
|
||||||
|
'backend_id', b.tool_id,
|
||||||
|
'instruction_source',COALESCE(
|
||||||
|
b.config->'argument_bindings'->'instruction',
|
||||||
|
b.config->'argumentBindings'->'instruction',
|
||||||
|
'{"source":"model"}'::jsonb
|
||||||
|
),
|
||||||
|
'instructions',''
|
||||||
|
)
|
||||||
|
FROM assistant_tool_bindings b JOIN tools t ON t.id=b.tool_id
|
||||||
|
WHERE a.id=b.assistant_id AND t.type='task'
|
||||||
|
""")
|
||||||
|
op.execute("UPDATE background_tasks SET backend_id=tool_id, backend_type='opencode'")
|
||||||
|
op.execute("DELETE FROM assistant_tool_bindings WHERE tool_id IN (SELECT id FROM tools WHERE type='task')")
|
||||||
|
op.execute("DELETE FROM tools WHERE type='task'")
|
||||||
|
op.drop_constraint("ck_tools_external_type", "tools", type_="check")
|
||||||
|
op.create_check_constraint("ck_tools_external_type", "tools", "type IN ('http', 'mcp', 'client')")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
raise RuntimeError(
|
||||||
|
"Task backend resources cannot be downgraded safely to task tools. "
|
||||||
|
"Restore a pre-migration database backup to roll back this resource migration."
|
||||||
|
)
|
||||||
@@ -70,6 +70,21 @@ class RuntimeKnowledgeBase(BaseModel):
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeTaskBackend(BaseModel):
|
||||||
|
"""Resolved task backend, frozen server-side when a task is accepted."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str = ""
|
||||||
|
type: Literal["reasoner", "opencode"]
|
||||||
|
config: dict = Field(default_factory=dict)
|
||||||
|
secrets: dict = Field(default_factory=dict)
|
||||||
|
model: RuntimeModelResource | None = None
|
||||||
|
tools: list[RuntimeTool] = Field(default_factory=list)
|
||||||
|
tool_bindings: list[RuntimeToolBinding] = Field(default_factory=list)
|
||||||
|
instruction_source: dict = Field(default_factory=lambda: {"source": "model"})
|
||||||
|
instructions: str = ""
|
||||||
|
|
||||||
|
|
||||||
class AssistantConfig(BaseModel):
|
class AssistantConfig(BaseModel):
|
||||||
"""运行时配置:前端可见部分(name/prompt/...) + 服务端注入部分(*_api_key/*_base_url)。"""
|
"""运行时配置:前端可见部分(name/prompt/...) + 服务端注入部分(*_api_key/*_base_url)。"""
|
||||||
|
|
||||||
@@ -138,6 +153,7 @@ class AssistantConfig(BaseModel):
|
|||||||
# and result mappings; platform built-ins are configured separately.
|
# and result mappings; platform built-ins are configured separately.
|
||||||
tools: list[RuntimeTool] = Field(default_factory=list)
|
tools: list[RuntimeTool] = Field(default_factory=list)
|
||||||
tool_bindings: list[RuntimeToolBinding] = Field(default_factory=list)
|
tool_bindings: list[RuntimeToolBinding] = Field(default_factory=list)
|
||||||
|
task_backend: RuntimeTaskBackend | 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 = ""
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from services.state_expressions import StateExpressionError, validate_update_rul
|
|||||||
from services.system_tools import RESERVED_TOOL_NAMES
|
from services.system_tools import RESERVED_TOOL_NAMES
|
||||||
from services.tool_policy import policy_for_tool
|
from services.tool_policy import policy_for_tool
|
||||||
from services.tool_executor import ToolExecutor
|
from services.tool_executor import ToolExecutor
|
||||||
|
from services.task_backend_resources import validate_backend_binding
|
||||||
from services.workflow_engine import WorkflowEngine
|
from services.workflow_engine import WorkflowEngine
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -242,11 +243,12 @@ async def _validate_tool_bindings(
|
|||||||
missing = sorted(tool_ids - set(tools))
|
missing = sorted(tool_ids - set(tools))
|
||||||
if missing:
|
if missing:
|
||||||
raise HTTPException(400, "工具不存在或未启用: " + ", ".join(missing))
|
raise HTTPException(400, "工具不存在或未启用: " + ", ".join(missing))
|
||||||
task_tools = [tool for tool in tools.values() if tool.type == "task"]
|
if any(tool.type not in {"http", "mcp", "client"} for tool in tools.values()):
|
||||||
if task_tools and (body.type != "prompt" or body.runtime_mode != "pipeline"):
|
for scope, raw in raw_bindings:
|
||||||
raise HTTPException(400, "后台任务工具仅支持提示词 Pipeline")
|
tool = tools.get(raw.get("tool_id") or raw.get("toolId"))
|
||||||
if len(task_tools) > 1:
|
if scope.startswith(("Action ", "Startup ")) and tool and tool.type == "task":
|
||||||
raise HTTPException(400, "每个助手最多绑定一个后台任务工具")
|
raise HTTPException(400, "任务后端不支持启动 Action")
|
||||||
|
raise HTTPException(400, "普通工具仅支持 HTTP/MCP/Client;任务请配置任务后端")
|
||||||
reserved = sorted(
|
reserved = sorted(
|
||||||
tool.function_name
|
tool.function_name
|
||||||
for tool in tools.values()
|
for tool in tools.values()
|
||||||
@@ -287,10 +289,6 @@ async def _validate_tool_bindings(
|
|||||||
type=tool.type,
|
type=tool.type,
|
||||||
definition=tool.definition or {},
|
definition=tool.definition or {},
|
||||||
)
|
)
|
||||||
if tool.type == "task" and binding.result_assignments:
|
|
||||||
raise HTTPException(400, "后台任务结果通过任务通知返回,不支持结果变量映射")
|
|
||||||
if tool.type == "task" and scope.startswith(("Action ", "Startup ")):
|
|
||||||
raise HTTPException(400, "后台任务工具不支持启动 Action")
|
|
||||||
properties, required = ToolExecutor.input_schema(runtime_tool)
|
properties, required = ToolExecutor.input_schema(runtime_tool)
|
||||||
unknown = sorted(set(binding.argument_bindings) - set(properties))
|
unknown = sorted(set(binding.argument_bindings) - set(properties))
|
||||||
if unknown:
|
if unknown:
|
||||||
@@ -506,6 +504,13 @@ async def _validate_knowledge_base(session: AsyncSession, body: AssistantUpsert)
|
|||||||
raise HTTPException(400, "知识库不存在")
|
raise HTTPException(400, "知识库不存在")
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_task_backend(session: AsyncSession, body: AssistantUpsert) -> None:
|
||||||
|
try:
|
||||||
|
await validate_backend_binding(session, body)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
async def _sync_bindings(
|
async def _sync_bindings(
|
||||||
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
|
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -641,6 +646,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
|||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
built_in_tools=assistant.built_in_tools or {},
|
built_in_tools=assistant.built_in_tools or {},
|
||||||
|
task_backend=getattr(assistant, "task_backend", None),
|
||||||
prompt=assistant.prompt,
|
prompt=assistant.prompt,
|
||||||
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
||||||
analysis_config=assistant.analysis_config or {},
|
analysis_config=assistant.analysis_config or {},
|
||||||
@@ -681,6 +687,7 @@ async def create_assistant(
|
|||||||
await _validate_vision_model(session, body)
|
await _validate_vision_model(session, body)
|
||||||
await _validate_analysis_model(session, body)
|
await _validate_analysis_model(session, body)
|
||||||
await _validate_knowledge_base(session, body)
|
await _validate_knowledge_base(session, body)
|
||||||
|
await _validate_task_backend(session, body)
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
resource_ids = data.pop("model_resource_ids")
|
resource_ids = data.pop("model_resource_ids")
|
||||||
tool_bindings = body.tool_bindings
|
tool_bindings = body.tool_bindings
|
||||||
@@ -721,6 +728,7 @@ async def duplicate_assistant(
|
|||||||
raise HTTPException(404, "助手不存在")
|
raise HTTPException(404, "助手不存在")
|
||||||
if source.type not in SUPPORTED_ASSISTANT_TYPES:
|
if source.type not in SUPPORTED_ASSISTANT_TYPES:
|
||||||
raise HTTPException(410, "该助手类型已下线")
|
raise HTTPException(410, "该助手类型已下线")
|
||||||
|
await _validate_task_backend(session, source)
|
||||||
assistant = Assistant(
|
assistant = Assistant(
|
||||||
id=f"asst_{uuid.uuid4().hex[:12]}",
|
id=f"asst_{uuid.uuid4().hex[:12]}",
|
||||||
name=f"{source.name} 副本",
|
name=f"{source.name} 副本",
|
||||||
@@ -739,6 +747,7 @@ async def duplicate_assistant(
|
|||||||
prompt=source.prompt,
|
prompt=source.prompt,
|
||||||
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
|
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
|
||||||
built_in_tools=dict(source.built_in_tools or {}),
|
built_in_tools=dict(source.built_in_tools or {}),
|
||||||
|
task_backend=dict(source.task_backend) if source.task_backend else None,
|
||||||
analysis_config=dict(source.analysis_config or {}),
|
analysis_config=dict(source.analysis_config or {}),
|
||||||
api_url=source.api_url,
|
api_url=source.api_url,
|
||||||
api_key=source.api_key,
|
api_key=source.api_key,
|
||||||
@@ -780,6 +789,7 @@ async def update_assistant(
|
|||||||
await _validate_vision_model(session, body)
|
await _validate_vision_model(session, body)
|
||||||
await _validate_analysis_model(session, body)
|
await _validate_analysis_model(session, body)
|
||||||
await _validate_knowledge_base(session, body)
|
await _validate_knowledge_base(session, body)
|
||||||
|
await _validate_task_backend(session, body)
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
resource_ids = data.pop("model_resource_ids")
|
resource_ids = data.pop("model_resource_ids")
|
||||||
tool_bindings = body.tool_bindings
|
tool_bindings = body.tool_bindings
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Administrator CRUD for reusable task backends."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from db.models import TaskBackendResource
|
||||||
|
from db.session import get_session
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from services.auth import require_admin
|
||||||
|
from services.masking import merge_secrets
|
||||||
|
from services.task_backend_resources import (
|
||||||
|
backend_consumers,
|
||||||
|
task_backend_to_out,
|
||||||
|
validate_backend_binding,
|
||||||
|
validate_backend_resource,
|
||||||
|
)
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from task_backend_schemas import TaskBackendOut, TaskBackendUpsert
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/task-backends",
|
||||||
|
tags=["task-backends"],
|
||||||
|
dependencies=[Depends(require_admin)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(body: TaskBackendUpsert, stored=None) -> dict:
|
||||||
|
stored_secrets = stored.secrets or {} if stored and stored.type == body.type else {}
|
||||||
|
return {
|
||||||
|
"name": body.name, "type": body.type, "description": body.description.strip(),
|
||||||
|
"config": body.config.model_dump(), "enabled": body.enabled,
|
||||||
|
"secrets": merge_secrets(body.secrets, stored_secrets) if body.type == "opencode" else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate(session, body: TaskBackendUpsert) -> None:
|
||||||
|
if not body.enabled:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await validate_backend_resource(session, body)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TaskBackendOut])
|
||||||
|
async def list_task_backends(session: AsyncSession = Depends(get_session)):
|
||||||
|
rows = (await session.execute(select(TaskBackendResource).order_by(TaskBackendResource.updated_at.desc()))).scalars().all()
|
||||||
|
return [task_backend_to_out(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TaskBackendOut)
|
||||||
|
async def create_task_backend(body: TaskBackendUpsert, session: AsyncSession = Depends(get_session)):
|
||||||
|
await _validate(session, body)
|
||||||
|
resource = TaskBackendResource(id=f"backend_{uuid.uuid4().hex[:12]}", **_payload(body))
|
||||||
|
session.add(resource)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(resource)
|
||||||
|
return task_backend_to_out(resource)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(session, backend_id: str) -> TaskBackendResource:
|
||||||
|
resource = await session.get(TaskBackendResource, backend_id)
|
||||||
|
if resource is None:
|
||||||
|
raise HTTPException(404, "任务后端不存在")
|
||||||
|
return resource
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{backend_id}", response_model=TaskBackendOut)
|
||||||
|
async def get_task_backend(backend_id: str, session: AsyncSession = Depends(get_session)):
|
||||||
|
return task_backend_to_out(await _get(session, backend_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{backend_id}", response_model=TaskBackendOut)
|
||||||
|
async def update_task_backend(backend_id: str, body: TaskBackendUpsert, session: AsyncSession = Depends(get_session)):
|
||||||
|
resource = await _get(session, backend_id)
|
||||||
|
await _validate(session, body)
|
||||||
|
payload = _payload(body, resource)
|
||||||
|
proposed = TaskBackendResource(id=resource.id, **payload)
|
||||||
|
consumers = await backend_consumers(session, backend_id)
|
||||||
|
# Disabling remains reversible; valid enabled configurations must work for all users.
|
||||||
|
if proposed.enabled:
|
||||||
|
try:
|
||||||
|
for assistant in consumers:
|
||||||
|
await validate_backend_binding(session, assistant, resource=proposed)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, f"助手 {assistant.name} 的任务配置无效: {exc}") from exc
|
||||||
|
for key, value in payload.items():
|
||||||
|
setattr(resource, key, value)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(resource)
|
||||||
|
return task_backend_to_out(resource)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{backend_id}/duplicate", response_model=TaskBackendOut)
|
||||||
|
async def duplicate_task_backend(backend_id: str, session: AsyncSession = Depends(get_session)):
|
||||||
|
source = await _get(session, backend_id)
|
||||||
|
body = TaskBackendUpsert(name=f"{source.name[:124]} 副本", type=source.type, description=source.description, config=source.config, secrets=source.secrets or {}, enabled=source.enabled)
|
||||||
|
return await create_task_backend(body, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{backend_id}")
|
||||||
|
async def delete_task_backend(backend_id: str, session: AsyncSession = Depends(get_session)):
|
||||||
|
resource = await _get(session, backend_id)
|
||||||
|
if await backend_consumers(session, backend_id):
|
||||||
|
raise HTTPException(409, "任务后端正被助手引用,请先解绑")
|
||||||
|
await session.delete(resource)
|
||||||
|
await session.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -100,7 +100,7 @@ def _payload(
|
|||||||
_normalize_http_template_inputs(definition)
|
_normalize_http_template_inputs(definition)
|
||||||
secrets = (
|
secrets = (
|
||||||
merge_secrets(body.secrets, stored_secrets or {})
|
merge_secrets(body.secrets, stored_secrets or {})
|
||||||
if definition["type"] in {"http", "task"}
|
if definition["type"] == "http"
|
||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
secret_variables = (secrets.get("dynamic_variables") or {}) if secrets else {}
|
secret_variables = (secrets.get("dynamic_variables") or {}) if secrets else {}
|
||||||
@@ -154,7 +154,7 @@ async def list_tools(session: AsyncSession = Depends(get_session)):
|
|||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Tool)
|
select(Tool)
|
||||||
.where(Tool.type.in_(("http", "mcp", "client", "task")))
|
.where(Tool.type.in_(("http", "mcp", "client")))
|
||||||
.order_by(Tool.updated_at.desc())
|
.order_by(Tool.updated_at.desc())
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|||||||
+10
-27
@@ -21,7 +21,7 @@ AssistantType = Literal["prompt", "workflow", "dify", "fastgpt"]
|
|||||||
SUPPORTED_ASSISTANT_TYPES = get_args(AssistantType)
|
SUPPORTED_ASSISTANT_TYPES = get_args(AssistantType)
|
||||||
TurnEndStrategy = Literal["silence", "smart_turn"]
|
TurnEndStrategy = Literal["silence", "smart_turn"]
|
||||||
KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
|
KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
|
||||||
ToolType = Literal["http", "mcp", "client", "task"]
|
ToolType = Literal["http", "mcp", "client"]
|
||||||
ToolStatus = Literal["active", "archived", "draft"]
|
ToolStatus = Literal["active", "archived", "draft"]
|
||||||
McpServerStatus = Literal["active", "archived", "draft"]
|
McpServerStatus = Literal["active", "archived", "draft"]
|
||||||
McpTransport = Literal["streamable_http", "sse"]
|
McpTransport = Literal["streamable_http", "sse"]
|
||||||
@@ -180,6 +180,12 @@ class ToolBinding(CamelModel):
|
|||||||
result_assignments: dict[str, str] = Field(default_factory=dict)
|
result_assignments: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantTaskBackendBinding(CamelModel):
|
||||||
|
backend_id: str = Field(min_length=1, max_length=40)
|
||||||
|
instruction_source: ValueSource = Field(default_factory=ModelValueSource)
|
||||||
|
instructions: str = Field(default="", max_length=12000)
|
||||||
|
|
||||||
|
|
||||||
class StartupAction(CamelModel):
|
class StartupAction(CamelModel):
|
||||||
id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_-]*$")
|
id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_-]*$")
|
||||||
phase: Literal["preflight", "opening"] = "opening"
|
phase: Literal["preflight", "opening"] = "opening"
|
||||||
@@ -233,6 +239,7 @@ class AssistantUpsert(CamelModel):
|
|||||||
default_factory=KnowledgeRetrievalConfig
|
default_factory=KnowledgeRetrievalConfig
|
||||||
)
|
)
|
||||||
tool_bindings: list[ToolBinding] = Field(default_factory=list)
|
tool_bindings: list[ToolBinding] = Field(default_factory=list)
|
||||||
|
task_backend: AssistantTaskBackendBinding | None = None
|
||||||
built_in_tools: "BuiltInTools" = Field(default_factory=lambda: BuiltInTools())
|
built_in_tools: "BuiltInTools" = Field(default_factory=lambda: BuiltInTools())
|
||||||
|
|
||||||
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
|
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
|
||||||
@@ -262,6 +269,8 @@ class AssistantUpsert(CamelModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _strip_irrelevant_fields(self):
|
def _strip_irrelevant_fields(self):
|
||||||
|
if self.task_backend and (self.type != "prompt" or self.runtime_mode != "pipeline"):
|
||||||
|
raise ValueError("任务后端仅支持提示词 Pipeline")
|
||||||
allowed = ALLOWED_FIELDS[self.type]
|
allowed = ALLOWED_FIELDS[self.type]
|
||||||
for field in ("prompt", "api_url", "api_key", "app_id"):
|
for field in ("prompt", "api_url", "api_key", "app_id"):
|
||||||
if field not in allowed:
|
if field not in allowed:
|
||||||
@@ -474,37 +483,11 @@ class McpToolDefinition(CamelModel):
|
|||||||
config: McpToolConfig
|
config: McpToolConfig
|
||||||
|
|
||||||
|
|
||||||
class TaskToolConfig(CamelModel):
|
|
||||||
provider: Literal["opencode"] = "opencode"
|
|
||||||
url: str = Field(min_length=1, max_length=2048)
|
|
||||||
directory: str = Field(min_length=1, max_length=2048)
|
|
||||||
username: str = Field(default="opencode", max_length=128)
|
|
||||||
timeout_seconds: int = Field(default=600, ge=30, le=7200)
|
|
||||||
allow_interruptions: Literal[True] = True
|
|
||||||
execution_mode: Literal["immediate"] = "immediate"
|
|
||||||
|
|
||||||
@field_validator("url")
|
|
||||||
@classmethod
|
|
||||||
def validate_task_url(cls, value: str) -> str:
|
|
||||||
from urllib.parse import urlsplit
|
|
||||||
parsed = urlsplit(value)
|
|
||||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
||||||
raise ValueError("任务服务须使用不含凭证、查询参数的 HTTP(S) 地址")
|
|
||||||
return value.rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
class TaskToolDefinition(CamelModel):
|
|
||||||
schema_version: int = 2
|
|
||||||
type: Literal["task"] = "task"
|
|
||||||
config: TaskToolConfig
|
|
||||||
|
|
||||||
|
|
||||||
ToolDefinition = Annotated[
|
ToolDefinition = Annotated[
|
||||||
Union[
|
Union[
|
||||||
HttpToolDefinition,
|
HttpToolDefinition,
|
||||||
ClientToolDefinition,
|
ClientToolDefinition,
|
||||||
McpToolDefinition,
|
McpToolDefinition,
|
||||||
TaskToolDefinition,
|
|
||||||
],
|
],
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from models import (
|
|||||||
RuntimeToolBinding,
|
RuntimeToolBinding,
|
||||||
)
|
)
|
||||||
from services.node_specs import copy_graph, graph_references
|
from services.node_specs import copy_graph, graph_references
|
||||||
|
from services.task_backend_resources import resolve_task_backend
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -174,6 +175,7 @@ async def _tools_for(
|
|||||||
.where(
|
.where(
|
||||||
or_(*identity_filters),
|
or_(*identity_filters),
|
||||||
Tool.status == "active",
|
Tool.status == "active",
|
||||||
|
Tool.type.in_(("http", "mcp", "client")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -323,6 +325,7 @@ async def resolve_runtime_config(
|
|||||||
built_in_tools=assistant.built_in_tools or {},
|
built_in_tools=assistant.built_in_tools or {},
|
||||||
tools=runtime_tools,
|
tools=runtime_tools,
|
||||||
tool_bindings=prompt_tool_bindings,
|
tool_bindings=prompt_tool_bindings,
|
||||||
|
task_backend=await resolve_task_backend(session, assistant),
|
||||||
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 "",
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ INTERFACE_DEFINITIONS: list[dict] = [
|
|||||||
"name": "OpenAI Compatible LLM",
|
"name": "OpenAI Compatible LLM",
|
||||||
"capability": "LLM",
|
"capability": "LLM",
|
||||||
"fields": OPENAI_COMMON
|
"fields": OPENAI_COMMON
|
||||||
+ [field("temperature", "Temperature", type_="number", default=0.7)],
|
+ [
|
||||||
|
field("temperature", "Temperature", type_="number", default=0.7),
|
||||||
|
field("supportsReasoningEffort", "Supports reasoning_effort", type_="boolean", default=False),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"interface_type": "openai-asr",
|
"interface_type": "openai-asr",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ SYSTEM_TOOL_SPECS: dict[str, dict[str, str]] = {
|
|||||||
SYSTEM_TOOL_KINDS = frozenset(SYSTEM_TOOL_SPECS)
|
SYSTEM_TOOL_KINDS = frozenset(SYSTEM_TOOL_SPECS)
|
||||||
RESERVED_TOOL_NAMES = frozenset(
|
RESERVED_TOOL_NAMES = frozenset(
|
||||||
spec["function_name"] for spec in SYSTEM_TOOL_SPECS.values()
|
spec["function_name"] for spec in SYSTEM_TOOL_SPECS.values()
|
||||||
) | frozenset({"get_background_task", "cancel_background_task", "send_background_task_message"})
|
) | frozenset({"delegate_task", "get_background_task", "cancel_background_task", "send_background_task_message", "complete_task", "ask_user"})
|
||||||
|
|
||||||
|
|
||||||
def system_tool_kind(definition: Mapping[str, Any] | None) -> str | None:
|
def system_tool_kind(definition: Mapping[str, Any] | None) -> str | None:
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Validate reusable task backends and resolve their server-only snapshots."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from db.models import Assistant, McpServer, ModelResource, TaskBackendResource, Tool
|
||||||
|
from models import (
|
||||||
|
RuntimeModelResource,
|
||||||
|
RuntimeTaskBackend,
|
||||||
|
RuntimeTool,
|
||||||
|
RuntimeToolBinding,
|
||||||
|
)
|
||||||
|
from schemas import AssistantTaskBackendBinding
|
||||||
|
from services.masking import mask_secrets
|
||||||
|
from services.system_tools import RESERVED_TOOL_NAMES
|
||||||
|
from services.tool_executor import ToolExecutor
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from task_backend_schemas import (
|
||||||
|
ReasonerBackendConfig,
|
||||||
|
TaskBackendOut,
|
||||||
|
TaskBackendUpsert,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}")
|
||||||
|
SYSTEM_VARIABLES = {
|
||||||
|
"system__agent_id", "system__conversation_id", "system__time", "system__time_utc",
|
||||||
|
"system__timezone", "system__agent_turns", "system__conversation_history",
|
||||||
|
"system__last_action_status", "system__last_action_invocation_id", "system__last_action_duration_ms",
|
||||||
|
"system__last_action_error", "system__last_action_error_code", "system__handoff_status",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def task_backend_to_out(resource: TaskBackendResource) -> TaskBackendOut:
|
||||||
|
return TaskBackendOut(
|
||||||
|
id=resource.id, name=resource.name, type=resource.type,
|
||||||
|
description=resource.description, config=resource.config,
|
||||||
|
secrets=mask_secrets(resource.secrets or {}), enabled=resource.enabled,
|
||||||
|
updated_at=resource.updated_at.isoformat() if resource.updated_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_source_variables(source: dict, variables: set[str]) -> None:
|
||||||
|
names = set()
|
||||||
|
if source.get("source") == "variable":
|
||||||
|
names.add(str(source.get("variable_name") or source.get("variableName") or ""))
|
||||||
|
elif source.get("source") == "template":
|
||||||
|
names.update(PLACEHOLDER.findall(json.dumps(source.get("value"), ensure_ascii=False)))
|
||||||
|
unknown = names - variables - SYSTEM_VARIABLES
|
||||||
|
if unknown:
|
||||||
|
raise ValueError("任务配置引用了未声明变量: " + ", ".join(sorted(unknown)))
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_backend_resource(
|
||||||
|
session,
|
||||||
|
body: TaskBackendUpsert,
|
||||||
|
*,
|
||||||
|
variables: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if body.type != "reasoner":
|
||||||
|
return
|
||||||
|
config = body.config
|
||||||
|
assert isinstance(config, ReasonerBackendConfig)
|
||||||
|
model = await session.get(ModelResource, config.model_resource_id)
|
||||||
|
if (
|
||||||
|
not model
|
||||||
|
or not model.enabled
|
||||||
|
or model.capability != "LLM"
|
||||||
|
or model.interface_type not in {"openai-llm", "dashscope-llm"}
|
||||||
|
):
|
||||||
|
raise ValueError("Reasoner 必须引用已启用的 OpenAI-compatible LLM 模型")
|
||||||
|
if (
|
||||||
|
config.reasoning_effort != "provider_default"
|
||||||
|
and (model.values or {}).get("supportsReasoningEffort") is not True
|
||||||
|
):
|
||||||
|
raise ValueError("该模型未声明支持 reasoning_effort,不能设置推理强度")
|
||||||
|
names = set()
|
||||||
|
for binding in config.tool_bindings:
|
||||||
|
tool = await session.get(Tool, binding.tool_id)
|
||||||
|
if not tool or tool.status != "active" or tool.type not in {"http", "mcp"}:
|
||||||
|
raise ValueError("Reasoner 仅允许已启用的 HTTP/MCP 服务端工具: " + binding.tool_id)
|
||||||
|
if tool.function_name in RESERVED_TOOL_NAMES or tool.function_name in names:
|
||||||
|
raise ValueError("Reasoner 工具函数名重复或属于平台保留名称: " + tool.function_name)
|
||||||
|
names.add(tool.function_name)
|
||||||
|
if tool.type == "mcp":
|
||||||
|
server = await session.get(McpServer, tool.mcp_server_id) if tool.mcp_server_id else None
|
||||||
|
if not server or server.status != "active":
|
||||||
|
raise ValueError("Reasoner MCP 工具的 Server 不存在或未启用")
|
||||||
|
runtime_tool = RuntimeTool(
|
||||||
|
id=tool.id,
|
||||||
|
name=tool.name,
|
||||||
|
function_name=tool.function_name,
|
||||||
|
type=tool.type,
|
||||||
|
definition=tool.definition or {},
|
||||||
|
)
|
||||||
|
properties, required = ToolExecutor.input_schema(runtime_tool)
|
||||||
|
if set(binding.argument_bindings) - set(properties):
|
||||||
|
raise ValueError("Reasoner 工具绑定引用了未知参数")
|
||||||
|
if set(required) - set(binding.argument_bindings):
|
||||||
|
raise ValueError("Reasoner 工具绑定缺少必填参数")
|
||||||
|
template_inputs = ((tool.definition or {}).get("config") or {}).get("template_inputs") or []
|
||||||
|
template_names = {item["name"] for item in template_inputs if item.get("name")}
|
||||||
|
required_templates = {item["name"] for item in template_inputs if item.get("name") and item.get("required", True)}
|
||||||
|
if set(binding.template_bindings) - template_names or required_templates - set(binding.template_bindings):
|
||||||
|
raise ValueError("Reasoner 工具模板输入缺失或引用了未知模板输入")
|
||||||
|
if variables is not None:
|
||||||
|
for source in [*binding.argument_bindings.values(), *binding.template_bindings.values()]:
|
||||||
|
validate_source_variables(source.model_dump(), variables)
|
||||||
|
unknown_targets = set(binding.result_assignments) - variables
|
||||||
|
if unknown_targets:
|
||||||
|
raise ValueError("Reasoner 结果映射目标未声明: " + ", ".join(sorted(unknown_targets)))
|
||||||
|
|
||||||
|
|
||||||
|
async def backend_consumers(session, backend_id: str) -> list[Assistant]:
|
||||||
|
return list((await session.execute(select(Assistant).where(or_(
|
||||||
|
Assistant.task_backend["backend_id"].astext == backend_id,
|
||||||
|
Assistant.task_backend["backendId"].astext == backend_id,
|
||||||
|
)))).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_backend_binding(
|
||||||
|
session, assistant, *, resource=None
|
||||||
|
) -> TaskBackendResource | None:
|
||||||
|
raw = getattr(assistant, "task_backend", None)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
if assistant.type != "prompt" or getattr(assistant, "runtime_mode", "pipeline") != "pipeline":
|
||||||
|
raise ValueError("任务后端仅支持提示词 Pipeline")
|
||||||
|
binding = (
|
||||||
|
raw
|
||||||
|
if isinstance(raw, AssistantTaskBackendBinding)
|
||||||
|
else AssistantTaskBackendBinding.model_validate(raw)
|
||||||
|
)
|
||||||
|
resource = resource or await session.get(TaskBackendResource, binding.backend_id)
|
||||||
|
if resource is None or not resource.enabled:
|
||||||
|
raise ValueError("任务后端不存在或未启用")
|
||||||
|
variables = set(assistant.dynamic_variable_definitions or {})
|
||||||
|
validate_source_variables(binding.instruction_source.model_dump(), variables)
|
||||||
|
validate_source_variables({"source": "template", "value": binding.instructions}, variables)
|
||||||
|
body = TaskBackendUpsert(name=resource.name, type=resource.type, config=resource.config)
|
||||||
|
await validate_backend_resource(session, body, variables=variables)
|
||||||
|
return resource
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_task_backend(session, assistant) -> RuntimeTaskBackend | None:
|
||||||
|
resource = await validate_backend_binding(session, assistant)
|
||||||
|
if resource is None:
|
||||||
|
return None
|
||||||
|
binding = AssistantTaskBackendBinding.model_validate(assistant.task_backend)
|
||||||
|
body = TaskBackendUpsert(name=resource.name, type=resource.type, config=resource.config)
|
||||||
|
config = body.config.model_dump()
|
||||||
|
model = None
|
||||||
|
tool_bindings = []
|
||||||
|
tools = []
|
||||||
|
if resource.type == "reasoner":
|
||||||
|
from services.config_resolver import _tools_for
|
||||||
|
|
||||||
|
row = await session.get(ModelResource, config["model_resource_id"])
|
||||||
|
model = RuntimeModelResource(
|
||||||
|
id=row.id, name=row.name, capability=row.capability, interface_type=row.interface_type,
|
||||||
|
values=row.values or {}, secrets=row.secrets or {}, support_image_input=bool(row.support_image_input),
|
||||||
|
)
|
||||||
|
tool_bindings = [RuntimeToolBinding(**item) for item in config["tool_bindings"]]
|
||||||
|
tools = await _tools_for(session, assistant, [item.tool_id for item in tool_bindings])
|
||||||
|
if len(tools) != len(tool_bindings):
|
||||||
|
raise ValueError("Reasoner 工具解析失败,请检查工具与 MCP Server")
|
||||||
|
return RuntimeTaskBackend(
|
||||||
|
id=resource.id, name=resource.name, type=resource.type, config=config,
|
||||||
|
secrets=resource.secrets or {}, model=model, tools=tools, tool_bindings=tool_bindings,
|
||||||
|
instruction_source=binding.instruction_source.model_dump(), instructions=binding.instructions,
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Public task backend configuration; runtime credentials stay server-side."""
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from pydantic import ConfigDict, Field, field_validator, model_validator
|
||||||
|
from schemas import CamelModel, ToolBinding
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBackendConfig(CamelModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
instructions: str = Field(default="", max_length=16000)
|
||||||
|
timeout_seconds: int = Field(default=600, ge=30, le=7200)
|
||||||
|
max_concurrency: Literal[1] = 1
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSkill(CamelModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
content: str = Field(min_length=1, max_length=16000)
|
||||||
|
|
||||||
|
@field_validator("name", "content")
|
||||||
|
@classmethod
|
||||||
|
def require_nonempty(cls, value: str) -> str:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError("技能名称和正文不能为空")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ReasonerBackendConfig(TaskBackendConfig):
|
||||||
|
model_resource_id: str = Field(min_length=1, max_length=40)
|
||||||
|
reasoning_effort: Literal["provider_default", "low", "medium", "high"] = "provider_default"
|
||||||
|
tool_bindings: list[ToolBinding] = Field(default_factory=list, max_length=50)
|
||||||
|
skills: list[TaskSkill] = Field(default_factory=list, max_length=20)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def unique_bindings_and_skills(self):
|
||||||
|
ids = [binding.tool_id for binding in self.tool_bindings]
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
raise ValueError("Reasoner 工具绑定不能重复")
|
||||||
|
names = [skill.name for skill in self.skills]
|
||||||
|
if len(names) != len(set(names)):
|
||||||
|
raise ValueError("技能名称不能重复")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class OpenCodeBackendConfig(TaskBackendConfig):
|
||||||
|
url: str = Field(min_length=1, max_length=2048)
|
||||||
|
directory: str = Field(min_length=1, max_length=2048)
|
||||||
|
username: str = Field(default="opencode", max_length=128)
|
||||||
|
agent: str = Field(default="", max_length=128)
|
||||||
|
model_provider_id: str = Field(default="", max_length=128)
|
||||||
|
model_id: str = Field(default="", max_length=256)
|
||||||
|
skills_note: str = Field(default="", max_length=4000)
|
||||||
|
|
||||||
|
@field_validator("url")
|
||||||
|
@classmethod
|
||||||
|
def validate_url(cls, value: str) -> str:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||||
|
raise ValueError("任务服务须使用不含凭证、查询参数的 HTTP(S) 地址")
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
@field_validator("directory")
|
||||||
|
@classmethod
|
||||||
|
def require_directory(cls, value: str) -> str:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError("必须填写远端工作目录")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_model_pair(self):
|
||||||
|
if bool(self.model_provider_id) != bool(self.model_id):
|
||||||
|
raise ValueError("远端模型的 provider ID 和 model ID 必须同时填写")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBackendUpsert(CamelModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
type: Literal["reasoner", "opencode"]
|
||||||
|
description: str = Field(default="", max_length=2048)
|
||||||
|
config: ReasonerBackendConfig | OpenCodeBackendConfig
|
||||||
|
secrets: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def select_config(cls, value):
|
||||||
|
if isinstance(value, dict) and value.get("type") in {"reasoner", "opencode"}:
|
||||||
|
value = dict(value)
|
||||||
|
config_class = ReasonerBackendConfig if value["type"] == "reasoner" else OpenCodeBackendConfig
|
||||||
|
raw = value.get("config") or {}
|
||||||
|
if isinstance(raw, CamelModel):
|
||||||
|
raw = raw.model_dump()
|
||||||
|
value["config"] = config_class.model_validate(raw)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def require_name(cls, value: str) -> str:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError("后端名称不能为空")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBackendOut(TaskBackendUpsert):
|
||||||
|
id: str
|
||||||
|
updated_at: str | None = None
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from db.models import Assistant, ModelResource, TaskBackendResource, Tool
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from routes import task_backends as routes
|
||||||
|
from schemas import AssistantOut, AssistantUpsert, ToolUpsert
|
||||||
|
from services.auth import require_admin
|
||||||
|
from services.task_backend_resources import resolve_task_backend, validate_backend_binding, validate_backend_resource
|
||||||
|
from task_backend_schemas import TaskBackendUpsert
|
||||||
|
|
||||||
|
|
||||||
|
def opencode_body(**changes):
|
||||||
|
return TaskBackendUpsert(name="远端任务", type="opencode", config={"url": "https://agent.test", "directory": "/work"}, **changes)
|
||||||
|
|
||||||
|
|
||||||
|
class MemorySession:
|
||||||
|
"""Explicit test-only repository for API/config boundary tests."""
|
||||||
|
|
||||||
|
def __init__(self, *rows):
|
||||||
|
self.rows = {(type(row), row.id): row for row in rows}
|
||||||
|
|
||||||
|
async def get(self, model, key):
|
||||||
|
return self.rows.get((model, key))
|
||||||
|
|
||||||
|
def add(self, row):
|
||||||
|
self.rows[type(row), row.id] = row
|
||||||
|
|
||||||
|
async def delete(self, row):
|
||||||
|
self.rows.pop((type(row), row.id))
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def refresh(self, row):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def execute(self, statement):
|
||||||
|
model = statement.column_descriptions[0]["entity"]
|
||||||
|
rows = [row for (kind, _), row in self.rows.items() if kind is model]
|
||||||
|
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows))
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBackendSchemaTests(unittest.TestCase):
|
||||||
|
def test_task_is_not_an_ordinary_tool_resource(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
ToolUpsert(name="旧工具", functionName="coding_task", definition={"type": "task", "config": {"url": "https://agent.test", "directory": "/work"}})
|
||||||
|
|
||||||
|
def test_backend_config_requires_real_supported_fields(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "Extra inputs"):
|
||||||
|
TaskBackendUpsert(name="后端", type="opencode", config={"url": "https://agent.test", "directory": "/work", "grantAllPermissions": True})
|
||||||
|
with self.assertRaisesRegex(ValueError, "同时填写"):
|
||||||
|
TaskBackendUpsert(name="后端", type="opencode", config={"url": "https://agent.test", "directory": "/work", "modelId": "model"})
|
||||||
|
|
||||||
|
def test_reasoner_rejects_duplicate_tools_and_skills(self):
|
||||||
|
for extra in [
|
||||||
|
{"toolBindings": [{"toolId": "http_1"}, {"toolId": "http_1"}]},
|
||||||
|
{"skills": [{"name": "review", "content": "a"}, {"name": " review ", "content": "b"}]},
|
||||||
|
]:
|
||||||
|
with self.subTest(extra=extra), self.assertRaisesRegex(ValueError, "重复"):
|
||||||
|
TaskBackendUpsert(name="推理", type="reasoner", config={"modelResourceId": "llm", **extra})
|
||||||
|
|
||||||
|
def test_assistant_binding_preserves_all_instruction_sources(self):
|
||||||
|
for source in [
|
||||||
|
{"source": "model"}, {"source": "literal", "value": "固定任务"},
|
||||||
|
{"source": "variable", "variableName": "goal"},
|
||||||
|
{"source": "template", "value": "处理 {{goal}}"},
|
||||||
|
]:
|
||||||
|
with self.subTest(source=source):
|
||||||
|
body = AssistantUpsert(name="助手", taskBackend={"backendId": "legacy_tool_id", "instructions": "补充说明", "instructionSource": source})
|
||||||
|
out = AssistantOut(id="asst", **body.model_dump()).model_dump(by_alias=True)
|
||||||
|
self.assertEqual(out["taskBackend"]["instructionSource"], source)
|
||||||
|
self.assertEqual(out["taskBackend"]["instructions"], "补充说明")
|
||||||
|
|
||||||
|
def test_task_backend_is_prompt_pipeline_only(self):
|
||||||
|
for options in [{"type": "workflow"}, {"runtimeMode": "realtime"}, {"type": "dify"}]:
|
||||||
|
with self.subTest(options=options), self.assertRaisesRegex(ValueError, "提示词 Pipeline"):
|
||||||
|
AssistantUpsert(name="助手", taskBackend={"backendId": "backend_1"}, **options)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBackendValidationTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.model = ModelResource(id="llm", name="深思考", capability="LLM", interface_type="openai-llm", values={}, secrets={"apiKey": "model-secret"}, enabled=True)
|
||||||
|
self.session = MemorySession(self.model)
|
||||||
|
|
||||||
|
async def test_reasoning_strength_requires_explicit_model_capability(self):
|
||||||
|
body = TaskBackendUpsert(name="推理", type="reasoner", config={"modelResourceId": "llm", "reasoningEffort": "high"})
|
||||||
|
with self.assertRaisesRegex(ValueError, "未声明"):
|
||||||
|
await validate_backend_resource(self.session, body)
|
||||||
|
self.model.values = {"supportsReasoningEffort": True}
|
||||||
|
await validate_backend_resource(self.session, body)
|
||||||
|
|
||||||
|
async def test_reasoner_rejects_client_tools_and_unknown_required_inputs(self):
|
||||||
|
tool = Tool(id="tool", name="工具", function_name="read_data", type="client", status="active", definition={"config": {}})
|
||||||
|
self.session.add(tool)
|
||||||
|
body = TaskBackendUpsert(name="推理", type="reasoner", config={"modelResourceId": "llm", "toolBindings": [{"toolId": "tool"}]})
|
||||||
|
with self.assertRaisesRegex(ValueError, "HTTP/MCP"):
|
||||||
|
await validate_backend_resource(self.session, body)
|
||||||
|
tool.type = "http"
|
||||||
|
tool.definition = {"config": {"parameters": [{"name": "query", "required": True, "type": "string"}]}}
|
||||||
|
with self.assertRaisesRegex(ValueError, "缺少必填"):
|
||||||
|
await validate_backend_resource(self.session, body)
|
||||||
|
|
||||||
|
async def test_template_sources_are_checked_against_actual_assistant(self):
|
||||||
|
resource = TaskBackendResource(id="backend", **routes._payload(opencode_body()))
|
||||||
|
self.session.add(resource)
|
||||||
|
body = AssistantUpsert(name="助手", taskBackend={"backendId": "backend", "instructionSource": {"source": "template", "value": "处理 {{missing}}"}})
|
||||||
|
with self.assertRaisesRegex(ValueError, "未声明变量"):
|
||||||
|
await validate_backend_binding(self.session, body)
|
||||||
|
body.dynamic_variable_definitions = {"missing": {"type": "string"}}
|
||||||
|
await validate_backend_binding(self.session, body)
|
||||||
|
|
||||||
|
async def test_runtime_snapshot_resolves_backend_and_model_separately(self):
|
||||||
|
resource = TaskBackendResource(id="backend", name="推理", type="reasoner", config={"model_resource_id": "llm", "reasoning_effort": "provider_default"}, secrets={}, enabled=True)
|
||||||
|
self.session.add(resource)
|
||||||
|
assistant = Assistant(id="assistant", name="助手", type="prompt", runtime_mode="pipeline", task_backend={"backend_id": "backend", "instruction_source": {"source": "literal", "value": "固定任务"}}, dynamic_variable_definitions={})
|
||||||
|
resolved = await resolve_task_backend(self.session, assistant)
|
||||||
|
self.assertEqual(resolved.model.id, "llm")
|
||||||
|
self.assertEqual(resolved.model.secrets["apiKey"], "model-secret")
|
||||||
|
self.assertEqual(resolved.instruction_source["value"], "固定任务")
|
||||||
|
self.assertEqual(resolved.config["max_concurrency"], 1)
|
||||||
|
|
||||||
|
async def test_masked_secret_preserves_stored_value_and_type_switch_discards_it(self):
|
||||||
|
stored = TaskBackendResource(type="opencode", secrets={"password": "stored-secret"})
|
||||||
|
payload = routes._payload(opencode_body(secrets={"password": "****"}), stored)
|
||||||
|
self.assertEqual(payload["secrets"]["password"], "stored-secret")
|
||||||
|
reasoner = TaskBackendUpsert(name="推理", type="reasoner", config={"modelResourceId": "llm"})
|
||||||
|
self.assertEqual(routes._payload(reasoner, stored)["secrets"], {})
|
||||||
|
|
||||||
|
async def test_resource_api_requires_admin_and_masks_passwords(self):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes.router)
|
||||||
|
async def session_dependency():
|
||||||
|
yield self.session
|
||||||
|
app.dependency_overrides[routes.get_session] = session_dependency
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
self.assertEqual((await client.get("/api/task-backends")).status_code, 401)
|
||||||
|
app.dependency_overrides[require_admin] = lambda: True
|
||||||
|
response = await client.post("/api/task-backends", json=opencode_body(secrets={"password": "keep-me-private"}).model_dump(by_alias=True))
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
backend_id = response.json()["id"]
|
||||||
|
self.assertNotIn("keep-me-private", response.text)
|
||||||
|
data = response.json()
|
||||||
|
data["name"] = "修改后端"
|
||||||
|
response = await client.put(f"/api/task-backends/{backend_id}", json=data)
|
||||||
|
self.assertEqual(response.status_code, 200, response.text)
|
||||||
|
stored = await self.session.get(TaskBackendResource, backend_id)
|
||||||
|
self.assertEqual(stored.secrets["password"], "keep-me-private")
|
||||||
|
duplicate = await client.post(f"/api/task-backends/{backend_id}/duplicate")
|
||||||
|
self.assertEqual(duplicate.status_code, 200, duplicate.text)
|
||||||
|
self.assertNotIn("keep-me-private", duplicate.text)
|
||||||
|
self.assertNotEqual(duplicate.json()["id"], backend_id)
|
||||||
|
self.assertEqual((await client.delete(f"/api/task-backends/{backend_id}")).status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -21,3 +21,7 @@
|
|||||||
| B-016 | MVP执行环境 | 内置Reasoner不提供宿主机Shell/文件沙箱;编码由OpenCode处理,界面明确实际权限由执行端配置 | semantic | default |
|
| B-016 | MVP执行环境 | 内置Reasoner不提供宿主机Shell/文件沙箱;编码由OpenCode处理,界面明确实际权限由执行端配置 | semantic | default |
|
||||||
| B-017 | F0过渡 | Walking skeleton短暂兼容旧tool提交入口以保持累计检查;迁移阶段移除该过渡入口,不保留为新产品接口 | interface | default |
|
| B-017 | F0过渡 | Walking skeleton短暂兼容旧tool提交入口以保持累计检查;迁移阶段移除该过渡入口,不保留为新产品接口 | interface | default |
|
||||||
| B-018 | 回退迁移 | 0027为数据语义迁移,downgrade明确拒绝,需备份恢复;不能把新的Reasoner配置静默回退成旧task工具 | semantic | default |
|
| B-018 | 回退迁移 | 0027为数据语义迁移,downgrade明确拒绝,需备份恢复;不能把新的Reasoner配置静默回退成旧task工具 | semantic | default |
|
||||||
|
| B-019 | 切换Realtime | 已绑定任务后端时复用现有配置移除确认交互,确认切换后解除绑定,后端资源保留 | semantic | default |
|
||||||
|
| B-020 | 配置有效性 | 后端配置拒绝未知字段;资源编辑验证已绑定助手,模型/工具失效时阻止保存和执行,不静默漏掉能力 | semantic | default |
|
||||||
|
| B-021 | 类型与凭据 | 后端资源可切换类型,但必须提交完整合法配置;OpenCode转Reasoner清除服务凭据,同类型空值/掩码保留原凭据 | interface | default |
|
||||||
|
| B-022 | 指令模板 | 助手绑定 instructions 的 {{变量}} 使用任务接受时冻结的变量单次渲染;资源 instructions 和技能正文按字面使用,不访问密钥变量 | semantic | default |
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
| Rung | Feature | Acceptance command | Tier | Status |
|
| Rung | Feature | Acceptance command | Tier | Status |
|
||||||
|------|---------|--------------------|------|--------|
|
|------|---------|--------------------|------|--------|
|
||||||
| F0 | 新后端输入→原生Job→记录主线 | cd backend && .venv/bin/python -m unittest tests.test_task_backend_spine | MUST | passed |
|
| F0 | 新后端输入→原生Job→记录主线 | cd backend && .venv/bin/python -m unittest tests.test_task_backend_spine | MUST | passed |
|
||||||
| F1 | 后端资源、助手配置、旧数据迁移 | cd backend && .venv/bin/python -m unittest tests.test_task_backend_resources | MUST | pending |
|
| F1 | 后端资源、助手配置、旧数据迁移 | cd backend && .venv/bin/python -m unittest tests.test_task_backend_resources | MUST | passed |
|
||||||
| F2 | 原生Reasoner、OpenCode、委派桥 | cd backend && .venv/bin/python -m unittest tests.test_reasoner_backend tests.test_prompt_background_tasks tests.test_background_tasks | MUST | pending |
|
| F2 | 原生Reasoner、OpenCode、委派桥 | cd backend && .venv/bin/python -m unittest tests.test_reasoner_backend tests.test_prompt_background_tasks tests.test_background_tasks | MUST | pending |
|
||||||
| F3 | 任务后端资源页与提示词编辑器 | cd frontend && npx tsc --noEmit | MUST | pending |
|
| F3 | 任务后端资源页与提示词编辑器 | cd frontend && npx tsc --noEmit | MUST | pending |
|
||||||
| F4 | 全量回归、隔离数据库迁移与审阅 | make test | MUST | pending |
|
| F4 | 全量回归、隔离数据库迁移与审阅 | make test | MUST | pending |
|
||||||
|
|
||||||
## Run record
|
## Run record
|
||||||
- F0:新后端资源输入 + 原生Jobs + 旧生命周期累计测试14项,exit 0。显式模拟执行器;backend-spine-smoke.json 已生成。
|
- F0:新后端资源输入 + 原生Jobs + 旧生命周期累计测试14项,exit 0。显式模拟执行器;backend-spine-smoke.json 已生成。
|
||||||
|
- F1:12项资源专项测试通过,连同原生主线、桥接、生命周期累计33项通过,exit 0;真实隔离 PostgreSQL 17 完成0026旧数据→0027迁移及CRUD/助手复制/运行时解析,见database-smoke.json。
|
||||||
|
|
||||||
## Deferred
|
## Deferred
|
||||||
- 真实模型/OpenCode联调:尚无服务地址与凭据。
|
- 真实模型/OpenCode联调:尚无服务地址与凭据。
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"database": "isolated disposable PostgreSQL 17 + pgvector Docker container",
|
||||||
|
"production_database_touched": false,
|
||||||
|
"environment_file_modified": false,
|
||||||
|
"temporary_container_removed": true,
|
||||||
|
"migration": "empty -> 0026; seeded legacy configurations -> 0027",
|
||||||
|
"checks": [
|
||||||
|
"model/literal/variable/template instruction sources preserved",
|
||||||
|
"backend IDs, connection configuration and service credentials preserved",
|
||||||
|
"legacy task tool rows and ordinary tool bindings removed",
|
||||||
|
"history IDs, status, sequence, connection and remote handles unchanged; backend metadata mapped; deadline NULL",
|
||||||
|
"real PostgreSQL resource create/list/update/duplicate/delete and bound-delete 409",
|
||||||
|
"real PostgreSQL assistant create/duplicate and runtime model/skill/backend resolution",
|
||||||
|
"API secrets masked; masked update preserves stored credentials"
|
||||||
|
],
|
||||||
|
"live_model_or_opencode_called": false,
|
||||||
|
"scope": "database persistence/configuration validation only; no claims of external model or OpenCode end-to-end execution"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user