F1: add reusable task backend resources and migrate task tools
This commit is contained in:
@@ -26,6 +26,7 @@ from services.state_expressions import StateExpressionError, validate_update_rul
|
||||
from services.system_tools import RESERVED_TOOL_NAMES
|
||||
from services.tool_policy import policy_for_tool
|
||||
from services.tool_executor import ToolExecutor
|
||||
from services.task_backend_resources import validate_backend_binding
|
||||
from services.workflow_engine import WorkflowEngine
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -242,11 +243,12 @@ async def _validate_tool_bindings(
|
||||
missing = sorted(tool_ids - set(tools))
|
||||
if missing:
|
||||
raise HTTPException(400, "工具不存在或未启用: " + ", ".join(missing))
|
||||
task_tools = [tool for tool in tools.values() if tool.type == "task"]
|
||||
if task_tools and (body.type != "prompt" or body.runtime_mode != "pipeline"):
|
||||
raise HTTPException(400, "后台任务工具仅支持提示词 Pipeline")
|
||||
if len(task_tools) > 1:
|
||||
raise HTTPException(400, "每个助手最多绑定一个后台任务工具")
|
||||
if any(tool.type not in {"http", "mcp", "client"} for tool in tools.values()):
|
||||
for scope, raw in raw_bindings:
|
||||
tool = tools.get(raw.get("tool_id") or raw.get("toolId"))
|
||||
if scope.startswith(("Action ", "Startup ")) and tool and tool.type == "task":
|
||||
raise HTTPException(400, "任务后端不支持启动 Action")
|
||||
raise HTTPException(400, "普通工具仅支持 HTTP/MCP/Client;任务请配置任务后端")
|
||||
reserved = sorted(
|
||||
tool.function_name
|
||||
for tool in tools.values()
|
||||
@@ -287,10 +289,6 @@ async def _validate_tool_bindings(
|
||||
type=tool.type,
|
||||
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)
|
||||
unknown = sorted(set(binding.argument_bindings) - set(properties))
|
||||
if unknown:
|
||||
@@ -506,6 +504,13 @@ async def _validate_knowledge_base(session: AsyncSession, body: AssistantUpsert)
|
||||
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(
|
||||
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
|
||||
) -> None:
|
||||
@@ -641,6 +646,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
||||
else []
|
||||
),
|
||||
built_in_tools=assistant.built_in_tools or {},
|
||||
task_backend=getattr(assistant, "task_backend", None),
|
||||
prompt=assistant.prompt,
|
||||
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
||||
analysis_config=assistant.analysis_config or {},
|
||||
@@ -681,6 +687,7 @@ async def create_assistant(
|
||||
await _validate_vision_model(session, body)
|
||||
await _validate_analysis_model(session, body)
|
||||
await _validate_knowledge_base(session, body)
|
||||
await _validate_task_backend(session, body)
|
||||
data = body.model_dump()
|
||||
resource_ids = data.pop("model_resource_ids")
|
||||
tool_bindings = body.tool_bindings
|
||||
@@ -721,6 +728,7 @@ async def duplicate_assistant(
|
||||
raise HTTPException(404, "助手不存在")
|
||||
if source.type not in SUPPORTED_ASSISTANT_TYPES:
|
||||
raise HTTPException(410, "该助手类型已下线")
|
||||
await _validate_task_backend(session, source)
|
||||
assistant = Assistant(
|
||||
id=f"asst_{uuid.uuid4().hex[:12]}",
|
||||
name=f"{source.name} 副本",
|
||||
@@ -739,6 +747,7 @@ async def duplicate_assistant(
|
||||
prompt=source.prompt,
|
||||
dynamic_variable_definitions=dict(source.dynamic_variable_definitions 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 {}),
|
||||
api_url=source.api_url,
|
||||
api_key=source.api_key,
|
||||
@@ -780,6 +789,7 @@ async def update_assistant(
|
||||
await _validate_vision_model(session, body)
|
||||
await _validate_analysis_model(session, body)
|
||||
await _validate_knowledge_base(session, body)
|
||||
await _validate_task_backend(session, body)
|
||||
data = body.model_dump()
|
||||
resource_ids = data.pop("model_resource_ids")
|
||||
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)
|
||||
secrets = (
|
||||
merge_secrets(body.secrets, stored_secrets or {})
|
||||
if definition["type"] in {"http", "task"}
|
||||
if definition["type"] == "http"
|
||||
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 = (
|
||||
await session.execute(
|
||||
select(Tool)
|
||||
.where(Tool.type.in_(("http", "mcp", "client", "task")))
|
||||
.where(Tool.type.in_(("http", "mcp", "client")))
|
||||
.order_by(Tool.updated_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
Reference in New Issue
Block a user