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 (
|
||||
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
@@ -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."
|
||||
)
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
+10
-27
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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-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 |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user