F1: add reusable task backend resources and migrate task tools

This commit is contained in:
Xin Wang
2026-10-23 23:45:50 +08:00
parent e958491201
commit 529250173d
17 changed files with 741 additions and 43 deletions
+2
View File
@@ -50,6 +50,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from routes import (
assistants,
background_tasks,
task_backends,
auth,
conversations,
health,
@@ -198,6 +199,7 @@ app.include_router(test_assets.router)
app.include_router(test_runs.router)
app.include_router(tools.router)
app.include_router(background_tasks.router)
app.include_router(task_backends.router)
app.include_router(voice_webrtc.router)
app.include_router(voice_ws.router)
+27 -2
View File
@@ -177,6 +177,7 @@ class Assistant(Base):
prompt: Mapped[str] = mapped_column(Text, default="") # prompt
dynamic_variable_definitions: Mapped[dict] = mapped_column(JSON, 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)
api_url: 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):
"""Reusable LLM tool definition; supported types are executed at runtime."""
@@ -248,7 +270,7 @@ class Tool(Base):
name="uq_tools_mcp_server_remote_name",
),
CheckConstraint(
"type IN ('http', 'mcp', 'client', 'task')",
"type IN ('http', 'mcp', 'client')",
name="ck_tools_external_type",
),
)
@@ -655,7 +677,10 @@ class BackgroundTask(Base):
id: Mapped[str] = mapped_column(String(40), primary_key=True)
assistant_id: Mapped[str] = mapped_column(String(40), 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)
origin_key: Mapped[str] = mapped_column(String(256), unique=True)
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."
)
+16
View File
@@ -70,6 +70,21 @@ class RuntimeKnowledgeBase(BaseModel):
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):
"""运行时配置:前端可见部分(name/prompt/...) + 服务端注入部分(*_api_key/*_base_url)。"""
@@ -138,6 +153,7 @@ class AssistantConfig(BaseModel):
# and result mappings; platform built-ins are configured separately.
tools: list[RuntimeTool] = 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_name: str = ""
knowledge_base_description: str = ""
+19 -9
View File
@@ -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
+109
View File
@@ -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}
+2 -2
View File
@@ -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()
+10 -27
View File
@@ -21,7 +21,7 @@ AssistantType = Literal["prompt", "workflow", "dify", "fastgpt"]
SUPPORTED_ASSISTANT_TYPES = get_args(AssistantType)
TurnEndStrategy = Literal["silence", "smart_turn"]
KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
ToolType = Literal["http", "mcp", "client", "task"]
ToolType = Literal["http", "mcp", "client"]
ToolStatus = Literal["active", "archived", "draft"]
McpServerStatus = Literal["active", "archived", "draft"]
McpTransport = Literal["streamable_http", "sse"]
@@ -180,6 +180,12 @@ class ToolBinding(CamelModel):
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):
id: str = Field(min_length=1, max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_-]*$")
phase: Literal["preflight", "opening"] = "opening"
@@ -233,6 +239,7 @@ class AssistantUpsert(CamelModel):
default_factory=KnowledgeRetrievalConfig
)
tool_bindings: list[ToolBinding] = Field(default_factory=list)
task_backend: AssistantTaskBackendBinding | None = None
built_in_tools: "BuiltInTools" = Field(default_factory=lambda: BuiltInTools())
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
@@ -262,6 +269,8 @@ class AssistantUpsert(CamelModel):
@model_validator(mode="after")
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]
for field in ("prompt", "api_url", "api_key", "app_id"):
if field not in allowed:
@@ -474,37 +483,11 @@ class McpToolDefinition(CamelModel):
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[
Union[
HttpToolDefinition,
ClientToolDefinition,
McpToolDefinition,
TaskToolDefinition,
],
Field(discriminator="type"),
]
+3
View File
@@ -22,6 +22,7 @@ from models import (
RuntimeToolBinding,
)
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.ext.asyncio import AsyncSession
@@ -174,6 +175,7 @@ async def _tools_for(
.where(
or_(*identity_filters),
Tool.status == "active",
Tool.type.in_(("http", "mcp", "client")),
)
)
).scalars().all()
@@ -323,6 +325,7 @@ async def resolve_runtime_config(
built_in_tools=assistant.built_in_tools or {},
tools=runtime_tools,
tool_bindings=prompt_tool_bindings,
task_backend=await resolve_task_backend(session, assistant),
knowledge_base_id=assistant.knowledge_base_id,
knowledge_base_name=knowledge_base.name if knowledge_base else "",
knowledge_base_description=knowledge_base.description if knowledge_base else "",
+4 -1
View File
@@ -52,7 +52,10 @@ INTERFACE_DEFINITIONS: list[dict] = [
"name": "OpenAI Compatible LLM",
"capability": "LLM",
"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",
+1 -1
View File
@@ -34,7 +34,7 @@ SYSTEM_TOOL_SPECS: dict[str, dict[str, str]] = {
SYSTEM_TOOL_KINDS = frozenset(SYSTEM_TOOL_SPECS)
RESERVED_TOOL_NAMES = frozenset(
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:
+171
View File
@@ -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,
)
+109
View File
@@ -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-017 | F0过渡 | Walking skeleton短暂兼容旧tool提交入口以保持累计检查;迁移阶段移除该过渡入口,不保留为新产品接口 | interface | 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 |
+2 -1
View File
@@ -3,13 +3,14 @@
| Rung | Feature | Acceptance command | Tier | Status |
|------|---------|--------------------|------|--------|
| 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 |
| F3 | 任务后端资源页与提示词编辑器 | cd frontend && npx tsc --noEmit | MUST | pending |
| F4 | 全量回归、隔离数据库迁移与审阅 | make test | MUST | pending |
## Run record
- F0:新后端资源输入 + 原生Jobs + 旧生命周期累计测试14项,exit 0。显式模拟执行器;backend-spine-smoke.json 已生成。
- F1:12项资源专项测试通过,连同原生主线、桥接、生命周期累计33项通过,exit 0;真实隔离 PostgreSQL 17 完成0026旧数据→0027迁移及CRUD/助手复制/运行时解析,见database-smoke.json。
## Deferred
- 真实模型/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"
}