Merge branch 'master-nj' into 'master'
feat: 模型配置支持删除,岗位行动规划对接任务系统字典与批量新增 Skill See merge request knowledge-base-bj/deerflow-code!20
This commit is contained in:
commit
fb5024ea82
11 files changed
+674
-72
No files matched your search
@@ -96,6 +96,13 @@ export async function updateManagedModel(name: string, payload: ModelSavePayload
|
||||
return response.json() as Promise<ManagedModel>;
|
||||
}
|
||||
|
||||
export async function deleteManagedModel(name: string): Promise<void> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) return readError(response, "删除模型失败");
|
||||
}
|
||||
|
||||
export async function testManagedModel(model: ModelSavePayload): Promise<ModelConnectionTestResult> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createManagedModel,
|
||||
deleteManagedModel,
|
||||
loadManagedModels,
|
||||
testManagedModel,
|
||||
updateManagedModel,
|
||||
@@ -36,3 +37,16 @@ export function useSaveManagedModel() {
|
||||
export function useTestManagedModel() {
|
||||
return useMutation({ mutationFn: testManagedModel });
|
||||
}
|
||||
|
||||
export function useDeleteManagedModel() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteManagedModel,
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: ["models"] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RefreshCwIcon,
|
||||
Settings2Icon,
|
||||
SparklesIcon,
|
||||
Trash2Icon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
import {
|
||||
type ManagedModel,
|
||||
type ModelSavePayload,
|
||||
useDeleteManagedModel,
|
||||
useManagedModels,
|
||||
useSaveManagedModel,
|
||||
useTestManagedModel,
|
||||
@@ -151,6 +153,41 @@ function Field({ label, required = false, children }: { label: string; required?
|
||||
return <label className="space-y-1.5 text-sm font-medium">{label}{required && <span className="ml-1 text-destructive">*</span>}{children}</label>;
|
||||
}
|
||||
|
||||
function ModelCapabilityBadge({
|
||||
icon: Icon,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
icon?: typeof KeyRoundIcon;
|
||||
label: string;
|
||||
className: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={`inline-flex h-7 items-center gap-1 whitespace-nowrap rounded px-2 text-xs font-medium leading-none ${className}`}>
|
||||
{Icon && <Icon aria-hidden className="block size-3.5 shrink-0" />}
|
||||
<span className="block leading-none">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function showGlobalMessage(
|
||||
theme: "success" | "error" | "warning",
|
||||
content: string,
|
||||
) {
|
||||
// Schedule after the modal state update so the message is rendered above
|
||||
// the page, rather than inside or behind a closing dialog.
|
||||
window.setTimeout(() => {
|
||||
MessagePlugin.closeAll();
|
||||
void MessagePlugin[theme]({
|
||||
content,
|
||||
duration: 5_000,
|
||||
placement: "top",
|
||||
zIndex: 10_000,
|
||||
attach: () => document.body,
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: () => void }) {
|
||||
const isNew = model === null;
|
||||
const [draft, setDraft] = useState<ModelDraft>(() => model ? draftFromModel(model) : emptyDraft());
|
||||
@@ -165,10 +202,10 @@ function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose:
|
||||
const payload = buildPayload(draft);
|
||||
const result = await test.mutateAsync(payload);
|
||||
setTestResult({ success: result.success, latency: result.latency_ms, message: result.message, preview: result.preview });
|
||||
if (!result.success) return void MessagePlugin.error(`连接失败:${result.message}`);
|
||||
MessagePlugin.success(`连接成功${result.latency_ms ? `,${result.latency_ms} ms` : ""}`);
|
||||
if (!result.success) return void showGlobalMessage("error", `连接失败:${result.message}`);
|
||||
showGlobalMessage("success", `连接成功${result.latency_ms ? `,${result.latency_ms} ms` : ""}`);
|
||||
} catch (error) {
|
||||
MessagePlugin.error(error instanceof Error ? error.message : "连接测试失败");
|
||||
showGlobalMessage("error", error instanceof Error ? error.message : "连接测试失败");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,13 +213,18 @@ function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose:
|
||||
try {
|
||||
const payload = buildPayload(draft);
|
||||
if (!payload.name || !payload.use || !payload.model) {
|
||||
return void MessagePlugin.warning("请填写模型标识、驱动与实际模型名");
|
||||
return void showGlobalMessage("warning", "请填写模型标识、驱动与实际模型名");
|
||||
}
|
||||
await save.mutateAsync({ name: model?.name ?? payload.name, payload, isNew });
|
||||
MessagePlugin.success(isNew ? "模型已添加,所有页面的模型选择器已刷新" : "模型配置已保存并立即生效");
|
||||
onClose();
|
||||
showGlobalMessage(
|
||||
"success",
|
||||
isNew
|
||||
? `模型“${payload.name}”已添加,所有页面的模型选择器已刷新。`
|
||||
: `模型“${payload.name}”的配置已保存并立即生效。`,
|
||||
);
|
||||
} catch (error) {
|
||||
MessagePlugin.error(error instanceof Error ? error.message : "保存模型配置失败");
|
||||
showGlobalMessage("error", error instanceof Error ? error.message : "保存模型配置失败");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,6 +273,8 @@ export default function ModelManagementPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
|
||||
const [editor, setEditor] = useState<ManagedModel | null | "new">(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ManagedModel | null>(null);
|
||||
const remove = useDeleteManagedModel();
|
||||
const models = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
return (data?.models ?? []).filter((model) => {
|
||||
@@ -240,7 +284,208 @@ export default function ModelManagementPage() {
|
||||
});
|
||||
}, [data?.models, filter, search]);
|
||||
const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget)
|
||||
return;
|
||||
try {
|
||||
await remove.mutateAsync(deleteTarget.name);
|
||||
const deletedName = deleteTarget.name;
|
||||
setDeleteTarget(null);
|
||||
showGlobalMessage("success", `模型“${deletedName}”已删除,配置已立即生效。`);
|
||||
}
|
||||
catch (error) {
|
||||
showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<WorkspaceContainer><WorkspaceHeader /><WorkspaceBody><main className="flex size-full min-h-0 flex-col overflow-y-auto bg-background px-6 py-5 md:px-8"><div className="mx-auto flex w-full max-w-7xl flex-col gap-5"><header className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex items-center gap-2"><span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary"><CpuIcon className="size-5" /></span><h1 className="text-2xl font-semibold tracking-tight">模型配置</h1></div><p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">管理当前部署 <code className="rounded bg-muted px-1 py-0.5">config.yaml</code> 中的对话模型。保存后所有页面立即可选;密钥仅可写入,永不回显。</p></div><div className="flex flex-wrap items-center gap-2"><TButton theme="default" variant="outline" loading={isFetching} onClick={() => void refetch()}><RefreshCwIcon className="mr-1.5 size-4" />刷新</TButton><TButton theme="primary" onClick={() => setEditor("new")}><PlusIcon className="mr-1.5 size-4" />添加模型</TButton></div></header><div className="grid gap-3 sm:grid-cols-3"><StatCard icon={BotIcon} label="已配置模型" value={stats.total} /><StatCard icon={SparklesIcon} label="支持思考" value={stats.thinking} /><StatCard icon={EyeIcon} label="支持视觉" value={stats.vision} /></div><section className="rounded-xl border border-border bg-card shadow-sm"><div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3"><div className="flex flex-wrap items-center gap-2">{([ ["all", "全部"], ["thinking", "支持思考"], ["vision", "支持视觉"] ] as const).map(([value, label]) => <TButton key={value} theme={filter === value ? "primary" : "default"} variant={filter === value ? "base" : "text"} size="small" onClick={() => setFilter(value)}>{label}</TButton>)}</div><Input value={search} placeholder="搜索名称、模型或驱动" clearable className="w-full sm:w-72" onChange={(value) => setSearch(String(value))} /></div>{isLoading && <div className="flex h-52 items-center justify-center gap-2 text-sm text-muted-foreground"><LoaderCircleIcon className="size-4 animate-spin" />加载模型配置…</div>}{error && <div className="m-4 flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm text-destructive"><CircleAlertIcon className="mt-0.5 size-4" />{error.message}</div>}{!isLoading && !error && models.length === 0 && <div className="flex h-52 flex-col items-center justify-center gap-2 text-sm text-muted-foreground"><CpuIcon className="size-7" />没有匹配的模型配置</div>}{!isLoading && !error && models.length > 0 && <div className="grid gap-3 p-4 lg:grid-cols-2">{models.map((model) => <article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35"><div className="flex items-start justify-between gap-3"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h2 className="truncate font-semibold">{model.display_name || model.name}</h2><Tag theme="success" variant="light" size="small">config.yaml</Tag></div><p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p></div><TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}>配置</TButton></div>{model.description ? <p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p> : <p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>}<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2"><span className="truncate" title={model.use}>驱动:{model.use}</span><span className="truncate" title={model.model}>模型:{model.model}</span></div><div className="mt-3 flex flex-wrap gap-1.5">{model.api_key_configured && <Tag size="small" variant="light"><KeyRoundIcon className="mr-1 size-3" />已配置密钥</Tag>}{model.supports_thinking && <Tag theme="primary" size="small" variant="light"><SparklesIcon className="mr-1 size-3" />思考</Tag>}{model.supports_vision && <Tag theme="warning" size="small" variant="light"><EyeIcon className="mr-1 size-3" />视觉</Tag>}{model.supports_reasoning_effort && <Tag size="small" variant="light">推理档位</Tag>}</div></article>)}</div>}</section>{data?.config_path && <p className="text-xs text-muted-foreground">当前配置文件:<code>{data.config_path}</code>{data.updated_at ? ` · 最近更新 ${new Date(data.updated_at).toLocaleString()}` : ""}</p>}</div></main></WorkspaceBody>{editor !== null && <ModelEditor key={editor === "new" ? "new" : editor.name} model={editor === "new" ? null : editor} onClose={() => setEditor(null)} />}</WorkspaceContainer>
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<main className="flex size-full min-h-0 flex-col overflow-y-auto bg-background px-6 py-5 md:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<CpuIcon className="size-5" />
|
||||
</span>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">模型配置</h1>
|
||||
</div>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
管理当前部署 <code className="rounded bg-muted px-1 py-0.5">config.yaml</code> 中的对话模型。保存后所有页面立即可选;密钥仅可写入,永不回显。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TButton theme="default" variant="outline" loading={isFetching} onClick={() => void refetch()}>
|
||||
<RefreshCwIcon className="mr-1.5 size-4" />刷新
|
||||
</TButton>
|
||||
<TButton theme="primary" onClick={() => setEditor("new")}>
|
||||
<PlusIcon className="mr-1.5 size-4" />添加模型
|
||||
</TButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatCard icon={BotIcon} label="已配置模型" value={stats.total} />
|
||||
<StatCard icon={SparklesIcon} label="支持思考" value={stats.thinking} />
|
||||
<StatCard icon={EyeIcon} label="支持视觉" value={stats.vision} />
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{([
|
||||
["all", "全部"],
|
||||
["thinking", "支持思考"],
|
||||
["vision", "支持视觉"],
|
||||
] as const).map(([value, label]) => (
|
||||
<TButton
|
||||
key={value}
|
||||
theme={filter === value ? "primary" : "default"}
|
||||
variant={filter === value ? "base" : "text"}
|
||||
size="small"
|
||||
onClick={() => setFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</TButton>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
placeholder="搜索名称、模型或驱动"
|
||||
clearable
|
||||
className="w-full sm:w-72"
|
||||
onChange={(value) => setSearch(String(value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-52 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />加载模型配置…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="m-4 flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<CircleAlertIcon className="mt-0.5 size-4" />{error.message}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && models.length === 0 && (
|
||||
<div className="flex h-52 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<CpuIcon className="size-7" />没有匹配的模型配置
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && models.length > 0 && (
|
||||
<div className="grid gap-3 p-4 lg:grid-cols-2">
|
||||
{models.map((model) => (
|
||||
<article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
|
||||
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}>
|
||||
配置
|
||||
</TButton>
|
||||
<TButton
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="small"
|
||||
aria-label={"删除模型 " + (model.display_name || model.name)}
|
||||
onClick={() => setDeleteTarget(model)}
|
||||
>
|
||||
<Trash2Icon className="mr-1 size-3.5" />删除
|
||||
</TButton>
|
||||
</div>
|
||||
</div>
|
||||
{model.description ? (
|
||||
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
|
||||
) : (
|
||||
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
|
||||
)}
|
||||
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<span className="truncate" title={model.use}>驱动:{model.use}</span>
|
||||
<span className="truncate" title={model.model}>模型:{model.model}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{model.api_key_configured && (
|
||||
<ModelCapabilityBadge
|
||||
icon={KeyRoundIcon}
|
||||
label="已配置密钥"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
)}
|
||||
{model.supports_thinking && (
|
||||
<ModelCapabilityBadge
|
||||
icon={SparklesIcon}
|
||||
label="思考"
|
||||
className="bg-primary/10 text-primary"
|
||||
/>
|
||||
)}
|
||||
{model.supports_vision && (
|
||||
<ModelCapabilityBadge
|
||||
icon={EyeIcon}
|
||||
label="视觉"
|
||||
className="bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
/>
|
||||
)}
|
||||
{model.supports_reasoning_effort && (
|
||||
<ModelCapabilityBadge
|
||||
label="推理档位"
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{data?.config_path && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当前配置文件:<code>{data.config_path}</code>
|
||||
{data.updated_at ? " · 最近更新 " + new Date(data.updated_at).toLocaleString() : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</WorkspaceBody>
|
||||
|
||||
{editor !== null && (
|
||||
<ModelEditor
|
||||
key={editor === "new" ? "new" : editor.name}
|
||||
model={editor === "new" ? null : editor}
|
||||
onClose={() => setEditor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<Dialog
|
||||
visible
|
||||
header="删除模型"
|
||||
placement="center"
|
||||
width={440}
|
||||
zIndex={3200}
|
||||
confirmBtn={{ content: "删除", theme: "danger" }}
|
||||
cancelBtn="取消"
|
||||
confirmLoading={remove.isPending}
|
||||
onClose={() => {
|
||||
if (!remove.isPending) setDeleteTarget(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (!remove.isPending) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={() => void handleDelete()}
|
||||
>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
确定要删除模型 <code className="rounded bg-muted px-1 py-0.5">{deleteTarget.name}</code> 吗?删除后会立即更新运行中的模型配置,依赖它的智能体或任务可能无法继续使用。
|
||||
</p>
|
||||
</Dialog>
|
||||
)}
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,9 @@ Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内
|
||||
|
||||
> ⚠️ 维护提示:资源文件(``_roundtable_seed_assets/``)是**发布默认值**的唯一可信源。
|
||||
> 线上调优后,请把改动写回对应的资源 .md,否则下次新机器补建出来的还是旧版。
|
||||
> ``_ensure_one`` 只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
|
||||
> ``_ensure_one`` 通常只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
|
||||
> 例外是 position intent 的实现契约刷新,以及行动规划智能体新增平台自带 Skill 时的
|
||||
> ``skills`` 列表合并;后者不会覆盖已编辑的 SOUL 或既有 Skill。
|
||||
|
||||
为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建
|
||||
------------------------------------------------------------
|
||||
@@ -52,6 +54,8 @@ import logging
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -108,6 +112,47 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
|
||||
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
|
||||
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
|
||||
_ACTION_PLAN_SKILL = "position-roundtable-action-create"
|
||||
|
||||
|
||||
def _ensure_action_plan_skill(agent_dir: Path) -> bool:
|
||||
"""Merge the owned batch-create Skill into an existing action planner.
|
||||
|
||||
The planner SOUL remains editable in the agent configuration drawer and is
|
||||
therefore never overwritten here. The skill binding, however, is a runtime
|
||||
integration contract: without it an already-deployed planner cannot read
|
||||
the new SKILL.md or invoke the batch API script after a code upgrade.
|
||||
"""
|
||||
config_path = agent_dir / "config.yaml"
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
logger.warning("Unable to read action planner config for skill binding: %s", config_path)
|
||||
return False
|
||||
if not isinstance(config, dict):
|
||||
logger.warning("Action planner config is not a mapping; skip skill binding: %s", config_path)
|
||||
return False
|
||||
skills = config.get("skills")
|
||||
if skills is None:
|
||||
normalized: list[str] = []
|
||||
elif isinstance(skills, list):
|
||||
normalized = [str(name).strip() for name in skills if str(name).strip()]
|
||||
else:
|
||||
logger.warning("Action planner skills is not a list; skip skill binding: %s", config_path)
|
||||
return False
|
||||
if _ACTION_PLAN_SKILL in normalized:
|
||||
return False
|
||||
|
||||
config["skills"] = [*normalized, _ACTION_PLAN_SKILL]
|
||||
try:
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(config, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
logger.warning("Unable to write action planner skill binding: %s", config_path)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_one(agent_id: str) -> bool:
|
||||
@@ -135,6 +180,8 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
if agent_dir.exists():
|
||||
if agent_id == ACTION_PLAN_AGENT_ID:
|
||||
return _ensure_action_plan_skill(agent_dir)
|
||||
if agent_id != POSITION_INTENT_AGENT_ID:
|
||||
# Other built-in agents may have been tuned on a deployed machine;
|
||||
# preserve the long-standing no-overwrite behavior for them.
|
||||
|
||||
+17
-11
@@ -26,23 +26,29 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "subtask-001",
|
||||
"title": "简短任务名称",
|
||||
"goal": "任务目标",
|
||||
"basis": ["方案总结章节或岗位交付依据"],
|
||||
"owner_position": "建议责任岗位",
|
||||
"dependencies": ["前置子任务 id"],
|
||||
"deliverable": "可核验交付物",
|
||||
"acceptance_criteria": ["验收条件"],
|
||||
"priority": "P0"
|
||||
"actionName": "行动名称",
|
||||
"actionDescription": "行动描述",
|
||||
"actionType": 0,
|
||||
"publishChannel": 0,
|
||||
"infoFormat": 0,
|
||||
"responsiblePosition": 0,
|
||||
"startTime": "",
|
||||
"endTime": "",
|
||||
"taskId": 0
|
||||
}
|
||||
```
|
||||
|
||||
`priority` 仅使用 `P0`、`P1`、`P2`。`dependencies` 没有前置时使用空数组。不要输出注释、尾逗号或额外字段来替代这些必填字段。
|
||||
`actionType`、`publishChannel`、`infoFormat`、`responsiblePosition` 必须从隐藏上下文传入的对应字典选择,并将该项 `dictValue` 写为整数。`taskId` 必须等于隐藏上下文中前端传入的任务 ID。不要输出注释、尾逗号、旧的 `id/title/goal/basis` 等字段,不能用 0 或空值代替未选择的字典项。
|
||||
|
||||
## 行动批量新增
|
||||
|
||||
首次生成完整行动清单后:仅当隐藏上下文的 `taskId` 是正整数且四类字典(发布渠道、行动类型、信息形式、责任岗位)均完整时,必须先用 `read_file` 读取 `position-roundtable-action-create` Skill 的 `SKILL.md` 全文,再按其说明调用脚本批量新增行动。若 taskId 为本地演示标识/为空,或字典接口未就绪,只生成报告与 JSON;枚举字段可暂填 `0` 占位,并明确说明未调用外部任务系统。不得逐条调用旧的 `/task/action/add`,也不得自行用 curl 或 requests 替代脚本。
|
||||
|
||||
脚本成功时才可以说明行动已入库;脚本失败时如实报告失败原因。相同任务、相同行动清单会由脚本自动幂等跳过。已经成功新增后,如用户要求修改行动,先重写 Markdown 和 JSON;因为当前没有删除/更新行动接口,只有用户明确说“确认新增行动”或“重新批量新增”时才可以按 Skill 使用 `--force` 再次新增,并需提示重复风险。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只依据用户消息中提供的任务信息、意图、方案总结和岗位交付。
|
||||
- 不调用联网、派活、澄清、展示文件等工具。
|
||||
- 不调用联网、派活、澄清、展示文件等工具;仅可为行动批量新增按专属 Skill 使用 `read_file` 与 `bash`。
|
||||
- 不删除或修改上游岗位产物。
|
||||
- 不在报告中添加“AI 生成”类水印。
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
id: position-action-planner
|
||||
name: 行动规划智能体
|
||||
description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。
|
||||
skills:
|
||||
- position-roundtable-action-create
|
||||
@@ -378,7 +378,7 @@ async def create_model_configuration(body: ModelUpsertRequest, request: Request)
|
||||
return _to_admin_item(created)
|
||||
|
||||
|
||||
@router.put("/{model_name}", response_model=ModelAdminItem)
|
||||
@router.put("/{model_name:path}", response_model=ModelAdminItem)
|
||||
async def update_model_configuration(model_name: str, body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
|
||||
await _require_admin(request)
|
||||
if body.name != model_name:
|
||||
@@ -396,6 +396,20 @@ async def update_model_configuration(model_name: str, body: ModelUpsertRequest,
|
||||
return _to_admin_item(updated)
|
||||
|
||||
|
||||
@router.delete("/{model_name:path}", status_code=204)
|
||||
async def delete_model_configuration(model_name: str, request: Request) -> None:
|
||||
"""Delete one model configuration and activate the remaining model list."""
|
||||
|
||||
await _require_admin(request)
|
||||
async with _MUTATION_LOCK:
|
||||
path = _resolve_config_path()
|
||||
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
|
||||
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
|
||||
if index is None:
|
||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
|
||||
await _save_models(request, raw_before, [*models[:index], *models[index + 1 :]])
|
||||
|
||||
|
||||
@router.post("/test", response_model=ModelTestResponse)
|
||||
async def test_model_connection(body: ModelTestRequest, request: Request) -> ModelTestResponse:
|
||||
"""Perform one bounded minimal completion without persisting the draft."""
|
||||
|
||||
@@ -156,10 +156,33 @@ class RejectNodeRequest(BaseModel):
|
||||
reason: str = Field(default="", max_length=5_000)
|
||||
|
||||
|
||||
class ActionPlanDictionaryOption(BaseModel):
|
||||
"""One task-system dictionary row, preserving dictValue as the model input."""
|
||||
|
||||
label: str = Field(default="", max_length=256)
|
||||
value: str | int | float = ""
|
||||
id: str | int | None = None
|
||||
dict_type: str = Field(default="", max_length=128)
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class ActionPlanDictionaries(BaseModel):
|
||||
"""The four action fields that must be selected from task-system dictionaries."""
|
||||
|
||||
publish_channels: list[ActionPlanDictionaryOption] = Field(default_factory=list)
|
||||
action_types: list[ActionPlanDictionaryOption] = Field(default_factory=list)
|
||||
info_formats: list[ActionPlanDictionaryOption] = Field(default_factory=list)
|
||||
responsible_positions: list[ActionPlanDictionaryOption] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PrepareBuiltinRunRequest(BaseModel):
|
||||
"""Optional follow-up instruction for one persisted built-in agent."""
|
||||
|
||||
message: str = Field(default="", max_length=20_000)
|
||||
# 岗位会商会话允许本地演示使用非数字 taskId。真实任务系统新增行动
|
||||
# 时由专属 Skill 再按外部接口契约校验正整数,不能在 prepare 阶段阻断报告生成。
|
||||
task_id: str | int | None = Field(default=None, max_length=128)
|
||||
action_dictionaries: ActionPlanDictionaries | None = None
|
||||
|
||||
|
||||
class BuiltinRunPreparationResponse(BaseModel):
|
||||
@@ -1060,6 +1083,8 @@ async def _build_builtin_prompt(
|
||||
session: dict[str, Any],
|
||||
nodes: list[dict[str, Any]],
|
||||
follow_up: str = "",
|
||||
action_task_id: str | int | None = None,
|
||||
action_dictionaries: ActionPlanDictionaries | None = None,
|
||||
) -> str:
|
||||
task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
|
||||
intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
|
||||
@@ -1103,13 +1128,50 @@ async def _build_builtin_prompt(
|
||||
else:
|
||||
summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
|
||||
summary_content = _truncate_text(summary.get("content"), 12_000)
|
||||
dictionary_groups = (
|
||||
("发布渠道(publishChannel)", action_dictionaries.publish_channels if action_dictionaries else []),
|
||||
("行动类型(actionType)", action_dictionaries.action_types if action_dictionaries else []),
|
||||
("信息形式(infoFormat)", action_dictionaries.info_formats if action_dictionaries else []),
|
||||
("责任岗位(responsiblePosition)", action_dictionaries.responsible_positions if action_dictionaries else []),
|
||||
)
|
||||
dictionaries_ready = bool(action_task_id is not None and all(options for _, options in dictionary_groups))
|
||||
dictionary_lines = [
|
||||
"【前端传入的行动字典(名称 → 必须原样使用的 dictValue)】",
|
||||
f"- 当前 taskId:{action_task_id if action_task_id is not None else '(本地预览)'}",
|
||||
]
|
||||
for label, options in dictionary_groups:
|
||||
dictionary_lines.append(f"- {label}:")
|
||||
dictionary_lines.extend(
|
||||
f" - {option.label} → {option.value}"
|
||||
for option in options
|
||||
)
|
||||
dictionary_instruction = (
|
||||
"actionType、publishChannel、infoFormat、responsiblePosition 必须从下方同名字典复制 dictValue 并写成整数;"
|
||||
"taskId 必须原样写为当前 taskId。不要保留 id、title、goal、basis、owner_position、dependencies 等旧字段,"
|
||||
"也不要用 0 或空值代替未选择的字典项。"
|
||||
if dictionaries_ready
|
||||
else "当前四类任务系统字典未获取完整,这是本地预览/接口未就绪状态。仍需生成 9 字段 JSON,"
|
||||
"但四个枚举字段可暂填 0 占位,且不得调用 position-roundtable-action-create Skill 或外部任务系统。"
|
||||
)
|
||||
submit_instruction = (
|
||||
"若当前 taskId 是正整数,首次生成完整行动清单后按行动规划智能体的 SOUL 调用 position-roundtable-action-create Skill 批量新增行动;"
|
||||
"若 taskId 为本地演示标识或为空,只生成报告和 JSON,明确说明未调用任务系统。Skill 返回失败时如实报告,不能伪称已入库。"
|
||||
if dictionaries_ready
|
||||
else "本轮字典不完整,只生成报告和 JSON 预览;不得调用 Skill、不得请求外部任务系统,也不能宣称行动已入库。"
|
||||
)
|
||||
instructions = [
|
||||
"请基于以下方案总结和岗位会商材料,生成或按用户要求修订《岗位会商行动规划》。",
|
||||
"必须使用 write_file 分别写入两个最终交付:`/mnt/user-data/outputs/行动规划报告.md` 与 `/mnt/user-data/outputs/action-plan-subtasks.json`。",
|
||||
"Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排;JSON 必须是严格合法数组,每个子任务都包含 id、title、goal、basis、owner_position、dependencies、deliverable、acceptance_criteria、priority。",
|
||||
"Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排。"
|
||||
"JSON 必须是严格合法数组;每个对象只能包含 actionName、actionDescription、actionType、"
|
||||
"publishChannel、infoFormat、responsiblePosition、startTime、endTime、taskId 这 9 个字段。",
|
||||
dictionary_instruction,
|
||||
submit_instruction,
|
||||
"",
|
||||
"【已生效方案总结】",
|
||||
summary_content or "(方案总结正文未写入快照,请依据下方有效岗位交付重新归纳。)",
|
||||
"",
|
||||
*dictionary_lines,
|
||||
]
|
||||
if follow_up.strip():
|
||||
instructions.extend(["", "【用户本次补充/修改要求】", follow_up.strip()])
|
||||
@@ -1549,6 +1611,8 @@ async def _prepare_builtin_run(
|
||||
*,
|
||||
kind: str,
|
||||
follow_up: str = "",
|
||||
action_task_id: str | int | None = None,
|
||||
action_dictionaries: ActionPlanDictionaries | None = None,
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
store, user_id, session = await _require_writable_session(request, session_id)
|
||||
nodes = await store.list_nodes(session_id, user_id)
|
||||
@@ -1582,6 +1646,8 @@ async def _prepare_builtin_run(
|
||||
session=session,
|
||||
nodes=nodes,
|
||||
follow_up=follow_up,
|
||||
action_task_id=action_task_id,
|
||||
action_dictionaries=action_dictionaries,
|
||||
),
|
||||
source_revisions=_source_revisions(nodes),
|
||||
)
|
||||
@@ -1689,14 +1755,28 @@ async def complete_summary(
|
||||
async def prepare_action_plan(
|
||||
request: Request, session_id: str, body: PrepareBuiltinRunRequest
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
|
||||
return await _prepare_builtin_run(
|
||||
request,
|
||||
session_id,
|
||||
kind="action_plan",
|
||||
follow_up=body.message,
|
||||
action_task_id=body.task_id,
|
||||
action_dictionaries=body.action_dictionaries,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/action-plan/ask", response_model=BuiltinRunPreparationResponse)
|
||||
async def ask_action_plan(
|
||||
request: Request, session_id: str, body: PrepareBuiltinRunRequest
|
||||
) -> BuiltinRunPreparationResponse:
|
||||
return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
|
||||
return await _prepare_builtin_run(
|
||||
request,
|
||||
session_id,
|
||||
kind="action_plan",
|
||||
follow_up=body.message,
|
||||
action_task_id=body.task_id,
|
||||
action_dictionaries=body.action_dictionaries,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/action-plan/complete", response_model=SessionDetailResponse)
|
||||
|
||||
@@ -33,7 +33,7 @@ concurrency:
|
||||
fail_open: true
|
||||
|
||||
models:
|
||||
- name: deepseek-chat
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-chat
|
||||
@@ -45,20 +45,52 @@ models:
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
|
||||
- name: zai-org/GLM-5-FP8
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: zai-org/GLM-5-FP8
|
||||
api_key: $GLM5_FP8_API_KEY
|
||||
base_url: https://api.us-west-2.modal.direct/v1
|
||||
base_url: https://api.us-west-2.modal.direct/v2
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
|
||||
supports_reasoning_effort: false
|
||||
- name: deepseek-ai/DeepSeek-V4-Flash
|
||||
display_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Flash
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
when_thinking_disabled:
|
||||
extra_body:
|
||||
enable_thinking: false
|
||||
- name: inclusionAI/Ling-flash-2.0
|
||||
display_name: inclusionAI/Ling-flash-2.0
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: inclusionAI/Ling-flash-2.0
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Pro
|
||||
display_name: deepseek-ai/DeepSeek-V4-Pro
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Pro
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
# - name: deepseek-v4-flash
|
||||
# display_name: DeepSeek V4 Flash
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
@@ -71,7 +103,6 @@ models:
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
|
||||
# - name: deepseek-v4-pro
|
||||
# display_name: DeepSeek V4 Pro
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
@@ -136,52 +167,9 @@ models:
|
||||
# temperature: 1.0
|
||||
# supports_vision: true
|
||||
# supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Flash
|
||||
display_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Flash
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
|
||||
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
|
||||
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
|
||||
when_thinking_disabled:
|
||||
extra_body:
|
||||
enable_thinking: false
|
||||
- name: Pro/MiniMaxAI/MiniMax-M2.5
|
||||
display_name: Pro/MiniMaxAI/MiniMax-M2.5
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: Pro/MiniMaxAI/MiniMax-M2.5
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: inclusionAI/Ling-flash-2.0
|
||||
display_name: inclusionAI/Ling-flash-2.0
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: inclusionAI/Ling-flash-2.0
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
- name: deepseek-ai/DeepSeek-V4-Pro
|
||||
display_name: deepseek-ai/DeepSeek-V4-Pro
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-ai/DeepSeek-V4-Pro
|
||||
api_key: $SILICONFLOW_API_KEY
|
||||
base_url: https://api.siliconflow.cn/v1/
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
# 识图(图片识别)配置。
|
||||
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
|
||||
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
|
||||
|
||||
@@ -6,6 +6,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from starlette.routing import Match
|
||||
|
||||
from app.gateway.routers import model_management as router
|
||||
|
||||
@@ -53,6 +54,64 @@ def test_connection_error_redaction_hides_literal_and_resolved_keys() -> None:
|
||||
assert router._redact_text(message, ["raw-secret", "resolved-secret"]) == "provider rejected *** and ***"
|
||||
|
||||
|
||||
def test_model_mutation_routes_accept_names_with_slashes() -> None:
|
||||
for method in ("PUT", "DELETE"):
|
||||
route = next(
|
||||
item
|
||||
for item in router.router.routes
|
||||
if getattr(item, "methods", set()) == {method} and getattr(item, "path", "") == "/api/admin/models/{model_name:path}"
|
||||
)
|
||||
match, child_scope = route.matches(
|
||||
{
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": "/api/admin/models/zai-org/GLM-5-FP8",
|
||||
}
|
||||
)
|
||||
assert match is Match.FULL
|
||||
assert child_scope["path_params"] == {"model_name": "zai-org/GLM-5-FP8"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_model_configuration_removes_matching_model_and_activates_config(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
"""models:
|
||||
- name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: GLM-5-FP8
|
||||
- name: keep-me
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: keep-me
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
saved: dict[str, object] = {}
|
||||
|
||||
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
saved["raw_before"] = raw_before
|
||||
saved["models"] = models
|
||||
return models
|
||||
|
||||
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
|
||||
monkeypatch.setattr(router, "_save_models", save_models)
|
||||
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
|
||||
|
||||
await router.delete_model_configuration("zai-org/GLM-5-FP8", request)
|
||||
|
||||
assert "zai-org/GLM-5-FP8" in str(saved["raw_before"])
|
||||
assert saved["models"] == [
|
||||
{
|
||||
"name": "keep-me",
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "keep-me",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_test_uses_one_minimal_prompt_without_writing_config(
|
||||
tmp_path,
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -65,6 +68,120 @@ def test_position_intent_agent_is_a_seeded_functional_agent():
|
||||
assert POSITION_INTENT_AGENT_ID in set(required_agent_ids())
|
||||
|
||||
|
||||
def test_action_plan_seed_binds_the_batch_create_skill_and_nine_field_contract():
|
||||
"""New deployments must not fall back to the retired subtask JSON contract."""
|
||||
action_assets = roundtable_seed._ASSETS_DIR / roundtable_seed.ACTION_PLAN_AGENT_ID
|
||||
soul = (action_assets / "SOUL.md").read_text(encoding="utf-8")
|
||||
config = (action_assets / "config.yaml").read_text(encoding="utf-8")
|
||||
|
||||
assert "position-roundtable-action-create" in config
|
||||
for field in (
|
||||
"actionName",
|
||||
"actionDescription",
|
||||
"actionType",
|
||||
"publishChannel",
|
||||
"infoFormat",
|
||||
"responsiblePosition",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"taskId",
|
||||
):
|
||||
assert field in soul
|
||||
assert "/task/action/add" in soul
|
||||
assert "批量新增" in soul
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_plan_prompt_receives_frontend_task_id_and_dictionary_values():
|
||||
"""The action planner must see exact dictValue choices, not frontend labels only."""
|
||||
dictionaries = position_roundtable_router.ActionPlanDictionaries(
|
||||
publish_channels=[{"label": "微信公众号", "value": "11"}],
|
||||
action_types=[{"label": "宣传", "value": "21"}],
|
||||
info_formats=[{"label": "图文", "value": "31"}],
|
||||
responsible_positions=[{"label": "规划岗", "value": "41"}],
|
||||
)
|
||||
prompt = await position_roundtable_router._build_builtin_prompt(
|
||||
kind="action_plan",
|
||||
session={
|
||||
"task_snapshot": {"title": "任务 A", "description": "生成行动"},
|
||||
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
|
||||
},
|
||||
nodes=[],
|
||||
action_task_id=1001,
|
||||
action_dictionaries=dictionaries,
|
||||
)
|
||||
|
||||
assert "当前 taskId:1001" in prompt
|
||||
assert "微信公众号 → 11" in prompt
|
||||
assert "宣传 → 21" in prompt
|
||||
assert "图文 → 31" in prompt
|
||||
assert "规划岗 → 41" in prompt
|
||||
assert "id、title、goal" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_plan_prompt_degrades_when_task_dictionaries_are_unavailable():
|
||||
"""A 404 from the task-system dictionaries must not block local planning."""
|
||||
prompt = await position_roundtable_router._build_builtin_prompt(
|
||||
kind="action_plan",
|
||||
session={
|
||||
"task_snapshot": {"title": "本地演示任务"},
|
||||
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
|
||||
},
|
||||
nodes=[],
|
||||
action_task_id="abc123",
|
||||
action_dictionaries=position_roundtable_router.ActionPlanDictionaries(),
|
||||
)
|
||||
|
||||
assert "字典未获取完整" in prompt
|
||||
assert "不得调用 Skill" in prompt
|
||||
assert "0 占位" in prompt
|
||||
|
||||
|
||||
def test_action_plan_prepare_keeps_a_local_mock_task_id_without_numeric_coercion():
|
||||
"""A local demo id must not stop the action-plan conversation at prepare time."""
|
||||
body = position_roundtable_router.PrepareBuiltinRunRequest(task_id="abc123")
|
||||
|
||||
assert body.task_id == "abc123"
|
||||
|
||||
|
||||
def test_action_batch_skill_accepts_only_the_nine_field_array(tmp_path):
|
||||
"""The batch Skill must reject old task-subdivision fields before any HTTP call."""
|
||||
archive_path = roundtable_seed._ASSETS_DIR.parents[3] / "skill-packages" / "position-roundtable-action-create.skill"
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
archive.extractall(tmp_path / "skill-package")
|
||||
script_path = tmp_path / "skill-package" / "position-roundtable-action-create" / "scripts" / "batch_add_actions.py"
|
||||
spec = importlib.util.spec_from_file_location("position_action_batch_skill", script_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
valid = [{
|
||||
"actionName": "发布内容",
|
||||
"actionDescription": "向目标渠道发布审核通过的信息。",
|
||||
"actionType": "1",
|
||||
"publishChannel": "2",
|
||||
"infoFormat": "3",
|
||||
"responsiblePosition": "4",
|
||||
"startTime": "2026-08-12T09:00:00+08:00",
|
||||
"endTime": "2026-08-12T18:00:00+08:00",
|
||||
"taskId": 1001,
|
||||
}]
|
||||
source = tmp_path / "action-plan-subtasks.json"
|
||||
source.write_text(json.dumps(valid, ensure_ascii=False), encoding="utf-8")
|
||||
assert module.read_actions(source, 1001) == [{
|
||||
**valid[0],
|
||||
"actionType": 1,
|
||||
"publishChannel": 2,
|
||||
"infoFormat": 3,
|
||||
"responsiblePosition": 4,
|
||||
}]
|
||||
|
||||
source.write_text(json.dumps([{**valid[0], "id": "legacy"}], ensure_ascii=False), encoding="utf-8")
|
||||
with pytest.raises(module.ActionInputError, match="多余字段"):
|
||||
module.read_actions(source, 1001)
|
||||
|
||||
|
||||
def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
|
||||
"""Dedicated schema must not regress into solutioning or gatekeeping."""
|
||||
prompt = (
|
||||
@@ -165,6 +282,29 @@ def test_position_intent_seed_repairs_its_contract_without_touching_other_agents
|
||||
assert generic_soul.read_text(encoding="utf-8") == "deployed generic tune"
|
||||
|
||||
|
||||
def test_existing_action_planner_only_merges_the_owned_skill(tmp_path, monkeypatch):
|
||||
"""A code upgrade may add the Skill without overwriting a user-tuned SOUL."""
|
||||
agents_dir = tmp_path / "agents"
|
||||
monkeypatch.setattr(
|
||||
roundtable_seed,
|
||||
"get_paths",
|
||||
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
|
||||
)
|
||||
action_dir = agents_dir / roundtable_seed.ACTION_PLAN_AGENT_ID
|
||||
action_dir.mkdir(parents=True)
|
||||
(action_dir / "SOUL.md").write_text("deployed action-plan tune", encoding="utf-8")
|
||||
(action_dir / "config.yaml").write_text(
|
||||
"id: position-action-planner\nskills:\n - existing-skill\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert roundtable_seed._ensure_one(roundtable_seed.ACTION_PLAN_AGENT_ID) is True
|
||||
config = (action_dir / "config.yaml").read_text(encoding="utf-8")
|
||||
assert "existing-skill" in config
|
||||
assert "position-roundtable-action-create" in config
|
||||
assert (action_dir / "SOUL.md").read_text(encoding="utf-8") == "deployed action-plan tune"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch):
|
||||
"""Step 1 initialization must not depend on the deployed Gateway port."""
|
||||
|
||||
Reference in new issue
Block a user