Merge branch 'dhk-1' into 'master'

feat(roundtable): 会商席位「技能识别强化」改为按需开关(智能体级 + 链条每席位级)

See merge request knowledge-base-bj/deerflow-server!141
This commit is contained in:
董宏凯 committed 2026-06-21 08:59:10 +00:00
commit 02115bccfe
60 files changed
+3266 -297

No files matched your search

+3
View File
@@ -10,6 +10,9 @@ VITE_MOCK_COP_TASK_DETAIL=true
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。 # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_SQ_REPORT=true VITE_MOCK_SQ_REPORT=true
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_COP_TASK_DETAIL=true
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
+3
View File
@@ -10,6 +10,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。 # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。
VITE_MOCK_SQ_REPORT=false VITE_MOCK_SQ_REPORT=false
# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001 VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
+3
View File
@@ -10,6 +10,9 @@ VITE_MOCK_COP_TASK_DETAIL=false
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。 # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:默认真实接口;真实接口未就绪可改 true 走本地样例假数据。
VITE_MOCK_SQ_REPORT=false VITE_MOCK_SQ_REPORT=false
# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API # Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
@@ -229,6 +229,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
); );
const [editPublished, setEditPublished] = useState(agent.published); const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private); const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
// 会商席位「技能识别强化」开关(弱模型补偿)。默认关。
const [editSeatSkillDirective, setEditSeatSkillDirective] = useState(
agent.seat_skill_directive ?? false,
);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? ""); const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false); const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares(); const { data: publishSquares } = usePublishSquares();
@@ -274,6 +278,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
setEditSquareId(agent.square_id ?? ""); setEditSquareId(agent.square_id ?? "");
setSelectedSkills(agent.skills ?? []); setSelectedSkills(agent.skills ?? []);
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions)); setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
setEditSeatSkillDirective(agent.seat_skill_directive ?? false);
}, [ }, [
agent.common_questions, agent.common_questions,
agent.description, agent.description,
@@ -282,6 +287,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
agent.published, agent.published,
agent.square_id, agent.square_id,
agent.skills, agent.skills,
agent.seat_skill_directive,
editOpen, editOpen,
]); ]);
@@ -295,6 +301,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
if (agentDetail) { if (agentDetail) {
setEditSoul(agentDetail.soul ?? ""); setEditSoul(agentDetail.soul ?? "");
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions)); setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
// 列表响应不带 config 额外字段(seat_skill_directive 为 null),详情才有真值。
setEditSeatSkillDirective(agentDetail.seat_skill_directive ?? false);
} else { } else {
setEditSoul(agent.soul ?? ""); setEditSoul(agent.soul ?? "");
} }
@@ -386,6 +394,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
request.skills = selectedSkills; request.skills = selectedSkills;
if (JSON.stringify(nextQuestions) !== JSON.stringify(baselineQuestions)) if (JSON.stringify(nextQuestions) !== JSON.stringify(baselineQuestions))
request.common_questions = nextQuestions; request.common_questions = nextQuestions;
if (editSeatSkillDirective !== ((agentDetail?.seat_skill_directive ?? agent.seat_skill_directive) ?? false))
request.seat_skill_directive = editSeatSkillDirective;
if (Object.keys(request).length === 0) { if (Object.keys(request).length === 0) {
setEditOpen(false); setEditOpen(false);
@@ -960,6 +970,19 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
onChange={setEditCommonQuestions} onChange={setEditCommonQuestions}
/> />
</div> </div>
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">会商技能识别强化</p>
<p className="text-muted-foreground text-xs">
作为会商席位时,把已配置技能(含 SKILL.md 路径与「先 read_file
再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在业务链条配置里打开(任一即生效)。
</p>
</div>
<Switch
checked={editSeatSkillDirective}
onCheckedChange={setEditSeatSkillDirective}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3"> <div className="flex items-center justify-between rounded-md border p-3">
<div> <div>
<p className="text-sm font-medium">公开发布</p> <p className="text-sm font-medium">公开发布</p>
@@ -76,6 +76,8 @@ interface AgentDraft {
published: boolean; published: boolean;
featured: boolean; featured: boolean;
templateName: string; templateName: string;
/** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean;
} }
function draftFromAgent(agent: Agent): AgentDraft { function draftFromAgent(agent: Agent): AgentDraft {
@@ -88,6 +90,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
published: agent.published, published: agent.published,
featured: agent.featured_order != null, featured: agent.featured_order != null,
templateName: agent.template_name ?? "", templateName: agent.template_name ?? "",
seatSkillDirective: agent.seat_skill_directive ?? false,
}; };
} }
@@ -295,6 +298,8 @@ export function AgentDetailPanel({
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul; if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
if (draft.templateName.trim() !== (agent.template_name ?? "")) if (draft.templateName.trim() !== (agent.template_name ?? ""))
request.template_name = draft.templateName.trim(); request.template_name = draft.templateName.trim();
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
request.seat_skill_directive = draft.seatSkillDirective;
if (nextPublished !== agent.published) request.published = nextPublished; if (nextPublished !== agent.published) request.published = nextPublished;
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? [])) if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills; request.skills = draft.skills;
@@ -561,6 +566,21 @@ export function AgentDetailPanel({
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。 配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
</p> </p>
</div> </div>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3">
<p className="text-sm font-medium">会商技能识别强化</p>
<p className="text-muted-foreground text-xs">
作为会商席位时,把已配置技能(含 SKILL.md 路径与「先 read_file
再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在业务链条配置中打开(任一即生效)。
</p>
</div>
<Switch
checked={draft.seatSkillDirective}
onCheckedChange={(checked) =>
setDraft({ ...draft, seatSkillDirective: checked })
}
/>
</div>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2"> <div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div> <div>
<p className="text-sm font-medium">公开发布</p> <p className="text-sm font-medium">公开发布</p>
+8
View File
@@ -58,6 +58,10 @@ export interface Agent {
/** 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的 /** 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
* Markdown 产物右上角出现"发布入库"按钮。空/缺省 = 不支持入库。 */ * Markdown 产物右上角出现"发布入库"按钮。空/缺省 = 不支持入库。 */
template_name?: string | null; template_name?: string | null;
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
seat_skill_directive?: boolean | null;
tags?: Tag[]; tags?: Tag[];
featured_order?: number | null; featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */ /** Whether the current user has favorited (收藏) this agent. */
@@ -91,6 +95,8 @@ export interface CreateAgentRequest {
tool_groups?: string[] | null; tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null; common_questions?: AgentQuestion[] | null;
template_name?: string | null; template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
} }
export interface UpdateAgentRequest { export interface UpdateAgentRequest {
@@ -106,4 +112,6 @@ export interface UpdateAgentRequest {
common_questions?: AgentQuestion[] | null; common_questions?: AgentQuestion[] | null;
/** 入库模板名;传空字符串表示清除。 */ /** 入库模板名;传空字符串表示清除。 */
template_name?: string | null; template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
} }
+59 -2
View File
@@ -35,6 +35,9 @@ export interface CopTaskDetail {
/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */ /** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */
export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail"; export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail";
/** 行动详情整包暂存的 sessionStorage key(xdfx 行动分析:会商/单智能体读取行动名称/叙事)。 */
export const ACTION_DETAIL_STORAGE_KEY = "login.actionDetail";
/** goPath 基础码 → 业务码(配置管理 businessCode)。 */ /** goPath 基础码 → 业务码(配置管理 businessCode)。 */
const GOPATH_BUSINESS_CODE: Record<string, string> = { const GOPATH_BUSINESS_CODE: Record<string, string> = {
"3qfx": "3Q", // 三情分析 "3qfx": "3Q", // 三情分析
@@ -74,8 +77,28 @@ function consumerBaseUrl(): string {
return typeof value === "string" ? value.replace(/\/+$/, "") : ""; return typeof value === "string" ? value.replace(/\/+$/, "") : "";
} }
/** 拉取任务详情;缺少 consumer 地址 / 返回不合法时返回 null。 */ /** cop-task-detail 是否走本地假数据:开关 `VITE_MOCK_COP_TASK_DETAIL=true`(真实接口未就绪时调试用)。 */
function copTaskDetailMockEnabled(): boolean {
return (import.meta.env as Record<string, unknown>).VITE_MOCK_COP_TASK_DETAIL === "true";
}
/** cop-task-detail 本地假数据(与真实 data 字段同构;mock 开关开启时返回)。 */
function mockCopTaskDetail(taskId: string): CopTaskDetail {
return {
id: taskId || "2055",
overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务(本地假数据)。",
taskDirection: "正向引导 + 风险预警",
taskName: "任务01:暑期文旅舆情综合研判",
taskContent:
"围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。",
};
}
/** 拉取任务详情;``VITE_MOCK_COP_TASK_DETAIL=true`` 走假数据;缺少 consumer 地址 / 返回不合法时返回 null。 */
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> { export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
if (copTaskDetailMockEnabled()) {
return mockCopTaskDetail(taskId);
}
const base = consumerBaseUrl(); const base = consumerBaseUrl();
if (!base || !taskId) return null; if (!base || !taskId) return null;
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。 // service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
@@ -111,6 +134,25 @@ export function readCopTaskDetail(): CopTaskDetail | null {
} }
} }
/** 把行动详情整包暂存到 sessionStorage(xdfx 行动分析用;隐私模式/quota 异常时静默失败)。 */
export function persistActionDetail(detail: ActionDetail): void {
try {
sessionStorage.setItem(ACTION_DETAIL_STORAGE_KEY, JSON.stringify(detail));
} catch {
/* noop */
}
}
/** 读取暂存的行动详情(会商左上角「当前行动」、单智能体行动详情卡都可读)。 */
export function readActionDetail(): ActionDetail | null {
try {
const raw = sessionStorage.getItem(ACTION_DETAIL_STORAGE_KEY);
return raw ? (JSON.parse(raw) as ActionDetail) : null;
} catch {
return null;
}
}
/** 行动详情接口 ``{action_detail_url}/{actionId}`` 返回的 ``data`` 字段(xdfx 行动分析用)。 */ /** 行动详情接口 ``{action_detail_url}/{actionId}`` 返回的 ``data`` 字段(xdfx 行动分析用)。 */
export interface ActionDetail { export interface ActionDetail {
/** 行动id(深链 ?taskId= 传入的就是它)。 */ /** 行动id(深链 ?taskId= 传入的就是它)。 */
@@ -148,7 +190,7 @@ export async function fetchActionDetail(actionId: string): Promise<ActionDetail
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return null; if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>; const d = data as Record<string, unknown>;
return { const detail: ActionDetail = {
id: d.id != null ? String(d.id) : "", id: d.id != null ? String(d.id) : "",
taskId: d.taskId != null ? String(d.taskId) : "", taskId: d.taskId != null ? String(d.taskId) : "",
purposeId: d.purposeId != null ? String(d.purposeId) : "", purposeId: d.purposeId != null ? String(d.purposeId) : "",
@@ -157,6 +199,10 @@ export async function fetchActionDetail(actionId: string): Promise<ActionDetail
typeof d.actionStatus === "number" || typeof d.actionStatus === "string" ? d.actionStatus : "", typeof d.actionStatus === "number" || typeof d.actionStatus === "string" ? d.actionStatus : "",
narration: typeof d.narration === "string" ? d.narration : "", narration: typeof d.narration === "string" ? d.narration : "",
}; };
// 取到即整包暂存:登录解析任务id 时(resolveDeeplinkTaskId→fetchTaskIdFromAction)就会缓存它,
// 之后会商左上角「当前行动」、单智能体行动详情卡都能直接 readActionDetail(),避免重复请求。
persistActionDetail(detail);
return detail;
} }
/** /**
@@ -228,6 +274,15 @@ export async function resolveTaskDeeplinkTarget(
if (parsed.roundtable) { if (parsed.roundtable) {
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条; // 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条;
// taskId 让会商页按任务隔离存取草稿(一个 taskId 多条记录),并触发第三步自动结构化输出。 // taskId 让会商页按任务隔离存取草稿(一个 taskId 多条记录),并触发第三步自动结构化输出。
// xdfx(行动分析):根是「行动」——把行动名称随首条消息交给任务理解,左上角展示「当前行动」。
// 行动详情登录解析任务id 时已缓存(fetchActionDetail 内 persistActionDetail);这里优先读缓存。
let actionName = "";
let actionId = "";
if (parsed.businessCode === XDFX_BUSINESS_CODE && rawId) {
actionId = rawId;
const ad = readActionDetail() ?? (await fetchActionDetail(rawId));
actionName = ad?.actionName ?? "";
}
return { return {
path: `${taskBase}/roundtable`, path: `${taskBase}/roundtable`,
state: { state: {
@@ -236,6 +291,8 @@ export async function resolveTaskDeeplinkTarget(
taskGoal: detail.taskContent, taskGoal: detail.taskContent,
businessCode: parsed.businessCode, businessCode: parsed.businessCode,
taskId: taskId || undefined, taskId: taskId || undefined,
...(actionId ? { actionId } : {}),
...(actionName ? { actionName } : {}),
autoStart: true, autoStart: true,
}, },
}, },
+5
View File
@@ -83,6 +83,8 @@ export default function NewAgentPage() {
const [modelInput, setModelInput] = useState<string>(""); const [modelInput, setModelInput] = useState<string>("");
const [chatModelInput, setChatModelInput] = useState<string>(""); const [chatModelInput, setChatModelInput] = useState<string>("");
const [templateNameInput, setTemplateNameInput] = useState(""); const [templateNameInput, setTemplateNameInput] = useState("");
// 会商席位「技能识别强化」开关(弱模型补偿)。默认关。
const [seatSkillDirectiveInput, setSeatSkillDirectiveInput] = useState(false);
const [formError, setFormError] = useState(""); const [formError, setFormError] = useState("");
const [agentId, setAgentId] = useState(""); const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null); const [agent, setAgent] = useState<Agent | null>(null);
@@ -230,6 +232,7 @@ export default function NewAgentPage() {
skills: selectedSkills, skills: selectedSkills,
model: modelOverride, model: modelOverride,
template_name: templateNameInput.trim(), template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput,
}); });
setAgent(updated); setAgent(updated);
return updated; return updated;
@@ -251,6 +254,7 @@ export default function NewAgentPage() {
skills: selectedSkills, skills: selectedSkills,
model: modelOverride, model: modelOverride,
template_name: templateNameInput.trim(), template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput,
}); });
setAgentId(created.id); setAgentId(created.id);
setAgent(created); setAgent(created);
@@ -281,6 +285,7 @@ export default function NewAgentPage() {
selectedSkills, selectedSkills,
soulInput, soulInput,
templateNameInput, templateNameInput,
seatSkillDirectiveInput,
t.agents.nameStepCheckError, t.agents.nameStepCheckError,
t.agents.nameStepNetworkError, t.agents.nameStepNetworkError,
validateForm, validateForm,
@@ -31,6 +31,8 @@ export interface ChainCreatePayload {
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
/** 默认席位执行模式(flash/thinking/pro/ultra);null = 未设。 */ /** 默认席位执行模式(flash/thinking/pro/ultra);null = 未设。 */
seatMode?: SeatMode | null; seatMode?: SeatMode | null;
/** 研讨前并行取数开关(逐条 opt-in);缺省 false。 */
gatherFirst?: boolean;
} }
/** Partial update — send only what changed. */ /** Partial update — send only what changed. */
@@ -46,6 +48,8 @@ export interface ChainUpdatePayload {
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
/** 默认席位执行模式;send `null` to clear, omit to leave. */ /** 默认席位执行模式;send `null` to clear, omit to leave. */
seatMode?: SeatMode | null; seatMode?: SeatMode | null;
/** 研讨前并行取数开关;send true/false 改,omit 不改。 */
gatherFirst?: boolean;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */ /** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean; isPublic?: boolean;
} }
@@ -76,6 +80,7 @@ interface ServerChain {
stage_goals?: (string | null)[] | null; stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null; coordinator_prompt?: string | null;
seat_mode?: string | null; seat_mode?: string | null;
gather_first?: boolean;
is_public?: boolean; is_public?: boolean;
is_mine?: boolean; is_mine?: boolean;
owner_name?: string | null; owner_name?: string | null;
@@ -93,6 +98,7 @@ function toChain(s: ServerChain): BusinessChain {
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null, stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
coordinatorPrompt: s.coordinator_prompt ?? null, coordinatorPrompt: s.coordinator_prompt ?? null,
seatMode: (s.seat_mode as SeatMode | null) ?? undefined, seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
gatherFirst: s.gather_first ?? false,
isPublic: s.is_public ?? false, isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。 // 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true, isMine: s.is_mine ?? true,
@@ -112,6 +118,7 @@ function fromCreate(p: ChainCreatePayload) {
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}), ...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}), ...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
...(p.seatMode !== undefined ? { seat_mode: p.seatMode } : {}), ...(p.seatMode !== undefined ? { seat_mode: p.seatMode } : {}),
...(p.gatherFirst !== undefined ? { gather_first: p.gatherFirst } : {}),
}; };
} }
@@ -126,6 +133,7 @@ function fromUpdate(p: ChainUpdatePayload) {
if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals; if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals;
if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt; if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt;
if (p.seatMode !== undefined) out.seat_mode = p.seatMode; if (p.seatMode !== undefined) out.seat_mode = p.seatMode;
if (p.gatherFirst !== undefined) out.gather_first = p.gatherFirst;
if (p.isPublic !== undefined) out.is_public = p.isPublic; if (p.isPublic !== undefined) out.is_public = p.isPublic;
return out; return out;
} }
@@ -12,7 +12,7 @@ import { getBackendBaseURL } from "@/core/config";
import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph"; import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
/** 第三步输入框意图分类结果(与后端 /intent 对应)。 */ /** 第三步输入框意图分类结果(与后端 /intent 对应)。 */
export type FlowIntent = "regenerate_report" | "modify_flow" | "regenerate_flow" | "qa" | "unclear"; export type FlowIntent = "regenerate_report" | "modify_flow" | "regenerate_flow" | "ingest" | "qa" | "unclear";
/** /**
* 识别第三步输入框消息的意图(一次模型调用)。失败回退 "unclear"(前端发起澄清)。 * 识别第三步输入框消息的意图(一次模型调用)。失败回退 "unclear"(前端发起澄清)。
@@ -22,6 +22,8 @@ export async function classifyFlowIntent(req: {
message: string; message: string;
hasReport: boolean; hasReport: boolean;
hasFlow: boolean; hasFlow: boolean;
/** 当前是否可入库(入库按钮已出现、未入库);为真才允许识别出 ingest 意图。 */
canIngest?: boolean;
model?: string; model?: string;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<{ intent: FlowIntent; reason: string }> { }): Promise<{ intent: FlowIntent; reason: string }> {
@@ -33,6 +35,7 @@ export async function classifyFlowIntent(req: {
message: req.message, message: req.message,
has_report: req.hasReport, has_report: req.hasReport,
has_flow: req.hasFlow, has_flow: req.hasFlow,
can_ingest: req.canIngest ?? false,
model: req.model, model: req.model,
}), }),
signal: req.signal, signal: req.signal,
@@ -82,6 +85,10 @@ export interface FlowExtractRequest {
chainLevels?: string[]; chainLevels?: string[];
/** 业务链总控编排提示(写明层级时优先用它)。 */ /** 业务链总控编排提示(写明层级时优先用它)。 */
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
/** 深链接业务码(rwfx→6BF 等)。非空 → 后端按业务链抽取规范强制注入完整层级。 */
businessCode?: string | null;
/** 根节点 label 强制值(xdfx:本次行动名称)。非空 → 根节点 n1 用它,不用任务名当根。 */
rootLabel?: string | null;
/** 推理模型名(沿用第三步所选)。 */ /** 推理模型名(沿用第三步所选)。 */
model?: string; model?: string;
/** Phase 2 问答修改:当前流程图 + 指令 → 按指令改图(其余不变)。 */ /** Phase 2 问答修改:当前流程图 + 指令 → 按指令改图(其余不变)。 */
@@ -94,6 +101,11 @@ export interface FlowExtractRequest {
onValidating?: () => void; onValidating?: () => void;
/** 校验/修复流式增量(同样含思考 + 正文,前端实时显示校验过程)。 */ /** 校验/修复流式增量(同样含思考 + 正文,前端实时显示校验过程)。 */
onValidateDelta?: (text: string) => void; onValidateDelta?: (text: string) => void;
/**
* 模型自动容错:后端某模型抽取失败,已切到下一个模型重抽。上层据此**清空已累计的抽取增量**
* (`onExtractDelta` 之前吐出的半截内容)并提示用户。`failedModel`/`nextModel`/`reason` 描述切换。
*/
onModelSwitch?: (info: { failedModel: string; nextModel: string; reason: string }) => void;
} }
/** /**
@@ -109,6 +121,8 @@ export async function streamFlowExtract(req: FlowExtractRequest): Promise<FlowGr
seat_deliveries: (req.seatDeliveries ?? []).map((s) => ({ sender: s.sender, content: s.content })), seat_deliveries: (req.seatDeliveries ?? []).map((s) => ({ sender: s.sender, content: s.content })),
chain_levels: req.chainLevels ?? [], chain_levels: req.chainLevels ?? [],
coordinator_prompt: req.coordinatorPrompt ?? null, coordinator_prompt: req.coordinatorPrompt ?? null,
business_code: req.businessCode ?? null,
root_label: req.rootLabel ?? null,
model: req.model, model: req.model,
current_graph: req.currentGraph ?? null, current_graph: req.currentGraph ?? null,
instruction: req.instruction ?? null, instruction: req.instruction ?? null,
@@ -152,6 +166,12 @@ export async function streamFlowExtract(req: FlowExtractRequest): Promise<FlowGr
} else if (event === "validate_delta") { } else if (event === "validate_delta") {
const c = typeof frame.content === "string" ? frame.content : ""; const c = typeof frame.content === "string" ? frame.content : "";
if (c) req.onValidateDelta?.(c); if (c) req.onValidateDelta?.(c);
} else if (event === "model_switch") {
req.onModelSwitch?.({
failedModel: typeof frame.failed_model === "string" ? frame.failed_model : "",
nextModel: typeof frame.next_model === "string" ? frame.next_model : "",
reason: typeof frame.reason === "string" ? frame.reason : "",
});
} else if (event === "final") { } else if (event === "final") {
graph = (frame.graph as FlowGraphData) ?? null; graph = (frame.graph as FlowGraphData) ?? null;
} else if (event === "error") { } else if (event === "error") {
@@ -234,6 +234,17 @@ export interface StreamMultiAgentRequest {
reasoningEffort?: "minimal" | "low" | "medium" | "high"; reasoningEffort?: "minimal" | "low" | "medium" | "high";
/** 仅 special 用;ultra 档置真,给席位解锁 `task` 子代理工具。 */ /** 仅 special 用;ultra 档置真,给席位解锁 `task` 子代理工具。 */
subagentEnabled?: boolean; subagentEnabled?: boolean;
/**
* 仅 special 用。会商席位「技能识别强化」开关(沿用所选业务链条配置)。置 true 时后端把该
* 席位配置的技能(含 SKILL.md 路径 + 必须先 read_file 的硬指令)拼进本轮任务,补偿弱模型。
* 与该席位 agent 自身 config.yaml 的同名开关取 OR。省略 = false(关)。
*/
seatSkillDirective?: boolean;
/**
* 深链接业务码(rwfx→6BF 等)。**仅方案总结报告(summary)单例消费**:非空 → 后端按业务链抽取规范
* 强制注入「完整性核对」指令,让总结核对产出相对业务链完不完整。普通会商省略 = 不注入。
*/
businessCode?: string | null;
skillStopNames?: string[]; skillStopNames?: string[];
/** /**
* 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付 * 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付
@@ -286,6 +297,27 @@ export interface StreamMultiAgentRequest {
name?: string, name?: string,
args?: Record<string, unknown>, args?: Record<string, unknown>,
) => void; ) => void;
/**
* 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个
* 模型并即将重跑本轮时触发。上层据此把该角色「正在流式的失败气泡」重置(清空已累计的增量
* 文本)并给用户一条「『X』模型异常,已切换『Y』继续」的提示。此回调可能多次触发(连续多个
* 模型都失败时)。触发后,本次 streamMultiAgent 仍会继续用新模型流式,最终照常 resolve。
*/
onModelSwitch?: (info: ModelSwitchInfo) => void;
}
/** 模型自动容错通知(`streamMultiAgent` 的 `onModelSwitch` 回调入参)。 */
export interface ModelSwitchInfo {
/** 角色:leader / seat / seat_parallel / report / summary / dashboard / ingest。 */
role: string;
/** 出问题的智能体 agent_name。 */
agentName: string;
/** 失败的模型名。 */
failedModel: string;
/** 已切换到的下一个模型名。 */
nextModel: string;
/** 失败原因(已翻译成简短中文,如「服务繁忙」)。 */
reason: string;
} }
export interface ClarificationPayload { export interface ClarificationPayload {
@@ -319,6 +351,8 @@ export interface StreamMultiAgentResult {
content: string; content: string;
/** Echoed agent_name of the agent that produced this run. */ /** Echoed agent_name of the agent that produced this run. */
agentName: string | null; agentName: string | null;
/** 实际产出本轮结果的模型(发生模型自动容错时即最终切换到的模型);后端未下发时为 null。 */
modelUsed?: string | null;
/** /**
* Populated when the leader called ask_clarification — the run ended without * Populated when the leader called ask_clarification — the run ended without
* dispatching, and the model is asking the user a question instead. When * dispatching, and the model is asking the user a question instead. When
@@ -485,6 +519,10 @@ export async function streamMultiAgent(
thinking_enabled: req.thinkingEnabled, thinking_enabled: req.thinkingEnabled,
reasoning_effort: req.reasoningEffort, reasoning_effort: req.reasoningEffort,
subagent_enabled: req.subagentEnabled, subagent_enabled: req.subagentEnabled,
// 仅 special 用;会商技能识别强化(沿用所选业务链条开关)。省略 → 后端按 false(关)处理。
seat_skill_directive: req.seatSkillDirective ?? false,
// 仅 summary 单例用;深链接业务码 → 后端注入业务链完整性核对。省略/空 → 不注入。
business_code: req.businessCode ?? null,
skill_stop_names: req.skillStopNames ?? [], skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。 // 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false, synthesis_mode: req.synthesisMode ?? false,
@@ -540,6 +578,7 @@ export async function streamMultiAgent(
content: "", content: "",
agentName: null, agentName: null,
clarification: null, clarification: null,
modelUsed: null,
}; };
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the // Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
@@ -665,6 +704,28 @@ export async function streamMultiAgent(
}); });
} }
// 模型自动容错帧:后端检测到当前模型出问题,已切到下一个模型并即将重跑本轮。
// 不是终态帧(无 content)→ 不结束流。清空已累计的(失败尝试的)tool_call 增量,
// 把 phase 退回 connected,并通知上层重置该角色气泡 + 给用户提示。随后新模型的
// 流式增量会照常通过 onTextDelta 继续累计到(已被上层清空的)气泡。
if (
parsed &&
typeof parsed === "object" &&
(parsed as Record<string, unknown>).status === "model_switch"
) {
const obj = parsed as Record<string, unknown>;
toolCallAcc.clear();
currentPhase = "connected";
req.onModelSwitch?.({
role: typeof obj.role === "string" ? obj.role : "",
agentName: typeof obj.agent_name === "string" ? obj.agent_name : req.agentName,
failedModel: typeof obj.failed_model === "string" ? obj.failed_model : "",
nextModel: typeof obj.next_model === "string" ? obj.next_model : "",
reason: typeof obj.reason === "string" ? obj.reason : "",
});
continue;
}
// Phase inference from LangGraph messages-tuple frames. We only flip // Phase inference from LangGraph messages-tuple frames. We only flip
// to tool_calling/tool_result on chunks that clearly indicate them; // to tool_calling/tool_result on chunks that clearly indicate them;
// everything else with non-empty AI text means we're streaming. // everything else with non-empty AI text means we're streaming.
@@ -765,6 +826,10 @@ export async function streamMultiAgent(
if (typeof parsed.agent_name === "string") { if (typeof parsed.agent_name === "string") {
result.agentName = parsed.agent_name; result.agentName = parsed.agent_name;
} }
{
const mu = (parsed as Record<string, unknown>).model_used;
if (typeof mu === "string" && mu) result.modelUsed = mu;
}
// SSE error 帧(后端 stream_generator 在席位/Step3 run 失败时下发 // SSE error 帧(后端 stream_generator 在席位/Step3 run 失败时下发
// `{status:"error", content, agent_name}`):上报,便于收集用户侧报错。 // `{status:"error", content, agent_name}`):上报,便于收集用户侧报错。
if (result.message === "error") { if (result.message === "error") {
@@ -98,6 +98,18 @@ function toStartBody(p: StartJobPayload) {
seedMessage: p.seedMessage, seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null, threadIds: p.threadIds ?? null,
coordinatorName: p.coordinatorName ?? "roundtable-coordinator", coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
// 每席位技能识别强化映射 {agentId: bool}:沿用所选业务链条里各席位的开关;缺省 {}。
// 后端 run_seat 按 agentId 查表,与各席位 agent 自身开关取 OR。
seat_skill_directives: p.seatSkillDirectives ?? {},
// 每席位推理深度覆盖映射 {agentId: SeatMode|null}:沿用所选业务链条里各席位单独配的推理深度;
// 后端 run_seat 按 agentId 查表,覆盖优先、否则跟随作业级 seatMode(thinking/reasoning/subagent)。
seat_modes: Object.fromEntries(
Object.entries(p.seatModes ?? {}).filter(([, v]) => v != null) as [string, string][],
),
// 研讨前并行取数开关(来源链条 gatherFirst):true = 后台研讨前先并行取数再研讨。缺省 false。
gather_first: p.gatherFirst ?? false,
// 深链接业务码:后台总结报告据此做业务链完整性核对(写实业务如 rwfx 才生效)。空 → 不注入。
business_code: p.businessCode ?? null,
...(seat ...(seat
? { ? {
thinking_enabled: seat.thinking_enabled, thinking_enabled: seat.thinking_enabled,
@@ -150,19 +150,25 @@ export function BusinessChainPicker({
// 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局模型。 // 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局模型。
const modelById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.model ?? null])); const modelById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.model ?? null]));
const modelOf = (id: string) => modelById.get(id) ?? null; const modelOf = (id: string) => modelById.get(id) ?? null;
// 席位「会商技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。
const skillById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_skill_directive ?? false]));
const skillOf = (id: string) => skillById.get(id) ?? false;
// 席位「推理深度覆盖」(链条配置 seat_mode):按 agent_id 取,null/缺省 = 跟随链条级 seatMode。
const seatModeById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_mode ?? null]));
const seatModeOf = (id: string) => seatModeById.get(id) ?? null;
// 链条带分层、过滤存活后仍并行 → dag 编排(携带快速/沙箱子模式);否则退回线性 chain。 // 链条带分层、过滤存活后仍并行 → dag 编排(携带快速/沙箱子模式);否则退回线性 chain。
if (dagPlan) { if (dagPlan) {
// dag:席位顺序按 stage 展平(与 plan 一致)。 // dag:席位顺序按 stage 展平(与 plan 一致)。
const agents = dagPlan.stages const agents = dagPlan.stages
.flatMap((st) => st.agentIds) .flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) })); .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), seatMode: seatModeOf(id) }));
onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode }); onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode, gatherFirst: selectedChain.gatherFirst ?? false });
return; return;
} }
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) })); const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
onConfirm({ agents, mode: "chain", chain: chainMeta }); onConfirm({ agents, mode: "chain", chain: chainMeta, gatherFirst: selectedChain.gatherFirst ?? false });
}; };
// 整片脱敏:链条卡片区(链条名/描述/席位智能体名)。输入框不受影响(Hook 跳过)。 // 整片脱敏:链条卡片区(链条名/描述/席位智能体名)。输入框不受影响(Hook 跳过)。
@@ -156,7 +156,7 @@ export function DashboardChatPanel({
{/* 聊天记录 */} {/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col"> <div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white"> <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -181,7 +181,8 @@ export function Step1Panel(props: Step1PanelProps) {
<div className="relative flex-1 min-h-0 flex flex-col font-normal"> <div className="relative flex-1 min-h-0 flex flex-col font-normal">
<div <div
ref={step1ScrollRef} ref={step1ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white" // overflow-x-hidden:消息列表只竖向滚(见 Step2Panel 同处注释),避免误触横向拖动抢滑块。
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4 bg-white"
> >
{interactiveMessages.map((msg) => ( {interactiveMessages.map((msg) => (
<Step1Message <Step1Message
@@ -551,7 +551,11 @@ export function Step2Panel(props: Step2PanelProps) {
)} )}
<div <div
ref={step2ScrollRef} ref={step2ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner" // overflow-x-hidden:消息列表只竖向滚。否则 `overflow-y-auto` 单写时按 CSS 规范
// 另一轴会被计算成 auto,任何子元素溢出一两像素都会让整列横向可滚 → 鼠标移到右侧
// 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内
// 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
> >
{isInitializing && step2RoundtableDialogues.length === 0 && ( {isInitializing && step2RoundtableDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500"> <div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
@@ -592,7 +592,7 @@ function AgentProgressPanel({
<div className="relative flex-1 min-h-0 flex flex-col"> <div className="relative flex-1 min-h-0 flex flex-col">
<div <div
ref={scrollRef} ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white" className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white"
> >
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
@@ -707,7 +707,7 @@ function AgentProgressPanel({
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */} {/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<div className="relative flex-1 min-h-0 flex flex-col"> <div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white"> <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && ( {phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -20,6 +20,7 @@ import { useArtifacts } from "@/components/workspace/artifacts";
import { import {
initMultiAgent, initMultiAgent,
streamMultiAgent, streamMultiAgent,
type ModelSwitchInfo,
type ClarificationPayload, type ClarificationPayload,
type LeaderDispatch, type LeaderDispatch,
type StreamPhase, type StreamPhase,
@@ -279,6 +280,26 @@ export interface UseStep2OrchestrationOptions {
* 智能体」弹窗确认时的 `SeatSelectionResult.seatMode` 决定。缺省 `pro`。 * 智能体」弹窗确认时的 `SeatSelectionResult.seatMode` 决定。缺省 `pro`。
*/ */
selectedMode: SeatMode; selectedMode: SeatMode;
/**
* 每席位「技能识别强化」开关映射 {agentId: bool}(沿用所选业务链条里每个席位的配置)。
* 按 agentId 查表后透传给该 special 席位 run,与该席位 agent 自身 config.yaml 的同名开关取
* OR。recommend / 未选链条 → 空 {}(各席位仅靠其自身开关)。
*/
seatSkillDirectives?: Record<string, boolean>;
/**
* 每席位「推理深度覆盖」映射 {agentId: SeatMode | null}(沿用所选业务链条里每个席位的配置)。
* 派活该 special 席位时按 agentId 查表,**席位覆盖优先、否则跟随链条级 `selectedMode`**:
* `seatModeToRequestFields(seatModes[agentId] ?? selectedMode)`。模型卡顿时把次要席位降到
* `flash` 即可大幅提速。recommend / 未选链条 / 未覆盖 → 跟随 `selectedMode`。
*/
seatModes?: Record<string, SeatMode | null>;
/**
* 「研讨前并行取数」开关(来源业务链条的 gatherFirst,逐条 opt-in,缺省 false)。true = 正式研讨
* 前先对**全席位并行**跑一轮「只取数」(各席位只调技能取数、不研讨),产出作为共享资料(peerDeliveries)
* 注入后续研讨轮,把最慢的技能检索前置并行、给(尤其 chain 串行的)研讨段提速。每轮研讨只在**本轮
* 研讨真正启动时**取数一次(resetInitGate 复位后下一轮再取),澄清续跑/干预不重复取数。
*/
gatherFirst?: boolean;
/** /**
* 编排模式: * 编排模式:
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`); * - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
@@ -434,6 +455,9 @@ export function useStep2Orchestration(
currentStep, currentStep,
selectedModel, selectedModel,
selectedMode, selectedMode,
seatSkillDirectives = {},
seatModes = {},
gatherFirst = false,
orchestrationMode, orchestrationMode,
orchestrationPlan, orchestrationPlan,
dagExecMode = "fast", dagExecMode = "fast",
@@ -567,6 +591,16 @@ export function useStep2Orchestration(
const selectedModelRef = useRef<string>(selectedModel); const selectedModelRef = useRef<string>(selectedModel);
// 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。 // 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。
const selectedModeRef = useRef<SeatMode>(selectedMode); const selectedModeRef = useRef<SeatMode>(selectedMode);
// 每席位技能识别强化映射(所选业务链条各席位开关)同样打到 ref,席位调用按 agentId 读最新值。
const seatSkillDirectivesRef = useRef<Record<string, boolean>>(seatSkillDirectives);
// 每席位推理深度覆盖映射(所选业务链条各席位 seatMode)打到 ref,席位调用按 agentId 读最新值;
// 覆盖优先、否则跟随 selectedModeRef。
const seatModesRef = useRef<Record<string, SeatMode | null>>(seatModes);
// 「研讨前并行取数」开关 + 本轮取数产物({name,content}[])+ 本轮是否已取过的闸门。取数产物作为
// peerDeliveries 注入研讨轮席位;闸门保证一轮研讨只取一次(resetInitGate / resetStep2State 复位)。
const gatherFirstRef = useRef<boolean>(gatherFirst);
const gatheredDeliveriesRef = useRef<Array<{ name: string; content: string }>>([]);
const gatherPhaseDoneRef = useRef<boolean>(false);
const agentConfigsRef = useRef<Record<number, AgentConfig>>(agentConfigs); const agentConfigsRef = useRef<Record<number, AgentConfig>>(agentConfigs);
const agentNameToRoleRef = useRef(agentNameToRole); const agentNameToRoleRef = useRef(agentNameToRole);
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds); const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
@@ -606,6 +640,15 @@ export function useStep2Orchestration(
useEffect(() => { useEffect(() => {
selectedModeRef.current = selectedMode; selectedModeRef.current = selectedMode;
}, [selectedMode]); }, [selectedMode]);
useEffect(() => {
seatSkillDirectivesRef.current = seatSkillDirectives;
}, [seatSkillDirectives]);
useEffect(() => {
seatModesRef.current = seatModes;
}, [seatModes]);
useEffect(() => {
gatherFirstRef.current = gatherFirst;
}, [gatherFirst]);
useEffect(() => { useEffect(() => {
agentConfigsRef.current = agentConfigs; agentConfigsRef.current = agentConfigs;
}, [agentConfigs]); }, [agentConfigs]);
@@ -1162,6 +1205,24 @@ export function useStep2Orchestration(
[], [],
); );
/**
* 模型自动容错通知(前台):后端切到下一个模型重跑本轮时调用。弹 toast + 在时间线插一条
* 「系统提示」气泡,让用户看到「『X』模型异常 → 已切换『Y』继续」。
*/
const notifyModelSwitch = useCallback(
(info: ModelSwitchInfo) => {
const who = info.agentName || "智能体";
triggerToast(`⚠️「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`);
appendDialogue({
sender: "系统提示",
avatarType: "amber",
content: `⚠️ 模型容错:「${who}」所用模型「${info.failedModel}」${info.reason},已自动切换到「${info.nextModel}」继续。`,
confidence: "模型容错",
});
},
[appendDialogue, triggerToast],
);
const phaseLabel = useCallback( const phaseLabel = useCallback(
( (
phase: StreamPhase, phase: StreamPhase,
@@ -1277,6 +1338,20 @@ export function useStep2Orchestration(
); );
}, []); }, []);
/**
* 生成某气泡的 onModelSwitch 处理器:弹提示 + 把该气泡里「失败尝试已累计的半截内容」清空
* (随后新模型的流式增量会重新填进同一气泡),并把状态标为「模型切换中…」。
*/
const makeModelSwitchHandler = useCallback(
(bubbleId: number, isParallel: boolean) => (info: ModelSwitchInfo) => {
notifyModelSwitch(info);
if (isParallel) parallelStreamRef.current.delete(bubbleId);
setDialogueContent(bubbleId, "");
setDialogueConfidence(bubbleId, "模型切换中…");
},
[notifyModelSwitch, setDialogueContent, setDialogueConfidence],
);
// ─── §6 并行流式:全接收 / 惰性渲染 / 节流 ─────────────────────────────── // ─── §6 并行流式:全接收 / 惰性渲染 / 节流 ───────────────────────────────
/** /**
* 节流 flush(~80ms 一拍,约 12fps):**仅被选中**的席位把 ref 累积的全文挂到 * 节流 flush(~80ms 一拍,约 12fps):**仅被选中**的席位把 ref 累积的全文挂到
@@ -1573,7 +1648,8 @@ export function useStep2Orchestration(
"③ 风险 / 注意事项(如有);④ 与其它席位的依赖 / 承接关系(如有)。"; "③ 风险 / 注意事项(如有);④ 与其它席位的依赖 / 承接关系(如有)。";
// 优化4:跨轮累积各席位**完整交付**,作为 peerDeliveries 传给后续席位的 run // 优化4:跨轮累积各席位**完整交付**,作为 peerDeliveries 传给后续席位的 run
// (后端注入 context,席位用 read_peer_delivery 按需取全文,不进 prompt 不耗 token)。 // (后端注入 context,席位用 read_peer_delivery 按需取全文,不进 prompt 不耗 token)。
const priorDeliveries: Array<{ name: string; content: string }> = []; // 研讨前并行取数(gatherFirst)的产物作为初始 peerDeliveries 注入:研讨席位开局即带共享资料。
const priorDeliveries: Array<{ name: string; content: string }> = [...gatheredDeliveriesRef.current];
// 共享的「单席位交付执行」——串行/并行分支共用。含**失败自动重试一次**(优化5, // 共享的「单席位交付执行」——串行/并行分支共用。含**失败自动重试一次**(优化5,
// 对齐 Step3 自愈:网络错误 / 502 重试,abort 不重试)。返回交付正文(aborted → 空)。 // 对齐 Step3 自愈:网络错误 / 502 重试,abort 不重试)。返回交付正文(aborted → 空)。
@@ -1601,8 +1677,13 @@ export function useStep2Orchestration(
peerDeliveries: priorDeliveries, peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined, model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式(快速/思考/专业问答/多智能体问答)→ thinking/reasoning/subagent。 // 席位执行模式(快速/思考/专业问答/多智能体问答)→ thinking/reasoning/subagent。
...seatModeToRequestFields(selectedModeRef.current), // 席位覆盖优先(业务链条里给该席位单独配的推理深度),否则跟随链条级 selectedMode。
...seatModeToRequestFields(seatModesRef.current[a.agentId] ?? selectedModeRef.current),
// 会商技能识别强化(该席位在所选业务链条里的开关,按 agentId 查表);与该席位
// agent 自身开关取 OR。
seatSkillDirective: seatSkillDirectivesRef.current[a.agentId] ?? false,
signal: subAborter.signal, signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel),
onTextDelta: (text) => onTextDelta: (text) =>
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text), a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args) => {
@@ -1683,10 +1764,16 @@ export function useStep2Orchestration(
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。 // 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
// 首轮纯派活、无交付,传 false 保持上下文精简。 // 首轮纯派活、无交付,传 false 保持上下文精简。
synthesisMode: safetyCounter > 1, synthesisMode: safetyCounter > 1,
// 削减重复广播(2026-06-20):首轮(safetyCounter==1)保留预广播——它把任务背景播进各席位
// thread,为席位打底;第 2 轮起的续轮/综合广播是「总控收到:<续轮 prompt>」纯噪声 + N 次
// 读写巨大席位 thread 的纯浪费(还会让弱模型从历史模仿派活),故 suppress。席位的前序交付
// 另经「席位交付广播」(broadcastSeatDeliveryToOthers)送达,不受影响。
suppressPreBroadcast: safetyCounter > 1,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。 // 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef), files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined, model: selectedModelRef.current || undefined,
signal: leaderAborter.signal, signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName); const label = phaseLabel(phase, "leader", toolName);
@@ -1954,10 +2041,15 @@ export function useStep2Orchestration(
dAgentThreadId: ids, dAgentThreadId: ids,
agentName: coord, agentName: coord,
newMessage: leaderTask, newMessage: leaderTask,
// 削减重复广播(2026-06-20):首个席位(i===0)保留预广播为其打底任务背景;从第 2 个席位起
// suppress——后续席位的前序交付已由「席位交付广播」送达其 thread,总控派活 prompt 再播一遍
// 是 N 次读写巨大 thread 的纯浪费(还会让弱模型模仿派活)。
suppressPreBroadcast: i > 0,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。 // 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef), files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined, model: selectedModelRef.current || undefined,
signal: leaderAborter.signal, signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName); const label = phaseLabel(phase, "leader", toolName);
@@ -2028,10 +2120,18 @@ export function useStep2Orchestration(
agentName: seat.agent_id, agentName: seat.agent_id,
displayName: seatName, displayName: seatName,
newMessage: task, newMessage: task,
// 研讨前并行取数(gatherFirst)的产物作为共享资料注入:chain 席位也能直接引用、少重复检索。
// chain 串行靠 thread 广播看前序交付;取数资料这里另经 peerDeliveries 一并带上。
peerDeliveries: gatheredDeliveriesRef.current,
model: perSeatModel || selectedModelRef.current || undefined, model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → thinking/reasoning/subagent(与推荐/DAG 路径一致)。 // 席位执行模式 → thinking/reasoning/subagent(与推荐/DAG 路径一致)。
...seatModeToRequestFields(selectedModeRef.current), // 席位覆盖优先(业务链条里给该席位单独配的推理深度),否则跟随链条级 selectedMode。
...seatModeToRequestFields(seatModesRef.current[seat.agent_id] ?? selectedModeRef.current),
// 会商技能识别强化(该席位在所选业务链条里的开关,按 agentId 查表);与该席位
// agent 自身开关取 OR。
seatSkillDirective: seatSkillDirectivesRef.current[seat.agent_id] ?? false,
signal: subAborter.signal, signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(subBubbleId, false),
onTextDelta: (text) => appendToDialogue(subBubbleId, text), onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
@@ -2091,8 +2191,11 @@ export function useStep2Orchestration(
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。", "各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
// 链条收口就是综合轮:让后端注入各席位完整交付正文。 // 链条收口就是综合轮:让后端注入各席位完整交付正文。
synthesisMode: true, synthesisMode: true,
// 削减重复广播:收口轮不派活、研讨已结束,把总控收口 prompt 播进席位 thread 纯浪费,suppress。
suppressPreBroadcast: true,
model: selectedModelRef.current || undefined, model: selectedModelRef.current || undefined,
signal: closeoutAborter.signal, signal: closeoutAborter.signal,
onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false),
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text), onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName); const label = phaseLabel(phase, "leader", toolName);
@@ -2189,7 +2292,8 @@ export function useStep2Orchestration(
let finishedAllStages = true; let finishedAllStages = true;
// 跨 stage 累积的**完整交付** {name, content}[] —— 注入下一 stage 席位的 run context // 跨 stage 累积的**完整交付** {name, content}[] —— 注入下一 stage 席位的 run context
// (peerDeliveries),供 read_peer_delivery 工具按需取全文(§12.9 传摘要按需传全文)。 // (peerDeliveries),供 read_peer_delivery 工具按需取全文(§12.9 传摘要按需传全文)。
const priorDeliveries: Array<{ name: string; content: string }> = []; // 研讨前并行取数(gatherFirst)的产物作为初始注入:首个 stage 席位开局即带共享资料。
const priorDeliveries: Array<{ name: string; content: string }> = [...gatheredDeliveriesRef.current];
// Phase 7:初始化 DAG 流程图节点状态 —— 计划内所有席位置 pending,执行过程中逐个点亮。 // Phase 7:初始化 DAG 流程图节点状态 —— 计划内所有席位置 pending,执行过程中逐个点亮。
const markDagNode = (agentId: string, state: ChainNodeState) => const markDagNode = (agentId: string, state: ChainNodeState) =>
@@ -2253,6 +2357,7 @@ export function useStep2Orchestration(
files: takeOnce(pendingInjectFilesRef), files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined, model: selectedModelRef.current || undefined,
signal: leaderAborter.signal, signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName); const label = phaseLabel(phase, "leader", toolName);
@@ -2370,10 +2475,15 @@ export function useStep2Orchestration(
peerDeliveries: priorDeliveries, peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined, model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → thinking/reasoning/subagent(与推荐/链条路径一致)。 // 席位执行模式 → thinking/reasoning/subagent(与推荐/链条路径一致)。
...seatModeToRequestFields(selectedModeRef.current), // 席位覆盖优先(业务链条里给该席位单独配的推理深度),否则跟随链条级 selectedMode。
...seatModeToRequestFields(seatModesRef.current[agentId] ?? selectedModeRef.current),
// 会商技能识别强化(该席位在所选业务链条里的开关,按 agentId 查表);与该
// 席位 agent 自身开关取 OR。
seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal, signal: subAborter.signal,
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点 // §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。 // stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
onTextDelta: (text) => onTextDelta: (text) =>
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text), isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args) => {
@@ -2495,10 +2605,132 @@ export function useStep2Orchestration(
], ],
); );
// 研讨前「并行取数」阶段:对**全席位并行**跑一轮「只取数、不研讨」,产出存进 gatheredDeliveriesRef
// 作为共享资料注入后续研讨轮(recommend/dag 经 priorDeliveries、chain 经 peerDeliveries)。复用
// recommend 同款并行批显示。每轮研讨只取一次(由 runActiveOrchestration 的 gatherPhaseDoneRef 闸门控制)。
const runGatherPhase = useCallback(
async (ids: ThreadIdMap): Promise<void> => {
const agents = selectedAgentsRef.current;
if (agents.length === 0) return;
// 与后端 engine._GATHER_TASK 同义:只取数、不研讨、不下结论。
const GATHER_TASK =
"【资料收集阶段——只取数、不研讨】\n现在是正式研讨**之前**的并行资料收集阶段。请你**只负责把与本次任务、" +
"与你这个席位职责相关的真实信息/数据收集、整理出来**(优先用你配置的技能去检索/获取真实信息,没有技能可凭专业知识梳理)," +
"为后续正式研讨打底。\n**这一步不要做分析评估、不要给方案、不要下结论**——把判断与方案留到研讨阶段。" +
"请把你收集到的关键信息整理成条理清晰的事实/要点清单输出,**不要向用户提问或等待确认**。";
const seatPrep = agents.map((a) => {
const meta = agentNameToRoleRef.current[a.agent_id.toLowerCase()];
const seatName = meta?.displayName ?? a.name;
const avatarType = meta?.avatarType ?? "blue";
const seatThreadId = resolveSeatThreadId(ids, a.agent_id);
const bubbleId = appendStreamingDialogue({
sender: seatName,
avatarType,
initialContent: "",
confidence: "并行取数中",
agentThreadId: seatThreadId,
});
return { agentId: a.agent_id, seatName, avatarType, seatThreadId, bubbleId, meta };
});
markParallelSpeakers(
seatPrep.map((p) => p.meta?.speakerId).filter((v): v is number => typeof v === "number"),
);
setParallelBatch(
seatPrep.map((p) => ({
agentId: p.agentId,
displayName: p.seatName,
avatarType: p.avatarType,
status: "running" as const,
bubbleId: p.bubbleId,
})),
);
setSelectedParallelSeats(seatPrep.slice(0, 1).map((p) => p.agentId));
const markBatchStatus = (agentId: string, status: ParallelSeatView["status"]) =>
setParallelBatch((prev) => prev.map((p) => (p.agentId === agentId ? { ...p, status } : p)));
const results = await Promise.all(
seatPrep.map(async ({ agentId, seatName, seatThreadId, bubbleId, meta }) => {
const perSeatModel = meta ? agentConfigsRef.current[meta.speakerId]?.model : undefined;
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: agentId,
displayName: seatName,
newMessage: GATHER_TASK,
model: perSeatModel || selectedModelRef.current || undefined,
...seatModeToRequestFields(seatModesRef.current[agentId] ?? selectedModeRef.current),
seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal,
onModelSwitch: makeModelSwitchHandler(bubbleId, true),
onTextDelta: (text) => ingestParallelDelta(bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, bubbleId);
},
});
activeAbortersRef.current.delete(subAborter);
finalizeDialogue(bubbleId, specRes.content, "资料收集");
markBatchStatus(agentId, "done");
if (meta) markSpeakerIdle(meta.speakerId);
parallelStreamRef.current.delete(bubbleId);
return specRes.content?.trim() ? { name: seatName, content: specRes.content } : null;
} catch (err) {
activeAbortersRef.current.delete(subAborter);
// 取数失败不阻断研讨:该席位无资料即可,研讨阶段它仍可自行检索。
finalizeDialogue(bubbleId, `(取数失败) ${(err as Error).message}`, "取数失败");
markBatchStatus(agentId, "done");
parallelStreamRef.current.delete(bubbleId);
return null;
}
}),
);
setParallelBatch([]);
setSelectedParallelSeats([]);
resetParallelStreams();
gatheredDeliveriesRef.current = results.filter(
(r): r is { name: string; content: string } => !!r,
);
},
[
appendStreamingDialogue,
finalizeDialogue,
ingestParallelDelta,
resetParallelStreams,
markParallelSpeakers,
markSpeakerIdle,
resolveSeatThreadId,
handleArtifactToolPhase,
handleUpstreamFrame,
ingestSubagentTaskFrame,
setDialogueConfidence,
appendStepIfNew,
],
);
// 按当前模式分发到对应编排函数。init / 恢复 / 干预 / 澄清回复统一走这里。 // 按当前模式分发到对应编排函数。init / 恢复 / 干预 / 澄清回复统一走这里。
const runActiveOrchestration = useCallback( const runActiveOrchestration = useCallback(
(seedMessage: string, ids: ThreadIdMap, coord: string) => { async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
const mode = orchestrationModeRef.current; const mode = orchestrationModeRef.current;
// 研讨前并行取数(opt-in,每轮一次):先把取数产物准备好,再分发到具体编排(研讨轮注入资料)。
// gatherPhaseDoneRef 闸门保证澄清续跑 / 干预重入不重复取数;置位在 await 前,避免并发重入双跑。
if (gatherFirstRef.current && !gatherPhaseDoneRef.current) {
gatherPhaseDoneRef.current = true;
try {
await runGatherPhase(ids);
} catch (err) {
console.warn("[roundtable] 研讨前并行取数阶段失败,跳过取数继续研讨:", err);
}
}
// dag:**只在计划尚未跑过时**执行整张 DAG;一旦跑过(用户干预 / 恢复 / 澄清回复), // dag:**只在计划尚未跑过时**执行整张 DAG;一旦跑过(用户干预 / 恢复 / 澄清回复),
// 改走 leader 自由派活循环,由总控接着派活,而不是把 DAG 从头重跑一遍。 // 改走 leader 自由派活循环,由总控接着派活,而不是把 DAG 从头重跑一遍。
if ( if (
@@ -2513,7 +2745,7 @@ export function useStep2Orchestration(
} }
return runOrchestration(seedMessage, ids, coord); return runOrchestration(seedMessage, ids, coord);
}, },
[runOrchestration, runChainOrchestration, runDagOrchestration], [runGatherPhase, runOrchestration, runChainOrchestration, runDagOrchestration],
); );
// ─── Effects ───────────────────────────────────────────────────── // ─── Effects ─────────────────────────────────────────────────────
@@ -2829,6 +3061,9 @@ export function useStep2Orchestration(
step2InitRef.current = false; step2InitRef.current = false;
// 新一轮研讨:允许 DAG 计划重新执行一遍。 // 新一轮研讨:允许 DAG 计划重新执行一遍。
dagPlanExecutedRef.current = false; dagPlanExecutedRef.current = false;
// 新一轮研讨:允许「研讨前并行取数」再取一次(清掉上一轮取数产物 + 闸门)。
gatherPhaseDoneRef.current = false;
gatheredDeliveriesRef.current = [];
setInitNonce((n) => n + 1); setInitNonce((n) => n + 1);
}, []); }, []);
@@ -2947,6 +3182,8 @@ export function useStep2Orchestration(
hasAutoOpenedRef.current = new Set(); hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null; pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false; dagPlanExecutedRef.current = false;
gatherPhaseDoneRef.current = false;
gatheredDeliveriesRef.current = [];
deselectArtifact(); deselectArtifact();
step2InitRef.current = false; step2InitRef.current = false;
}, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]); }, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]);
@@ -325,6 +325,12 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
newMessage: message, newMessage: message,
model: selectedModel || undefined, model: selectedModel || undefined,
signal: aborter.signal, signal: aborter.signal,
// 模型自动容错:后端切到下一个模型重跑 → 清空失败尝试的半截累计(acc + 气泡)。
onModelSwitch: (info) => {
acc = "";
setDialogues((prev) => prev.map((d) => (d.id === bubbleId ? { ...d, content: "" } : d)));
setDialogueConfidence(bubbleId, `模型切换:${info.failedModel}→${info.nextModel}(${info.reason})`);
},
onTextDelta: (text) => { onTextDelta: (text) => {
appendToDialogue(bubbleId, text); appendToDialogue(bubbleId, text);
acc += text; acc += text;
@@ -770,6 +770,16 @@ export function useStep3Report(
newMessage: prompt, newMessage: prompt,
model: selectedModel || undefined, model: selectedModel || undefined,
signal: aborter.signal, signal: aborter.signal,
// 模型自动容错:后端切到下一个模型重跑 → 清空失败尝试半截正文 + 步骤卡留可见提示。
onModelSwitch: (info) => {
setDialogues((prev) => prev.map((d) => (d.id === bubbleId ? { ...d, content: "" } : d)));
setDialogueConfidence(bubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
appendStepIfNew(
bubbleId,
`⚠️ 模型容错:「${info.failedModel}」${info.reason},改用「${info.nextModel}」`,
`model_switch:${info.nextModel}`,
);
},
onTextDelta: (text) => appendToDialogue(bubbleId, text), onTextDelta: (text) => appendToDialogue(bubbleId, text),
onPhaseChange: (p, toolName, toolArgs) => { onPhaseChange: (p, toolName, toolArgs) => {
const label = reportPhaseLabel(p, toolName); const label = reportPhaseLabel(p, toolName);
@@ -49,6 +49,7 @@ import {
type FlowIntent, type FlowIntent,
} from "@/roundtable-planning/api/flow-extract"; } from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants"; import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
import { readActionDetail } from "@/core/auth/task-deeplink";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */ /** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md"; const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
@@ -179,10 +180,19 @@ function buildMaterials(opts: {
seatDeliveries: ReportSeatDelivery[]; seatDeliveries: ReportSeatDelivery[];
consensusText: string; consensusText: string;
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
/** xdfx:本次「行动名称」,作为材料抬头喂进总结(报告围绕该行动展开)。 */
actionName?: string | null;
}): string { }): string {
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt } = opts; const { intentReady, seatDeliveries, consensusText, coordinatorPrompt, actionName } = opts;
const parts: string[] = []; const parts: string[] = [];
const an = actionName?.trim();
if (an) {
parts.push("# 本次行动");
parts.push(an);
parts.push("");
}
const cp = coordinatorPrompt?.trim(); const cp = coordinatorPrompt?.trim();
if (cp) { if (cp) {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)"); parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
@@ -294,6 +304,7 @@ function buildSummaryPrompt(opts: {
seatDeliveries: ReportSeatDelivery[]; seatDeliveries: ReportSeatDelivery[];
consensusText: string; consensusText: string;
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
actionName?: string | null;
flowMode?: boolean; flowMode?: boolean;
}): string { }): string {
const parts: string[] = []; const parts: string[] = [];
@@ -315,6 +326,18 @@ function buildSummaryPrompt(opts: {
} }
parts.push(""); parts.push("");
parts.push(buildMaterials(opts)); parts.push(buildMaterials(opts));
// 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)——两种模式通用,与后端 build_summary_prompt 同义。
parts.push("");
parts.push(
"【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" +
"你是这份总结的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)拆成" +
"用户核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、有无跑题或矛盾;" +
"再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→绝不凭空编。" +
"**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。" +
"**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分/哪些不足/有哪些可补充(可在对话里让我补全);" +
"**即便都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**" +
"最终产出要明显比各席位交付的简单拼接更扣用户意图。",
);
if (opts.flowMode) { if (opts.flowMode) {
parts.push( parts.push(
"请严格遵循你的职责设定:全程简体中文。" + "请严格遵循你的职责设定:全程简体中文。" +
@@ -1031,9 +1054,21 @@ export function useStep3Summary(
dAgentThreadId: { [agentName]: threadId }, dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结报告", displayName: "方案总结报告",
newMessage: prompt, newMessage: prompt,
// 深链接业务码:后端为 summary 单例注入「业务链完整性核对」(核对产出相对业务链完不完整)。
businessCode: ingestBusinessCodeRef.current,
model: selectedModel || undefined, model: selectedModel || undefined,
files: files && files.length > 0 ? files : undefined, files: files && files.length > 0 ? files : undefined,
signal: aborter.signal, signal: aborter.signal,
// 模型自动容错:后端切到下一个模型重跑 → 清空失败尝试的半截正文 + 在步骤卡留一条可见提示。
onModelSwitch: (info) => {
setDialogues((prev) => prev.map((d) => (d.id === bubbleId ? { ...d, content: "" } : d)));
setDialogueConfidence(bubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
appendStepIfNew(
bubbleId,
`⚠️ 模型容错:「${info.failedModel}」${info.reason},改用「${info.nextModel}」`,
`model_switch:${info.nextModel}`,
);
},
onTextDelta: (text) => appendToDialogue(bubbleId, text), onTextDelta: (text) => appendToDialogue(bubbleId, text),
onPhaseChange: (p, toolName, pArgs) => { onPhaseChange: (p, toolName, pArgs) => {
const label = summaryPhaseLabel(p, toolName); const label = summaryPhaseLabel(p, toolName);
@@ -1126,10 +1161,25 @@ export function useStep3Summary(
seatDeliveries, seatDeliveries,
chainLevels, chainLevels,
coordinatorPrompt, coordinatorPrompt,
// 深链接业务码:后端按业务链抽取规范强制注入完整层级(根治"无论什么业务都按目的→行为体抽")。
businessCode: ingestBusinessCodeRef.current,
// xdfx(7BF):根节点用「行动名称」(流程图根节点显示当前行动,而非任务名)。
rootLabel:
ingestBusinessCodeRef.current === "7BF" ? readActionDetail()?.actionName ?? null : null,
model: selectedModel || undefined, model: selectedModel || undefined,
currentGraph: opts?.currentGraph ?? null, currentGraph: opts?.currentGraph ?? null,
instruction: opts?.instruction, instruction: opts?.instruction,
signal: aborter.signal, signal: aborter.signal,
// 模型自动容错:后端切到下一个模型重抽 → 清空抽取气泡里失败尝试的半截内容 + 留可见提示。
onModelSwitch: (info) => {
setDialogues((prev) => prev.map((d) => (d.id === extractBubbleId ? { ...d, content: "" } : d)));
setDialogueConfidence(extractBubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
appendStepIfNew(
extractBubbleId,
`⚠️ 模型容错:「${info.failedModel}」${info.reason},改用「${info.nextModel}」`,
`model_switch:${info.nextModel}`,
);
},
onExtractDelta: (t) => appendToDialogue(extractBubbleId, t), onExtractDelta: (t) => appendToDialogue(extractBubbleId, t),
onValidating: () => { onValidating: () => {
setFlowPhase("validating"); setFlowPhase("validating");
@@ -1242,6 +1292,9 @@ export function useStep3Summary(
seatDeliveries, seatDeliveries,
consensusText, consensusText,
coordinatorPrompt, coordinatorPrompt,
// xdfx(7BF):把本次行动名称喂进总结材料,让报告围绕该行动展开。
actionName:
ingestBusinessCodeRef.current === "7BF" ? readActionDetail()?.actionName ?? null : null,
flowMode, flowMode,
}); });
await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode }); await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
@@ -1498,10 +1551,17 @@ export function useStep3Summary(
if (!intent) { if (!intent) {
// **立刻**显示「智能体调度中」气泡(不等意图接口返回),消除连接卡顿时的"卡住"感。 // **立刻**显示「智能体调度中」气泡(不等意图接口返回),消除连接卡顿时的"卡住"感。
dispatchBubbleId = appendStreamingDialogue({ sender: "智能体", avatarType: "blue" }); dispatchBubbleId = appendStreamingDialogue({ sender: "智能体", avatarType: "blue" });
// 入库可用(按钮已出现、未入库 / 未在跑)时把 canIngest 传给意图识别——让「帮我入库」被识别为
// ingest 而不是落到 qa/unclear(之前主分类器不懂入库,只有「稍后入库待命」时才识别入库,是个缺口)。
const canIngestNow =
ingestAvailableRef.current &&
ingestStatusRef.current !== "running" &&
ingestStatusRef.current !== "done";
const r = await classifyFlowIntent({ const r = await classifyFlowIntent({
message: text, message: text,
hasReport: !!currentMdRef.current.trim(), hasReport: !!currentMdRef.current.trim(),
hasFlow: !!flowGraphRef.current, hasFlow: !!flowGraphRef.current,
canIngest: canIngestNow,
model: selectedModel || undefined, model: selectedModel || undefined,
}); });
intent = r.intent; intent = r.intent;
@@ -1513,6 +1573,12 @@ export function useStep3Summary(
setPendingFlowClarification(text); // 组件渲染选项让用户自己定 setPendingFlowClarification(text); // 组件渲染选项让用户自己定
return; return;
} }
if (intent === "ingest") {
// 识别为入库(后端已保证仅 can_ingest 时才会返回 ingest)→ 直接入库;复用调度气泡。
if (dispatchBubbleId != null) finalizeDialogue(dispatchBubbleId, "正在入库…", "入库中");
await runIngest();
return;
}
if (intent === "regenerate_flow" || intent === "modify_flow") { if (intent === "regenerate_flow" || intent === "modify_flow") {
const flowAborter = new AbortController(); const flowAborter = new AbortController();
aborterRef.current = flowAborter; aborterRef.current = flowAborter;
@@ -29,6 +29,22 @@ export interface ChainSeat {
* 的 `handleConfirmAgents`)→ 走现有 per-seat 模型链路。 * 的 `handleConfirmAgents`)→ 走现有 per-seat 模型链路。
*/ */
model?: string | null; model?: string | null;
/**
* 可选「会商技能识别强化」开关(弱模型补偿)。true = 该席位作为会商子智能体执行时,把其
* 配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 的硬指令)拼进本轮任务。与该席位
* 智能体自身的同名开关(智能体编辑里的开关)取 OR。缺省 / false = 关。随 `seats` JSON 落库
* (无需 DB 列),在链条编辑器里逐席位开关。
*/
seat_skill_directive?: boolean;
/**
* 可选「席位推理深度覆盖」:覆盖链条级 `seatMode`,只对该席位生效。值是同一套
* `SeatMode`(flash/thinking/pro/ultra);null / 缺省 = 跟随链条级默认。用于「卡顿提速」:
* 把信息收集/边角席位单独降到 `flash`(不思考、最快),只给关键席位留 `pro`/`ultra`。
* 随 `seats` JSON 落库(无需 DB 列,但需在后端 `ChainSeatSchema` 显式声明,否则 model_dump
* 会剥掉)。**字段名用 snake_case `seat_mode`** 与 `model`/`seat_skill_directive` 一致(seats
* 数组在 `api/chains.ts` 不做 camel↔snake 转换,键名须与后端 schema 逐字匹配)。
*/
seat_mode?: SeatMode | null;
} }
/** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */ /** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */
@@ -61,6 +77,12 @@ export interface BusinessChain {
* 缺省 = 未设,调用方回退 `DEFAULT_SEAT_MODE`(pro)。 * 缺省 = 未设,调用方回退 `DEFAULT_SEAT_MODE`(pro)。
*/ */
seatMode?: SeatMode; seatMode?: SeatMode;
/**
* 可选「研讨前并行取数」开关(逐条 opt-in,缺省 false)。true = 选入研讨时,研讨前先跑一轮
* **全席位并行『取数』**(各席位只调技能取数、不研讨),产出作为共享资料注入后续研讨轮,把最慢
* 的技能延迟前置并行、给(尤其 chain 串行的)研讨段提速。三种编排通用。后端列 `gather_first`。
*/
gatherFirst?: boolean;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */ /** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean; isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */ /** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
@@ -80,8 +102,9 @@ export interface SeatSelectionResult {
/** /**
* 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 * 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。
* `model` 为该席位在来源链条里配置的模型覆盖(空 / 缺省 = 跟随全局模型)。 * `model` 为该席位在来源链条里配置的模型覆盖(空 / 缺省 = 跟随全局模型)。
* `seat_skill_directive` 为该席位在来源链条里配置的「技能识别强化」开关(缺省 = 关)。
*/ */
agents: Array<{ agent_id: string; name: string; model?: string | null }>; agents: Array<{ agent_id: string; name: string; model?: string | null; seat_skill_directive?: boolean; seatMode?: SeatMode | null }>;
/** /**
* 决定 Step 2 走哪种编排: * 决定 Step 2 走哪种编排:
* - `recommend` 总控自由派活; * - `recommend` 总控自由派活;
@@ -107,6 +130,11 @@ export interface SeatSelectionResult {
* 缺省由调用方按 `DEFAULT_SEAT_MODE`(pro)处理。 * 缺省由调用方按 `DEFAULT_SEAT_MODE`(pro)处理。
*/ */
seatMode?: SeatMode; seatMode?: SeatMode;
/**
* 「研讨前并行取数」开关(来源链条的 `gatherFirst`)。true = 研讨前先并行取数再研讨。
* 三种编排通用,缺省 false。recommend(无链条)= false。
*/
gatherFirst?: boolean;
} }
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */ /** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
@@ -135,6 +163,13 @@ export function buildChainSeatSelection(
// 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局。 // 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局。
const modelById = new Map(chain.seats.map((s) => [s.agent_id, s.model ?? null])); const modelById = new Map(chain.seats.map((s) => [s.agent_id, s.model ?? null]));
const modelOf = (id: string) => modelById.get(id) ?? null; const modelOf = (id: string) => modelById.get(id) ?? null;
// 席位「技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。
const skillById = new Map(chain.seats.map((s) => [s.agent_id, s.seat_skill_directive ?? false]));
const skillOf = (id: string) => skillById.get(id) ?? false;
// 席位「推理深度覆盖」(链条配置 seat_mode):按 agent_id 取,null/缺省 = 跟随链条级 seatMode。
// 输出到 SeatSelectionResult.agents[].seatMode(camel,喂给编排/作业的 seatModes map)。
const seatModeById = new Map(chain.seats.map((s) => [s.agent_id, s.seat_mode ?? null]));
const seatModeOf = (id: string) => seatModeById.get(id) ?? null;
const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id)); const survivingSeats = chain.seats.filter((s) => existingIds.has(s.agent_id));
if (survivingSeats.length < CHAIN_MIN_SEATS) return null; if (survivingSeats.length < CHAIN_MIN_SEATS) return null;
@@ -156,13 +191,13 @@ export function buildChainSeatSelection(
if (hasParallel || hasGoal || hasCoord) { if (hasParallel || hasGoal || hasCoord) {
const agents = plan.stages const agents = plan.stages
.flatMap((st) => st.agentIds) .flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) })); .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), seatMode: seatModeOf(id) }));
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode }; return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode, gatherFirst: chain.gatherFirst ?? false };
} }
} }
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) })); const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode }; return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode, gatherFirst: chain.gatherFirst ?? false };
} }
/** /**
@@ -13,6 +13,7 @@
* 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。 * 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。
*/ */
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain"; import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import type { SeatMode } from "@/roundtable-planning/lib/seat-mode";
/** 一个并行 stage:含其并行席位与可选「本阶段目标」(注入给总控/席位,仅 dag 模式生效)。 */ /** 一个并行 stage:含其并行席位与可选「本阶段目标」(注入给总控/席位,仅 dag 模式生效)。 */
export interface StageGroup { export interface StageGroup {
@@ -126,6 +127,35 @@ export function setSeatModel(groups: StageGroups, agentId: string, model: string
}); });
} }
/**
* 设置某个席位的「会商技能识别强化」开关(不可变,供编辑器席位下拉 onChange)。
* 按 agent_id 定位,跨 stage 适用。随 `seats` JSON 落库。
*/
export function setSeatSkillDirective(groups: StageGroups, agentId: string, enabled: boolean): StageGroups {
return groups.map((g) => {
if (!g.seats.some((s) => s.agent_id === agentId)) return g;
return {
...g,
seats: g.seats.map((s) => (s.agent_id === agentId ? { ...s, seat_skill_directive: enabled } : s)),
};
});
}
/**
* 设置某个席位的「推理深度覆盖」(不可变,供编辑器席位下拉 onChange)。
* `mode` 为 null = 清除覆盖(跟随链条级 seatMode)。按 agent_id 定位,跨 stage 适用。
* 随 `seats` JSON 落库。
*/
export function setSeatMode(groups: StageGroups, agentId: string, mode: SeatMode | null): StageGroups {
return groups.map((g) => {
if (!g.seats.some((s) => s.agent_id === agentId)) return g;
return {
...g,
seats: g.seats.map((s) => (s.agent_id === agentId ? { ...s, seat_mode: mode } : s)),
};
});
}
// ── 变换(均返回新结构,不可变)────────────────────────────────────────────── // ── 变换(均返回新结构,不可变)──────────────────────────────────────────────
/** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */ /** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */
@@ -392,6 +392,13 @@ export function buildDagStageLeaderTask(args: {
lines.push( lines.push(
`【本阶段目标】${goalText}`, `【本阶段目标】${goalText}`,
"请据此目标为每个席位拆解任务,并要求他们**显式承接上一阶段的产出**来完成本阶段目标。", "请据此目标为每个席位拆解任务,并要求他们**显式承接上一阶段的产出**来完成本阶段目标。",
"⚠️ 派活时**严格忠实于本阶段目标,不要自行新增目标里没有的「数量上限 / 只取最有代表性的 N 组 / 精简归纳成几条」等限制**," +
"也不要照搬其它阶段的数量(如上一阶段筛了 3 组,不代表本阶段也只要 3 组);" +
"目标没要求筛选 / 限定数量时,就要求席位**按其职责把每一条都完整输出**——" +
"若上一阶段有 N 条产出需逐条处理,就让席位**逐条、全部**产出(有多少条就出多少条),绝不替席位归纳 / 合并 / 只取代表性的几条。",
"⚠️ 也**不要替席位新增目标 / 席位职责之外的「输出体例 / 风格」要求**(如『写成连贯故事 / 要有场景感 / " +
"举具体城市景区为例 / 虚构时间地点情节』等)——派活只需转达本阶段目标 + 上一阶段产物 + 承接关系," +
"**让席位严格按它自己的职责设定(SOUL)输出**,且**忠实基于上一阶段的真实产物做分析,不要诱导席位脱离输入去编造 / 虚构事例**。",
); );
} }
lines.push( lines.push(
@@ -66,6 +66,10 @@ function toStep2Dialogue(d: JobDialogue, idxByAgent: Map<string, number>): Step2
if (d.role === "leader") { if (d.role === "leader") {
return { ...base, sender: "总控协调", avatarType: "blue", confidence: "派活说明" }; return { ...base, sender: "总控协调", avatarType: "blue", confidence: "派活说明" };
} }
// 模型自动容错的系统提示气泡(后端 progress.model_switch 以 role="system" 落库)。
if (d.role === "system") {
return { ...base, sender: d.name || "系统提示", avatarType: "amber", confidence: "模型容错" };
}
const aid = d.agentId ?? ""; const aid = d.agentId ?? "";
return { return {
...base, ...base,
@@ -52,7 +52,8 @@ export interface DispatchChainNode {
/** 一条研讨对话记录(leader 说明 / 某席位交付)。 */ /** 一条研讨对话记录(leader 说明 / 某席位交付)。 */
export interface JobDialogue { export interface JobDialogue {
id: number; id: number;
role: "leader" | "seat"; // "system" = 模型自动容错等系统提示气泡(后端 progress.model_switch 落库)。
role: "leader" | "seat" | "system";
agentId: string | null; agentId: string | null;
name: string; name: string;
content: string; content: string;
@@ -132,6 +133,26 @@ export interface StartJobPayload {
* reasoning/subagent 三字段发给后端。 * reasoning/subagent 三字段发给后端。
*/ */
seatMode?: SeatMode; seatMode?: SeatMode;
/**
* 每席位「技能识别强化」开关映射 {agentId: bool}(沿用所选业务链条里每个席位的配置)。
* 后台席位 run 按 agentId 查表(与各席位智能体自身的同名开关取 OR)决定是否注入。缺省 {}。
*/
seatSkillDirectives?: Record<string, boolean>;
/**
* 每席位「推理深度覆盖」映射 {agentId: SeatMode | null}(沿用所选业务链条里每个席位单独配的
* 推理深度)。后台席位 run 按 agentId 查表,覆盖优先、否则跟随链条级 `seatMode`。缺省 {}。
*/
seatModes?: Record<string, SeatMode | null>;
/**
* 「研讨前并行取数」开关(来源链条的 gatherFirst)。true = 后台作业研讨前先并行取数再研讨。
* 在 `toStartBody` 里下发为后端 `gather_first`。缺省 false。
*/
gatherFirst?: boolean;
/**
* 深链接业务码(rwfx→6BF 等)。透传给后台总结报告的「业务链完整性核对」(写实业务如 rwfx 才生效)。
* 普通会商省略。`toStartBody` 里下发为后端 `business_code`。
*/
businessCode?: string | null;
} }
/** 终态集合——SSE/轮询判定作业是否结束。 */ /** 终态集合——SSE/轮询判定作业是否结束。 */
@@ -27,6 +27,7 @@ import {
Bot, Bot,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Gauge,
GitBranch, GitBranch,
Globe, Globe,
GripVertical, GripVertical,
@@ -37,6 +38,7 @@ import {
Plus, Plus,
Save, Save,
Sliders, Sliders,
Sparkles,
SplitSquareVertical, SplitSquareVertical,
Trash2, Trash2,
UserPlus, UserPlus,
@@ -63,6 +65,15 @@ import {
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ModelNameSelect } from "@/components/workspace/model-name-select"; import { ModelNameSelect } from "@/components/workspace/model-name-select";
@@ -104,7 +115,9 @@ import {
moveStage, moveStage,
removeSeat as removeSeatFromGroups, removeSeat as removeSeatFromGroups,
seatExistsInGroups, seatExistsInGroups,
setSeatMode as setSeatModeOnGroups,
setSeatModel, setSeatModel,
setSeatSkillDirective,
setStageGoal, setStageGoal,
splitSeatToNewStage, splitSeatToNewStage,
stageGroupsToSeats, stageGroupsToSeats,
@@ -165,6 +178,8 @@ export default function BusinessChainEditorPage() {
const [coordinatorPrompt, setCoordinatorPrompt] = useState(""); const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
// 默认席位执行模式(快速/思考/专业问答/多智能体)—— 选入研讨时弹窗默认用它。 // 默认席位执行模式(快速/思考/专业问答/多智能体)—— 选入研讨时弹窗默认用它。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE); const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
// 链条级「研讨前并行取数」开关(逐条 opt-in,默认关):开 = 选入研讨时研讨前先并行取数再研讨。
const [gatherFirst, setGatherFirst] = useState<boolean>(false);
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。 // 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
const [stageGroups, setStageGroups] = useState<StageGroups>([]); const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -290,6 +305,7 @@ export default function BusinessChainEditorPage() {
setDescription(chain.description ?? ""); setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? ""); setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setSeatMode(chain.seatMode ?? DEFAULT_SEAT_MODE); setSeatMode(chain.seatMode ?? DEFAULT_SEAT_MODE);
setGatherFirst(chain.gatherFirst ?? false);
setStageGroups(chainToStageGroups(chain)); setStageGroups(chainToStageGroups(chain));
setStep(1); setStep(1);
}, []); }, []);
@@ -302,6 +318,7 @@ export default function BusinessChainEditorPage() {
setDescription(""); setDescription("");
setCoordinatorPrompt(""); setCoordinatorPrompt("");
setSeatMode(DEFAULT_SEAT_MODE); setSeatMode(DEFAULT_SEAT_MODE);
setGatherFirst(false);
setStageGroups([]); setStageGroups([]);
setStep(1); setStep(1);
}, []); }, []);
@@ -366,6 +383,16 @@ export default function BusinessChainEditorPage() {
setStageGroups((prev) => setSeatModel(prev, agentId, model)); setStageGroups((prev) => setSeatModel(prev, agentId, model));
}, []); }, []);
// 席位「会商技能识别强化」开关:编辑器席位下拉 onChange。
const setSeatSkillDirectiveCb = useCallback((agentId: string, enabled: boolean) => {
setStageGroups((prev) => setSeatSkillDirective(prev, agentId, enabled));
}, []);
// 席位「推理深度覆盖」:编辑器席位小图标下拉 onChange(null = 清除,跟随链条级 seatMode)。
const setSeatModeCb = useCallback((agentId: string, mode: SeatMode | null) => {
setStageGroups((prev) => setSeatModeOnGroups(prev, agentId, mode));
}, []);
// ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)──────────────────────── // ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false); const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null); const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
@@ -564,6 +591,10 @@ export default function BusinessChainEditorPage() {
description: live?.description ?? s.description ?? "", description: live?.description ?? s.description ?? "",
// 席位模型覆盖:随席位一起落库(空 → null = 跟随全局)。 // 席位模型覆盖:随席位一起落库(空 → null = 跟随全局)。
model: s.model ?? null, model: s.model ?? null,
// 席位「会商技能识别强化」开关:随席位一起落库(缺省 = false 关)。
seat_skill_directive: s.seat_skill_directive ?? false,
// 席位「推理深度覆盖」:随席位一起落库(null = 跟随链条级 seatMode)。
seat_mode: s.seat_mode ?? null,
}; };
}); });
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。 // 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
@@ -582,6 +613,7 @@ export default function BusinessChainEditorPage() {
stageGoals, stageGoals,
coordinatorPrompt: coordPrompt, coordinatorPrompt: coordPrompt,
seatMode, seatMode,
gatherFirst,
}); });
if (!updated) { if (!updated) {
toast.error("链条已不存在,可能被删除"); toast.error("链条已不存在,可能被删除");
@@ -601,6 +633,7 @@ export default function BusinessChainEditorPage() {
stageGoals, stageGoals,
coordinatorPrompt: coordPrompt, coordinatorPrompt: coordPrompt,
seatMode, seatMode,
gatherFirst,
}); });
setCurrentChainId(created.id); setCurrentChainId(created.id);
toast.success("已创建业务链条"); toast.success("已创建业务链条");
@@ -616,6 +649,7 @@ export default function BusinessChainEditorPage() {
description, description,
coordinatorPrompt, coordinatorPrompt,
seatMode, seatMode,
gatherFirst,
stageGroups, stageGroups,
seatCountInvalid, seatCountInvalid,
currentChainId, currentChainId,
@@ -754,6 +788,22 @@ export default function BusinessChainEditorPage() {
</p> </p>
</div> </div>
{/* 研讨前并行取数:逐条 opt-in 的提速开关(默认关)。 */}
<div className={cn("flex items-start justify-between gap-3 border border-border bg-muted/30 p-3", R_CARD)}>
<div className="space-y-1">
<label className="flex items-center gap-1 text-xs font-semibold text-foreground">
<Gauge className="size-3.5 text-primary" />
研讨前并行取数(提速)
</label>
<p className="text-[11px] leading-relaxed text-muted-foreground">
开启后,选入研讨时会**先并行跑一轮「只取数」**:各席位同时用技能把相关信息收集回来(不研讨),
产出作为共享资料注入后续研讨轮。把最慢的技能检索前置、并行做一次,给(尤其按链条顺序串行的)
研讨段大幅提速;研讨阶段席位仍可按需再调技能。
</p>
</div>
<Switch checked={gatherFirst} onCheckedChange={setGatherFirst} className="mt-0.5 shrink-0" />
</div>
{/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */} {/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -1131,6 +1181,8 @@ export default function BusinessChainEditorPage() {
onMergeSeat={mergeSeatToStage} onMergeSeat={mergeSeatToStage}
onSplitSeat={splitSeat} onSplitSeat={splitSeat}
onSeatModelChange={setSeatModelCb} onSeatModelChange={setSeatModelCb}
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
onSeatModeChange={setSeatModeCb}
onStageGoalChange={setGoal} onStageGoalChange={setGoal}
goalOptimizingIdx={goalOptimizingIdx} goalOptimizingIdx={goalOptimizingIdx}
onOptimizeGoal={handleOptimizeStageGoal} onOptimizeGoal={handleOptimizeStageGoal}
@@ -1309,6 +1361,8 @@ function StagesColumn({
onMergeSeat, onMergeSeat,
onSplitSeat, onSplitSeat,
onSeatModelChange, onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatModeChange,
onStageGoalChange, onStageGoalChange,
goalOptimizingIdx, goalOptimizingIdx,
onOptimizeGoal, onOptimizeGoal,
@@ -1325,6 +1379,8 @@ function StagesColumn({
onMergeSeat: (agentId: string, targetStageIdx: number) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void; onSplitSeat: (agentId: string) => void;
onSeatModelChange: (agentId: string, model: string | null) => void; onSeatModelChange: (agentId: string, model: string | null) => void;
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void; onStageGoalChange: (stageIdx: number, goal: string) => void;
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */ /** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
goalOptimizingIdx: number | null; goalOptimizingIdx: number | null;
@@ -1423,6 +1479,8 @@ function StagesColumn({
onMergeSeat={onMergeSeat} onMergeSeat={onMergeSeat}
onSplitSeat={onSplitSeat} onSplitSeat={onSplitSeat}
onSeatModelChange={onSeatModelChange} onSeatModelChange={onSeatModelChange}
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
onSeatModeChange={onSeatModeChange}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)} onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
goalOptimizing={goalOptimizingIdx === stageIdx} goalOptimizing={goalOptimizingIdx === stageIdx}
anyGoalOptimizing={goalOptimizingIdx !== null} anyGoalOptimizing={goalOptimizingIdx !== null}
@@ -1452,6 +1510,8 @@ function StageSection({
onMergeSeat, onMergeSeat,
onSplitSeat, onSplitSeat,
onSeatModelChange, onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatModeChange,
onGoalChange, onGoalChange,
goalOptimizing, goalOptimizing,
anyGoalOptimizing, anyGoalOptimizing,
@@ -1470,6 +1530,8 @@ function StageSection({
onMergeSeat: (agentId: string, targetStageIdx: number) => void; onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void; onSplitSeat: (agentId: string) => void;
onSeatModelChange: (agentId: string, model: string | null) => void; onSeatModelChange: (agentId: string, model: string | null) => void;
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
onGoalChange: (goal: string) => void; onGoalChange: (goal: string) => void;
/** 本 Stage 的阶段目标正在 AI 优化中。 */ /** 本 Stage 的阶段目标正在 AI 优化中。 */
goalOptimizing: boolean; goalOptimizing: boolean;
@@ -1631,6 +1693,8 @@ function StageSection({
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)} onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
onSplit={() => onSplitSeat(seat.agent_id)} onSplit={() => onSplitSeat(seat.agent_id)}
onModelChange={(model) => onSeatModelChange(seat.agent_id, model)} onModelChange={(model) => onSeatModelChange(seat.agent_id, model)}
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
onDetail={() => onSeatDetail(seat.agent_id, seat.name)} onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
/> />
))} ))}
@@ -1654,6 +1718,8 @@ function SeatStrip({
onMergeDown, onMergeDown,
onSplit, onSplit,
onModelChange, onModelChange,
onSkillDirectiveChange,
onSeatModeChange,
onDetail, onDetail,
}: { }: {
seat: ChainSeat; seat: ChainSeat;
@@ -1668,6 +1734,10 @@ function SeatStrip({
onSplit: () => void; onSplit: () => void;
/** 席位模型覆盖变更(null = 清除,跟随全局)。 */ /** 席位模型覆盖变更(null = 清除,跟随全局)。 */
onModelChange: (model: string | null) => void; onModelChange: (model: string | null) => void;
/** 席位「会商技能识别强化」开关变更。 */
onSkillDirectiveChange: (enabled: boolean) => void;
/** 席位「推理深度覆盖」变更(null = 跟随链条级 seatMode)。 */
onSeatModeChange: (mode: SeatMode | null) => void;
onDetail: () => void; onDetail: () => void;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
@@ -1739,8 +1809,85 @@ function SeatStrip({
</div> </div>
</div> </div>
{/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */} {/* 控制:推理深度 / 技能识别强化 / 查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2"> <div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
{/* 席位推理深度覆盖:小图标 → 下拉框(跟随链条默认 + 四档)。设了覆盖 = 高亮。
「卡顿提速」用:把信息收集/边角席位降到「快速」,只给关键席位留「专业/多智能体」。 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title="席位推理深度(覆盖链条默认;降到「快速」可在模型卡顿时大幅提速)"
className={cn(
"p-1 transition",
R_CTRL,
seat.seat_mode
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Gauge className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel className="text-[11px] font-semibold">
席位推理深度
<span className="mt-0.5 block text-[10px] font-normal text-muted-foreground">
覆盖链条级默认,仅本席位生效。模型卡顿时把次要席位降到「快速」可大幅提速。
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className={cn("text-xs", !seat.seat_mode && "font-semibold text-primary")}
onClick={() => onSeatModeChange(null)}
>
跟随链条默认
</DropdownMenuItem>
{SEAT_MODE_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
className={cn("flex-col items-start gap-0 text-xs", seat.seat_mode === opt.value && "font-semibold text-primary")}
onClick={() => onSeatModeChange(opt.value)}
>
<span>{opt.label}</span>
<span className="text-[10px] font-normal leading-tight text-muted-foreground">{opt.description}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* 会商技能识别强化:小图标 → 下拉框(开关 + 说明)。开启 = 高亮。 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title="会商技能识别强化(弱模型补偿)"
className={cn(
"p-1 transition",
R_CTRL,
seat.seat_skill_directive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Sparkles className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 p-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="text-xs font-semibold text-foreground">会商技能识别强化</p>
<p className="text-[11px] leading-relaxed text-muted-foreground">
开启后,该席位作为会商子智能体执行时,会把已配置的技能(含 SKILL.md
路径与「先 read_file 再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在该智能体编辑里打开(任一即生效)。
</p>
</div>
<Switch
checked={seat.seat_skill_directive ?? false}
onCheckedChange={onSkillDirectiveChange}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
<button <button
type="button" type="button"
onClick={onDetail} onClick={onDetail}
@@ -46,6 +46,7 @@ import {
Loader2, Loader2,
Pencil, Pencil,
Plus, Plus,
RotateCw,
Save, Save,
Settings, Settings,
Sparkles, Sparkles,
@@ -80,6 +81,7 @@ import { RecommendAgentsDialog } from "@/roundtable-planning/components/Recommen
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog"; import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal"; import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel"; import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { readActionDetail, readCopTaskDetail } from "@/core/auth/task-deeplink";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel"; import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel"; import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
import type { import type {
@@ -159,6 +161,10 @@ interface RoundtablePrefill {
businessCode?: string; businessCode?: string;
/** 外部任务 id(无界抽屉):聊天记录按此存取、打开时回显最新一条。 */ /** 外部任务 id(无界抽屉):聊天记录按此存取、打开时回显最新一条。 */
taskId?: string; taskId?: string;
/** xdfx 深链:原始行动 id(URL ?taskId= 传入的就是它)。供会商取行动详情用。 */
actionId?: string;
/** xdfx 深链:行动名称(注入 Step 1 首条用户消息——任务名称+任务描述+行动名称)。 */
actionName?: string;
/** 是否进入即自动开跑 Step 1 意图分析。 */ /** 是否进入即自动开跑 Step 1 意图分析。 */
autoStart?: boolean; autoStart?: boolean;
} }
@@ -312,6 +318,16 @@ export default function RoundtablePlanningPage() {
// Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的 // Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的
// 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。 // 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE); const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
// Step 2 每席位「技能识别强化」开关映射 {agentId: bool}:由所选业务链条里**每个席位**的同名
// 配置决定(recommend 无链条 → 空 {})。透传给每个席位 run,与各席位智能体自身的开关取 OR。
const [seatSkillDirectives, setSeatSkillDirectives] = useState<Record<string, boolean>>({});
// Step 2 每席位「推理深度覆盖」映射 {agentId: SeatMode|null}:由所选业务链条里**每个席位**单独配
// 的推理深度决定(null/未配 → 跟随链条级 seatMode)。透传给席位 run(覆盖优先、否则跟随 seatMode)。
// 「卡顿提速」用:把次要席位降到 flash 即可大幅缩短第二步耗时。
const [seatModes, setSeatModes] = useState<Record<string, SeatMode | null>>({});
// Step 2「研讨前并行取数」开关:由所选业务链条的 gatherFirst 决定(recommend / 无链条 → false)。
// true = 研讨前先并行跑一轮全席位『取数』,产出作为共享资料注入研讨轮,给(尤其 chain 串行)研讨段提速。
const [gatherFirst, setGatherFirst] = useState<boolean>(false);
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活; // Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活;
// dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。 // dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。 // 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -467,6 +483,9 @@ export default function RoundtablePlanningPage() {
currentStep, currentStep,
selectedModel, selectedModel,
selectedMode: seatMode, selectedMode: seatMode,
seatSkillDirectives,
seatModes,
gatherFirst,
orchestrationMode, orchestrationMode,
orchestrationPlan, orchestrationPlan,
dagExecMode, dagExecMode,
@@ -944,6 +963,18 @@ export default function RoundtablePlanningPage() {
setOrchestrationMode(result.mode); setOrchestrationMode(result.mode);
// 席位执行模式:弹窗未带(旧调用)时回退默认 pro,保证行为可预期。 // 席位执行模式:弹窗未带(旧调用)时回退默认 pro,保证行为可预期。
setSeatMode(result.seatMode ?? DEFAULT_SEAT_MODE); setSeatMode(result.seatMode ?? DEFAULT_SEAT_MODE);
// 会商技能识别强化:取所选业务链条里**每席位**的开关 → {agentId: bool} 映射
// (recommend / 旧调用未带 → 空 {},各席位仅靠其智能体自身开关)。
setSeatSkillDirectives(
Object.fromEntries(result.agents.map((a) => [a.agent_id, a.seat_skill_directive ?? false])),
);
// 每席位推理深度覆盖:取所选业务链条里**每席位**单独配的 seatMode → {agentId: SeatMode|null}
// 映射(recommend / 未配 → null,跟随链条级 seatMode)。
setSeatModes(
Object.fromEntries(result.agents.map((a) => [a.agent_id, a.seatMode ?? null])),
);
// 研讨前并行取数:取所选业务链条的开关(recommend / 旧调用未带 → false)。
setGatherFirst(result.gatherFirst ?? false);
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null); setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null); setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast"); setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
@@ -1058,7 +1089,13 @@ export default function RoundtablePlanningPage() {
if (!step1.intentThreadId || step1.intentReady) return; if (!step1.intentThreadId || step1.intentReady) return;
const title = prefill.taskTitle?.trim(); const title = prefill.taskTitle?.trim();
const goal = prefill.taskGoal?.trim(); const goal = prefill.taskGoal?.trim();
const text = [title ? `任务名称:${title}` : "", goal ? `任务描述:${goal}` : ""] // xdfx(行动分析):除任务名称/描述外,再带上「行动名称」一起交给任务理解智能体。
const actionName = prefill.businessCode === "7BF" ? prefill.actionName?.trim() : "";
const text = [
title ? `任务名称:${title}` : "",
goal ? `任务描述:${goal}` : "",
actionName ? `行动名称:${actionName}` : "",
]
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
if (!text) return; if (!text) return;
@@ -1460,6 +1497,15 @@ export default function RoundtablePlanningPage() {
triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求"); triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
}, [resetToStep1, triggerToast]); }, [resetToStep1, triggerToast]);
// 深链接(taskId)模式专用的「重新分析」:清空第一步会话、保留当前任务(prefill 不动),并复位
// autoStarted 闸门,让 C2 自动把「任务名称/描述」重新作为首条消息发出,对同一任务重做意图分析。
// 与「新建任务」(全新空白任务) 区分——深链接下任务是固定的,重来=重新分析这条任务。
const handleReanalyze = useCallback(() => {
autoStartedRef.current = false;
resetToStep1();
triggerToast("🔄 已重新分析当前任务");
}, [resetToStep1, triggerToast]);
// 无界抽屉保活复用:父页面重开抽屉时 `bus.$emit('roundtable:reset')` → 子应用回到第一步。 // 无界抽屉保活复用:父页面重开抽屉时 `bus.$emit('roundtable:reset')` → 子应用回到第一步。
// 非无界环境下 onWujieEvent 返回 no-op,安全无副作用。 // 非无界环境下 onWujieEvent 返回 no-op,安全无副作用。
useEffect(() => { useEffect(() => {
@@ -1518,6 +1564,10 @@ export default function RoundtablePlanningPage() {
agents, agents,
model: selectedModel || undefined, // 模型沿用第二步选择 model: selectedModel || undefined, // 模型沿用第二步选择
seatMode, // 席位执行模式沿用第二步选择(后台续跑同样按此模式跑席位) seatMode, // 席位执行模式沿用第二步选择(后台续跑同样按此模式跑席位)
seatSkillDirectives, // 每席位技能识别强化沿用所选业务链条里各席位的开关
seatModes, // 每席位推理深度覆盖沿用所选业务链条里各席位的配置
gatherFirst, // 研讨前并行取数沿用所选业务链条的开关
businessCode: prefillRef.current?.businessCode ?? undefined, // 深链接业务码 → 后台总结业务链完整性核对
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)—— // 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。 // 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode, orchestrationMode,
@@ -1534,6 +1584,9 @@ export default function RoundtablePlanningPage() {
drafts, drafts,
selectedModel, selectedModel,
seatMode, seatMode,
seatSkillDirectives,
seatModes,
gatherFirst,
orchestrationMode, orchestrationMode,
orchestrationPlan, orchestrationPlan,
chainMeta, chainMeta,
@@ -1557,6 +1610,9 @@ export default function RoundtablePlanningPage() {
const intent = buildInitialIntent(); const intent = buildInitialIntent();
const model = selectedModel || undefined; const model = selectedModel || undefined;
const seatModeSnap = seatMode; const seatModeSnap = seatMode;
const seatSkillDirectivesSnap = seatSkillDirectives;
const seatModesSnap = seatModes;
const gatherFirstSnap = gatherFirst;
const mode = orchestrationMode; const mode = orchestrationMode;
const chainSnap = chainMeta; const chainSnap = chainMeta;
const planSnap = mode === "dag" ? orchestrationPlan : null; const planSnap = mode === "dag" ? orchestrationPlan : null;
@@ -1591,6 +1647,10 @@ export default function RoundtablePlanningPage() {
agents, agents,
model, model,
seatMode: seatModeSnap, seatMode: seatModeSnap,
seatSkillDirectives: seatSkillDirectivesSnap,
seatModes: seatModesSnap,
gatherFirst: gatherFirstSnap,
businessCode: prefillRef.current?.businessCode ?? undefined,
orchestrationMode: mode, orchestrationMode: mode,
chain: chainSnap, chain: chainSnap,
orchestrationPlan: planSnap, orchestrationPlan: planSnap,
@@ -1614,6 +1674,9 @@ export default function RoundtablePlanningPage() {
buildInitialIntent, buildInitialIntent,
selectedModel, selectedModel,
seatMode, seatMode,
seatSkillDirectives,
seatModes,
gatherFirst,
orchestrationMode, orchestrationMode,
orchestrationPlan, orchestrationPlan,
chainMeta, chainMeta,
@@ -1654,6 +1717,10 @@ export default function RoundtablePlanningPage() {
agents, agents,
model: selectedModel || undefined, model: selectedModel || undefined,
seatMode, seatMode,
seatSkillDirectives,
seatModes,
gatherFirst,
businessCode: prefillRef.current?.businessCode ?? undefined,
orchestrationMode, orchestrationMode,
chain: chainMeta, chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null, orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
@@ -1811,7 +1878,8 @@ export default function RoundtablePlanningPage() {
</button> </button>
)} )}
{!inWujieEmbed && ( {/* 深链接(taskId)模式:业务链由 businessCode 自动决定,隐藏手动「业务链条」入口。 */}
{!inWujieEmbed && !prefillRef.current?.taskId && (
<button <button
id="business-chain-btn" id="business-chain-btn"
onClick={() => setIsChainListOpen(true)} onClick={() => setIsChainListOpen(true)}
@@ -1823,15 +1891,28 @@ export default function RoundtablePlanningPage() {
</button> </button>
)} )}
<button {/* 深链接(taskId)模式:任务固定,把「新建任务」换成「重新分析」——清空会话、对同一任务重做意图分析。 */}
id="new-task-btn" {prefillRef.current?.taskId ? (
onClick={handleStartNewTask} <button
title="清空当前会话,从第一步开始一个全新任务" id="reanalyze-task-btn"
className="bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-emerald-500/10 active:scale-95" onClick={handleReanalyze}
> title="清空当前会话,对当前任务重新做意图分析"
<Sparkles className="w-3.5 h-3.5" /> className="bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-emerald-500/10 active:scale-95"
新建任务 >
</button> <RotateCw className="w-3.5 h-3.5" />
重新分析
</button>
) : (
<button
id="new-task-btn"
onClick={handleStartNewTask}
title="清空当前会话,从第一步开始一个全新任务"
className="bg-emerald-600 text-white hover:bg-emerald-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-emerald-500/10 active:scale-95"
>
<Sparkles className="w-3.5 h-3.5" />
新建任务
</button>
)}
<button <button
id="save-draft-header-btn" id="save-draft-header-btn"
@@ -1923,6 +2004,7 @@ export default function RoundtablePlanningPage() {
onOpenDraft={drafts.handleLoadDraft} onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId} activeDraftId={drafts.currentDraftId}
entryRailTaskId={prefillRef.current?.taskId ?? null} entryRailTaskId={prefillRef.current?.taskId ?? null}
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
/> />
{/* MIDDLE VIEWPORT */} {/* MIDDLE VIEWPORT */}
@@ -1933,7 +2015,10 @@ export default function RoundtablePlanningPage() {
{currentStep === 1 && ( {currentStep === 1 && (
<Step1Panel <Step1Panel
headerLeftSlot={ headerLeftSlot={
prefillRef.current?.taskId && purposeLevels.length > 0 ? ( // 「原任务已有结果」(目的树预览)只在 rwfx-A(6BF)模式显示;xdfx/3qfx 不显示。
prefillRef.current?.taskId &&
prefillRef.current?.businessCode === "6BF" &&
purposeLevels.length > 0 ? (
<PurposeFlowPreview <PurposeFlowPreview
levels={purposeLevels} levels={purposeLevels}
onOpen={() => setIsTaskPurposeOpen(true)} onOpen={() => setIsTaskPurposeOpen(true)}
@@ -2452,6 +2537,8 @@ interface LeftSidebarProps {
activeDraftId: string | null; activeDraftId: string | null;
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */ /** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
entryRailTaskId?: string | null; entryRailTaskId?: string | null;
/** 深链业务码:左上角卡片据此决定显示「当前任务」(rwfx/3qfx) 还是「当前行动」(xdfx)。 */
entryRailBusinessCode?: string | null;
} }
function Step2SandboxSidebarSync({ function Step2SandboxSidebarSync({
@@ -2498,6 +2585,7 @@ function LeftSidebar({
onOpenDraft, onOpenDraft,
activeDraftId, activeDraftId,
entryRailTaskId, entryRailTaskId,
entryRailBusinessCode,
}: LeftSidebarProps) { }: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、 // 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。 // 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2635,6 +2723,7 @@ function LeftSidebar({
onOpenDraft={onOpenDraft} onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId} activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null} taskId={entryRailTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
/> />
)} )}
</section> </section>
@@ -2681,6 +2770,7 @@ function RoundtableEntryRail({
onOpenDraft, onOpenDraft,
activeDraftId, activeDraftId,
taskId, taskId,
businessCode,
}: { }: {
selectedChainId: string | null; selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void; onSelectChain: (chain: BusinessChain) => void;
@@ -2688,12 +2778,25 @@ function RoundtableEntryRail({
activeDraftId: string | null; activeDraftId: string | null;
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */ /** 深链 taskId 模式:分析记录只列该任务的草稿。 */
taskId?: string | null; taskId?: string | null;
/** 深链业务码:xdfx(7BF) 左上角显示「当前行动」,其余显示「当前任务」。 */
businessCode?: string | null;
}) { }) {
const [chains, setChains] = useState<BusinessChain[]>([]); const [chains, setChains] = useState<BusinessChain[]>([]);
const [expandedChainId, setExpandedChainId] = useState<string | null>(null); const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
const { drafts: records, refresh } = useRoundtableDrafts(taskId); const { drafts: records, refresh } = useRoundtableDrafts(taskId);
// 深链接(taskId)模式:左上角不显示「业务链选择」(链条已由 businessCode 自动决定),改为展示
// 当前任务(rwfx/3qfx)或当前行动(xdfx)。xdfx 的根是「行动」,故展示行动名称 + 叙事。
const deeplink = Boolean(taskId);
const isXdfx = businessCode === "7BF";
const taskDetail = deeplink && !isXdfx ? readCopTaskDetail() : null;
const actionDetail = deeplink && isXdfx ? readActionDetail() : null;
useEffect(() => { useEffect(() => {
// 深链接模式不展示业务链选择 → 不必拉公共链条。
if (deeplink) {
void refresh();
return;
}
let cancelled = false; let cancelled = false;
void listChains("public") void listChains("public")
.then((all) => { .then((all) => {
@@ -2706,7 +2809,7 @@ function RoundtableEntryRail({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [refresh]); }, [refresh, deeplink]);
const formatTime = (value: string) => { const formatTime = (value: string) => {
const date = new Date(value); const date = new Date(value);
@@ -2722,7 +2825,49 @@ function RoundtableEntryRail({
return ( return (
<> <>
{/* 业务链(公共,前 3 个) */} {/* 深链接(taskId)模式:左上角展示「当前任务」(rwfx/3qfx)或「当前行动」(xdfx),替代业务链选择。 */}
{deeplink ? (
isXdfx ? (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Target className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前行动</h2>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">行动名称</p>
<p className="mt-0.5 text-xs font-semibold leading-relaxed text-slate-800">
{actionDetail?.actionName?.trim() || "(未获取到行动名称)"}
</p>
</div>
</div>
) : (
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)]">
<div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
<Clipboard className="w-3 h-3" strokeWidth={2.5} />
</span>
<h2 className="text-sm font-semibold tracking-wide text-slate-900">当前任务</h2>
</div>
<div className="space-y-2">
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">任务名称</p>
<p className="mt-0.5 text-xs font-semibold leading-relaxed text-slate-800">
{taskDetail?.taskName?.trim() || "(未获取到任务名称)"}
</p>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">任务描述</p>
<p className="mt-0.5 whitespace-pre-wrap text-[11px] leading-relaxed text-slate-600">
{taskDetail?.taskContent?.trim() || "(未获取到任务描述)"}
</p>
</div>
</div>
</div>
)
) : (
/* 业务链(公共,前 3 个) */
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] transition-shadow duration-300 hover:shadow-[0_4px_24px_rgba(45,37,34,0.04)]"> <div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] transition-shadow duration-300 hover:shadow-[0_4px_24px_rgba(45,37,34,0.04)]">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground"> <span className="flex items-center justify-center w-5 h-5 rounded-md bg-primary text-primary-foreground">
@@ -2903,6 +3048,7 @@ function RoundtableEntryRail({
</div> </div>
)} )}
</div> </div>
)}
{/* 分析记录(圆桌历史草稿) */} {/* 分析记录(圆桌历史草稿) */}
<div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] flex-1 flex flex-col min-h-[200px] overflow-hidden"> <div className="bg-white border border-[#ECE7E4] rounded-2xl p-4 shadow-[0_4px_20px_rgba(0,0,0,0.02)] flex-1 flex flex-col min-h-[200px] overflow-hidden">
@@ -556,3 +556,38 @@
.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb { .roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--primary) 35%, var(--border)); background: color-mix(in oklch, var(--primary) 35%, var(--border));
} }
/* =============================================================================
* 会商消息列表滚动条易用性修复(Step1/Step2/Step3 都受益)。
*
* 症状:鼠标移到右侧纵向滚动条滑块上特别容易触发「左右拖动」,导致点不到滑块。
*
* 根因 1(Step2/Step3 有沙箱/流程图分栏时):分栏拖拽手柄
* `#roundtable-step2-separator`(react-resizable-panels Separator)默认命中区是
* `::after { left:50%; width:16px; transform:translateX(-50%) }`——居中跨在分隔线
* 上、向左探出 8px,正好压住左侧消息列表那条贴右边的纵向滚动条。于是抓滚动条时
* 实际抓到了 `cursor-col-resize` 的手柄命中区 → 误触发左右拖动、点不到滑块。
* 修法:把命中区整体挪到分隔线**右侧(沙箱一侧)**,左侧滚动条彻底让出。
* ID 选择器特异性(1,0,1) 高于 Tailwind 的 `after:*` 工具类(0,1,1),无视加载顺序生效。
*
* 根因 2(所有步骤通用):默认滚动条只有 6px,太细难抓。仅在 .roundtable-planning
* 作用域内加宽到 12px,并用「透明边框 + background-clip:padding-box」把可见滑块收
* 成带内边距的圆条(更像可抓的条,而非贴边细线),命中区仍是整 12px。
* ========================================================================== */
#roundtable-step2-separator::after {
left: 0;
right: auto;
width: 14px;
transform: none;
}
.roundtable-planning ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.roundtable-planning ::-webkit-scrollbar-thumb {
border: 3px solid transparent;
background-clip: padding-box;
border-radius: 9999px;
}
+14 -1
View File
@@ -247,7 +247,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. | | **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. | | **Roundtable Drafts** (`/api/roundtable-drafts`) | **Strictly per-user** persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete (all owner-scoped). A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). taskId 深链会商记录 are **not** here — they live in the separate `/api/roundtable-task-drafts` store (see below) so personal history never mixes with task records. Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_task_drafts.py`. |
| **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. | | **Roundtable Task Drafts** (`/api/roundtable-task-drafts`) | **Task-scoped, unpartitioned** store for taskId-deeplink roundtable 聊天记录 (无界嵌入抽屉). Keyed **only by `task_id`** (one task → many records), **never partitioned by user** — any authenticated caller may read/write/delete a task's records (`created_by` is audit-only). Physically separate from `roundtable_drafts` so the two never mix. `GET /by-task/{task_id}` - all drafts for a task, newest-first (history dropdown), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`; `GET /by-task/{task_id}/latest` - newest draft (回显最新一条); `POST /` - create (`task_id` required); `GET/PUT/DELETE /{id}` - full read / partial update / delete (all user-agnostic). Backed by `deerflow.persistence.roundtable_task_drafts` (table `roundtable_task_drafts`, auto-created by `create_all`; migration `20260618_01`); store wired in `deps.py` as `app.state.roundtable_task_draft_store`. Existing `task_id`-bearing `roundtable_drafts` rows are migrated over by `scripts/migrate_roundtable_task_drafts.py`. Tests: `tests/test_roundtable_task_drafts.py`, `tests/test_roundtable_drafts_by_task.py`. |
| **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `<skill_system>` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") to the **seat's per-turn tLine truncated | **Roundtable Diagnostics** (`/api/roundtable-diagnostics`) | 会商全流程诊断日志 (报错 + 关键事件). One row per event in `roundtable_diagnostics`: `{scope (background = 后台挂起 / foreground = 前台服务端观测 / client = 前端上报), stage (step1_intent/step2_init/step2_leader/step2_seat/step2_recommend/step2_orchestration/step3_summary/step3_report/step3_dashboard/step3_structure/upstream/job), level (info/warning/error), event, message, detail (JSON), job_id/draft_id/task_id/user_id/agent_id/agent_name/cycle, created_at}`. **Reads are admin-only**: `GET /` - filter (scope/stage/level/event/job_id/draft_id/task_id/user_id/q/since/until; `scope` accepts a comma-separated set so 前端「前台调用」= `foreground,client`) + paginate (newest-first), each item enriched with `userName` (user_id → 邮箱 `@`-prefix via `get_local_provider().get_user`, best-effort batched cache); `GET /facets` - distinct values for filter dropdowns; `POST /admin/cleanup` - retention purge. **`POST /report` is NOT admin-gated** — any authenticated user's frontend posts the errors *the user actually hit* (HTTP 502 before any SSE frame / network drop / SSE error frame) so they're collected too; stored as `scope="client"`. Backed by `deerflow.persistence.roundtable_diagnostics` (table `roundtable_diagnostics`, auto-created by `create_all`; migration `20260619_01`); store wired in `deps.py` as `app.state.roundtable_diagnostic_store` + a process-level default store (`register_default_store`) so the harness-side engine/gateway can write across the app/harness boundary. **Writers** (all best-effort — diagnostics never break a run): background path uses `DiagnosticsRecorder` (bound to job ctx) threaded through `RoundtableJobExecutor` → `JobProgressWriter.diag(...)` (engine transitions/step3 failures/empty-delivery) + `InProcessRoundtableGateway` (per-turn timeout/report·summary·dashboard truncation+missing); foreground path uses `app/gateway/roundtable_diag.py::record_foreground_diag` in `multi_agent.py`/`intent.py`/`recommend.py` error frames. **Bug-fix shipped alongside**: `InProcessRoundtableGateway._run_agent_turn` guards each agent run with an **inactivity (idle) timeout** (`_await_run_with_idle_guard`, `ROUNDTABLE_SEAT_IDLE_TIMEOUT_SECONDS` default 1200s = 20min, tuned high for unstable intranet models) — **not** a total-duration cap, so a slow-but-alive run that keeps streaming tokens runs as long as it needs; only a run that emits **zero StreamBridge events for `idle_timeout` seconds straight** (a true freeze: 内网模型连接池挂死 / sqlite 锁在挂载卷上挂死) is cancelled + raises (顶层把 job 标 error,而非永久冻结) + records a `seat_turn_timeout` diagnostic. Activity is observed via `app.state.stream_bridge` (the worker publishes every chunk there). Optional absolute cap `ROUNDTABLE_SEAT_MAX_TOTAL_SECONDS` (default 0 = off). Frontend: admin page `pages/RoundtableDiagnosticsPage.tsx` (route `/page/strategy/admin/roundtable-diagnostics`), opened from the **会商页右上角设置按钮** (admin only — `handleSettingsClick` in `roundtable-planning/pages/RoundtablePlanningPage.tsx`); admin read client `strategy-components/api/roundtable-diagnostics.ts`. **Client error reporter** `roundtable-planning/api/diagnostics-report.ts` (`reportRoundtableError` fire-and-forget + `keepalive`, `setRoundtableErrorContext` for draft/task attribution set by the page, `stageForRoundtableAgent`) is called at every client-observable failure: `multi-agent.ts` (`streamMultiAgent` — covers Step2 leader/seat AND Step3 report/summary/dashboard/structure — reports HTTP error + SSE `{status:"error"}` frame + worker `event:error` frame [模型调用没成功] + fetch-reject [网络不可达] + mid-stream drop [连接重置/超时], deduped via a `reported` flag; `initMultiAgent` HTTP/error-frame/incomplete/mid-stream), `intent.ts` (Step1 HTTP+error-frame), `recommend.ts` (Step2 recommend HTTP+error-frame), `roundtable-jobs.ts` (job api/stream). The admin page's first filter is a **后台挂起 / 前台调用** toggle (frontend = `foreground,client`); the stage dropdown options adapt to it (background → step2+step3+job; frontend → step1+step2+step3) and each row shows the reporting user's name + 👤. Tests: `tests/test_roundtable_diagnostics.py`, `tests/test_roundtable_inprocess_timeout.py`. **Seat skill-recognition (弱模型补偿)**: a seat = one custom agent run whose configured skills (`agent config.yaml → skills`) are injected **only** via the system prompt's `<skill_system>` block (model must `read_file` the SKILL.md itself) — weak models (deepseek-chat 等) ignore it and never use their skills. `app/gateway/roundtable_seat_skills.py::append_seat_skill_directive(agent_id, task, *, chain_enabled=False)` appends an explicit Chinese directive (each configured-and-enabled skill's name + description + SKILL.md container path + "先 read_file 再按其流程取信息/执行") toLine truncated
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. | | **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. | | **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. | | **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
@@ -620,6 +620,19 @@ scheduler runs once per deployment. Per run:
- Config values starting with `$` resolved as environment variables - Config values starting with `$` resolved as environment variables
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`) - Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
### Roundtable Model Fault Tolerance (会商模型自动容错 / 换下一个模型)
会商第二步(leader/seat)+ 第三步(summary/report/dashboard + 结构化抽取)单轮执行时,**某模型出问题就自动切到下一个模型继续**,并告知用户「『X』模型异常 → 已切换『Y』」。前台实时(`multi_agent.py`)与后台挂起(`engine.py` + `InProcessRoundtableGateway`)两条路径都覆盖。
- **可检测的模型失败**:`LLMErrorHandlingMiddleware` 在同一模型重试 3 次后**不抛异常**,而是返回兜底 `AIMessage` —— 现在给它打 `additional_kwargs[LLM_ERROR_MARKER="deerflow_llm_error"]=reason`(model_dump 保留,经 checkpointer 序列化仍在)。`is_llm_error_message(msg)` 据标记(回退按已知兜底文案正文)识别,返回 reason。
- **候选模型链**:`app/gateway/roundtable_model_fallback.py::fallback_model_chain(current)` 按 `config.yaml models[]` 顺序给出 `[当前模型, …其余]`(去重保序)。`reason_text(reason)` 翻译成简短中文。总开关 env `ROUNDTABLE_MODEL_FALLBACK`(默认开,`0/false/off` 关 → 单模型旧行为);`ROUNDTABLE_MODEL_FALLBACK_MAX`(0=试完全部)。
- **触发换模型的三种情形**:① 单轮 run 抛异常(start_run 失败 / idle 卡死 → `timeout`,其余 `exception`);② 兜底错误文案(`is_llm_error_message`);③ 交付为空(仅 seat/leader;leader 还含「既无正文又无派活」)。Step3 单例(report/summary/dashboard/ingest/结构化)的产物是落盘文件,**不**把「正文为空」当失败,只对真模型错误换模型。
- **后台**:`InProcessRoundtableGateway._run_turn_with_fallback(...)` 包住 `_run_agent_turn`,逐候选模型试;换模型落 `model_switch` 诊断、全失败落 `model_fallback_exhausted`。`run_leader/run_seat/run_report/run_summary/run_dashboard_data` 全走它(report/summary/dashboard 的产物自愈重试保留,并记住已切到的可用模型用于后续续写轮)。各 Turn/Result 带回 `switches: list[ModelSwitch]`,引擎 `_emit_switches` → `JobProgressWriter.model_switch(...)` 往研讨时间线插一条 `role="system"` 系统提示气泡。
- **前台**:`_special_run`/`_leader_run` stream 收尾后检测失败 → yield `{status:"model_switch", role, agent_name, failed_model, next_model, reason}` 帧并用下一个模型重新流式;终态帧带 `model_used`。结构化抽取 `flow_extract.py` 的 round1 `model.astream` 也包了同款换模型循环(emit `event:"model_switch"` 帧)。
- **Step2 单席位隔离**:`engine._run_recommend/_run_chain/_run_dag` 的每个席位执行包 try/except —— 一个席位彻底失败(落 `seat_failed` 诊断 + 置空交付)不拖垮整批/整链/整个作业。
- **前端**:`multi-agent.ts` 解析 `model_switch` 帧 → `onModelSwitch` 回调(清空失败尝试的半截气泡 + 累计的 tool_call delta,phase 退回 connected);`useStep2Orchestration` 弹 toast + 时间线插「系统提示」气泡;`useStep3Summary/Report/Dashboard` + `flow-extract.ts` 重置对应气泡并在步骤卡留可见提示;后台路径经 `job-live.ts::toStep2Dialogue` 把 `role="system"` 渲染成 amber「系统提示」气泡。
- Tests: `tests/test_roundtable_model_fallback.py`。
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`) ### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints - `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints
@@ -339,6 +339,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
seat_thinking_enabled=p.seat_thinking_enabled, seat_thinking_enabled=p.seat_thinking_enabled,
seat_reasoning_effort=p.seat_reasoning_effort, seat_reasoning_effort=p.seat_reasoning_effort,
seat_subagent_enabled=p.seat_subagent_enabled, seat_subagent_enabled=p.seat_subagent_enabled,
seat_skill_directives=p.seat_skill_directives,
# 每席位推理深度覆盖(业务链条里各席位单独配):run_seat 命中即覆盖作业级 seatMode。
seat_modes=p.seat_modes,
# 诊断日志上下文:网关每轮 leader/seat/report/summary/dashboard 报错均带这些归属。 # 诊断日志上下文:网关每轮 leader/seat/report/summary/dashboard 报错均带这些归属。
job_id=p.job_id, job_id=p.job_id,
draft_id=p.draft_id, draft_id=p.draft_id,
@@ -18,12 +18,18 @@ import asyncio
import logging import logging
import os import os
import uuid import uuid
from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from langgraph.checkpoint.base import empty_checkpoint from langgraph.checkpoint.base import empty_checkpoint
from app.gateway.roundtable_model_fallback import (
fallback_model_chain,
is_llm_error_message,
reason_text,
)
from app.gateway.roundtable_run_policy import roundtable_run_policy from app.gateway.roundtable_run_policy import roundtable_run_policy
from app.gateway.roundtable_seat_skills import append_seat_skill_directive from app.gateway.roundtable_seat_skills import append_seat_skill_directive
from app.gateway.routers._roundtable_seed import ( from app.gateway.routers._roundtable_seed import (
@@ -36,6 +42,7 @@ from app.gateway.routers._roundtable_seed import (
from deerflow.agents.roundtable_orchestrator.report_json import extract_report_json from deerflow.agents.roundtable_orchestrator.report_json import extract_report_json
from deerflow.agents.roundtable_orchestrator.types import ( from deerflow.agents.roundtable_orchestrator.types import (
LeaderTurn, LeaderTurn,
ModelSwitch,
ReportResult, ReportResult,
SeatRef, SeatRef,
SeatTurn, SeatTurn,
@@ -509,6 +516,43 @@ def _build_seat_intro(agents: list[SeatRef]) -> str:
) )
# 席位执行模式 → (thinking_enabled, reasoning_effort, subagent_enabled) 派生参数。
# 与前端 `lib/seat-mode.ts::seatModeToRunParams` **逐字段对齐**(务必同步修改),供每席位
# 推理深度覆盖(self._seat_modes)在 run_seat 里把 SeatMode 解析成具体运行参数。未知/缺省档
# 落 None(→ 跟随作业级 seat_* / policy 默认)。
_SEAT_MODE_PARAMS: dict[str, tuple[bool | None, str | None, bool]] = {
"flash": (False, "minimal", False),
"thinking": (True, "low", False),
"pro": (True, "medium", False),
"ultra": (True, "high", True),
}
def _exception_reason(exc: BaseException) -> str:
"""把单轮 run 抛出的异常归类成模型容错原因码(用于换模型提示)。
idle 守护判定的卡死(``_await_run_with_idle_guard`` 抛 ``... stalled ...``)→ ``timeout``;
其余(start_run 失败 / DB 抖动 / 其它)→ ``exception``。
"""
text = str(exc).lower()
if "stall" in text or "idle" in text or "timeout" in text or "timed out" in text:
return "timeout"
return "exception"
def _seat_empty_reason(messages: list[dict[str, Any]]) -> str | None:
"""席位/取数轮校验:最后一条有正文的 AI 消息为空 → ``empty``(触发换模型)。"""
return "empty" if not _latest_ai_text(messages).strip() else None
def _leader_failed_reason(messages: list[dict[str, Any]]) -> str | None:
"""总控轮校验:既无正文、也无任何工具调用(派活/澄清)→ ``empty``(视为模型没干活)。"""
ai = _latest_ai_message(messages)
has_text = bool(_latest_ai_text(messages).strip())
has_tools = bool(_normalize_tool_calls((ai or {}).get("tool_calls")))
return None if (has_text or has_tools) else "empty"
# ── 网关实现 ───────────────────────────────────────────────────────────────── # ── 网关实现 ─────────────────────────────────────────────────────────────────
@@ -525,6 +569,8 @@ class InProcessRoundtableGateway:
seat_thinking_enabled: bool | None = None, seat_thinking_enabled: bool | None = None,
seat_reasoning_effort: str | None = None, seat_reasoning_effort: str | None = None,
seat_subagent_enabled: bool = False, seat_subagent_enabled: bool = False,
seat_skill_directives: dict[str, bool] | None = None,
seat_modes: dict[str, str] | None = None,
job_id: str | None = None, job_id: str | None = None,
draft_id: str | None = None, draft_id: str | None = None,
task_id: str | None = None, task_id: str | None = None,
@@ -547,6 +593,13 @@ class InProcessRoundtableGateway:
self._seat_thinking_enabled = seat_thinking_enabled self._seat_thinking_enabled = seat_thinking_enabled
self._seat_reasoning_effort = seat_reasoning_effort self._seat_reasoning_effort = seat_reasoning_effort
self._seat_subagent_enabled = seat_subagent_enabled self._seat_subagent_enabled = seat_subagent_enabled
# 每席位「技能识别强化」开关映射 {agent_id: bool}(沿用 Step2 所选业务链条里每个席位
# 的配置)。run_seat 按 agent_id 查表,与各席位 agent 自身的 config.yaml 开关取 OR,
# 传给 append_seat_skill_directive。默认空 dict(仅各 agent 自身开关生效)。
self._seat_skill_directives = dict(seat_skill_directives or {})
# 每席位「推理深度覆盖」映射 {agent_id: SeatMode}。run_seat 命中即用该档(_SEAT_MODE_PARAMS)
# 派生的 thinking/reasoning/subagent 覆盖作业级 seat_*;未命中则跟随作业级。默认空 dict。
self._seat_modes = dict(seat_modes or {})
self._seat_intro = "" self._seat_intro = ""
self._first_leader = True self._first_leader = True
@@ -564,25 +617,116 @@ class InProcessRoundtableGateway:
self._seat_intro = _build_seat_intro(self._agents) self._seat_intro = _build_seat_intro(self._agents)
return thread_ids, COORDINATOR_AGENT_ID return thread_ids, COORDINATOR_AGENT_ID
async def _run_turn_with_fallback(
self,
*,
thread_id: str,
agent_name: str,
message: str,
requested_model: str | None,
excluded_tools: list[str],
skill_stop_names: list[str],
thinking_enabled: bool,
reasoning_effort: str,
subagent_enabled: bool = False,
stage: str,
cycle: int | None = None,
validate: Callable[[list[dict[str, Any]]], str | None] | None = None,
) -> tuple[list[dict[str, Any]], list[ModelSwitch], str | None]:
"""跑一轮 agent,并在「模型出问题」时自动切到下一个模型重试。
触发换模型的三种情形(与用户选择一致):
① ``_run_agent_turn`` 抛异常(start_run 失败 / idle 卡死超时);
② 返回了中间件兜底错误文案(``is_llm_error_message`` 命中);
③ 调用方 ``validate`` 判定无效(如席位交付为空 / 总控既无正文又无派活)。
候选模型链按 ``config.yaml models[]`` 顺序(``fallback_model_chain``),首个为
``requested_model``(空则作业级 ``self._model``,再空则配置默认)。返回
``(最终 messages, 发生的换模型记录, 实际所用模型)``。全部模型都失败时返回最后一次的
messages(让既有的「空交付 / 产物缺失」逻辑兜底,绝不抛异常拖垮整个作业)。
"""
base = requested_model if (requested_model not in (None, "")) else self._model
chain = fallback_model_chain(base) or [base]
switches: list[ModelSwitch] = []
messages: list[dict[str, Any]] = []
prev_model: str | None = None
prev_reason: str | None = None
used_model: str | None = chain[0]
for idx, candidate in enumerate(chain):
if idx > 0:
label = reason_text(prev_reason)
switches.append(ModelSwitch(failed_model=prev_model or "", next_model=candidate or "", reason_label=label))
logger.warning(
"roundtable %s '%s' switching model %s -> %s (%s)",
stage, agent_name, prev_model, candidate, prev_reason,
)
await self._diag.record(
stage=stage, level="warning", event="model_switch",
message=f"「{agent_name}」模型「{prev_model}」{label},已自动切换到「{candidate}」重试",
detail={"failed_model": prev_model, "next_model": candidate, "reason": prev_reason, "attempt": idx + 1},
agent_name=agent_name, cycle=cycle,
)
used_model = candidate
try:
messages = await _run_agent_turn(
self._app,
user_id=self._user_id,
thread_id=thread_id,
agent_name=agent_name,
message=message,
model=candidate,
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
thinking_enabled=thinking_enabled,
reasoning_effort=reasoning_effort,
subagent_enabled=subagent_enabled,
diag=self._diag,
stage=stage,
cycle=cycle,
)
except Exception as exc: # noqa: BLE001 — 模型故障:归类后尝试下一个模型
prev_model, prev_reason, messages = candidate, _exception_reason(exc), []
if idx + 1 < len(chain):
continue
await self._diag.record(
stage=stage, level="error", event="model_fallback_exhausted",
message=f"「{agent_name}」所有备选模型均失败(最后 {candidate}:{prev_reason})",
detail={"reason": prev_reason, "tried": chain}, agent_name=agent_name, cycle=cycle,
)
return messages, switches, used_model
# 跑通了:再看是否「兜底错误文案」或 validate 判定无效。
reason = is_llm_error_message(_latest_ai_message(messages))
if reason is None and validate is not None:
reason = validate(messages)
if reason is None:
return messages, switches, used_model
prev_model, prev_reason = candidate, reason
if idx + 1 >= len(chain):
await self._diag.record(
stage=stage, level="error", event="model_fallback_exhausted",
message=f"「{agent_name}」所有备选模型均未产出有效结果(最后 {candidate}:{reason})",
detail={"reason": reason, "tried": chain}, agent_name=agent_name, cycle=cycle,
)
return messages, switches, used_model
return messages, switches, used_model
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn: async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
msg = message msg = message
if self._first_leader and self._seat_intro: if self._first_leader and self._seat_intro:
msg = self._seat_intro + "\n\n" + message msg = self._seat_intro + "\n\n" + message
self._first_leader = False self._first_leader = False
policy = roundtable_run_policy("leader") policy = roundtable_run_policy("leader")
messages = await _run_agent_turn( messages, switches, _used = await self._run_turn_with_fallback(
self._app,
user_id=self._user_id,
thread_id=thread_ids[coordinator_name], thread_id=thread_ids[coordinator_name],
agent_name=coordinator_name, agent_name=coordinator_name,
message=msg, message=msg,
model=model or self._model, requested_model=model,
excluded_tools=policy.excluded_tools, excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names, skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled, thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort, reasoning_effort=policy.reasoning_effort,
diag=self._diag,
stage="step2_leader", stage="step2_leader",
validate=_leader_failed_reason,
) )
ai = _latest_ai_message(messages) ai = _latest_ai_message(messages)
# tool_calls 取最后一条 AI 消息(派活就在这条);正文取最后一条有文字的 AI 消息。 # tool_calls 取最后一条 AI 消息(派活就在这条);正文取最后一条有文字的 AI 消息。
@@ -596,7 +740,7 @@ class InProcessRoundtableGateway:
if clar is not None: if clar is not None:
cargs = clar.get("args") or {} cargs = clar.get("args") or {}
question = str(cargs.get("question") or "").strip() or "总控需要补充信息,请补充后继续。" question = str(cargs.get("question") or "").strip() or "总控需要补充信息,请补充后继续。"
return LeaderTurn(final_text=final_text, clarification=question, tool_calls=turn_tool_calls) return LeaderTurn(final_text=final_text, clarification=question, tool_calls=turn_tool_calls, switches=switches)
# 派活解析(复用 multi_agent 纯函数) # 派活解析(复用 multi_agent 纯函数)
from app.gateway.routers.multi_agent import _extract_dispatch from app.gateway.routers.multi_agent import _extract_dispatch
@@ -609,39 +753,52 @@ class InProcessRoundtableGateway:
dispatched.append((name, task)) dispatched.append((name, task))
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.warning("dispatch parse failed for tool_call", exc_info=True) logger.warning("dispatch parse failed for tool_call", exc_info=True)
return LeaderTurn(final_text=final_text, dispatched=dispatched, tool_calls=turn_tool_calls) return LeaderTurn(final_text=final_text, dispatched=dispatched, tool_calls=turn_tool_calls, switches=switches)
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn: async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
policy = roundtable_run_policy("seat") policy = roundtable_run_policy("seat")
# 弱模型补偿:把该席位配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其 # 弱模型补偿:把该席位配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其
# 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见 # 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见
# roundtable_seat_skills 模块说明)。无配置技能时原样返回 task。 # roundtable_seat_skills 模块说明)。默认关:是否注入 = 该席位 agent 的 config.yaml
seat_task = append_seat_skill_directive(agent_id, task) # 开关 OR 该席位在所选业务链条里的开关(按 agent_id 查表)。无配置技能 / 两开关都关时
# 席位模式覆盖 policy 的 thinking/reasoning(None → policy 默认),与前端 _special_run 一致。 # 原样返回 task。
messages = await _run_agent_turn( seat_task = append_seat_skill_directive(
self._app, agent_id, task, chain_enabled=bool(self._seat_skill_directives.get(agent_id, False))
user_id=self._user_id, )
# 每席位「推理深度覆盖」:业务链条里给该席位单独配了 seatMode → 用该档派生的
# thinking/reasoning/subagent **覆盖作业级**(self._seat_*);未配则跟随作业级。
# 作业级仍为 None 时落 policy 默认。与前端 useStep2Orchestration「覆盖优先、否则跟随
# selectedMode」逐席位语义一致。
seat_thinking, seat_reasoning, seat_subagent = (
self._seat_thinking_enabled,
self._seat_reasoning_effort,
self._seat_subagent_enabled,
)
override = _SEAT_MODE_PARAMS.get(self._seat_modes.get(agent_id, ""))
if override is not None:
seat_thinking, seat_reasoning, seat_subagent = override
messages, switches, _used = await self._run_turn_with_fallback(
thread_id=thread_ids[agent_id], thread_id=thread_ids[agent_id],
agent_name=agent_id, agent_name=agent_id,
message=seat_task, message=seat_task,
model=model or self._model, requested_model=model,
excluded_tools=policy.excluded_tools, excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names, skill_stop_names=policy.skill_stop_names,
thinking_enabled=( thinking_enabled=(
policy.thinking_enabled policy.thinking_enabled if seat_thinking is None else seat_thinking
if self._seat_thinking_enabled is None
else self._seat_thinking_enabled
), ),
reasoning_effort=( reasoning_effort=(
policy.reasoning_effort policy.reasoning_effort if seat_reasoning is None else seat_reasoning
if self._seat_reasoning_effort is None
else self._seat_reasoning_effort
), ),
subagent_enabled=self._seat_subagent_enabled, subagent_enabled=seat_subagent,
diag=self._diag,
stage="step2_seat", stage="step2_seat",
validate=_seat_empty_reason,
)
return SeatTurn(
final_text=_latest_ai_text(messages),
tool_calls=_tool_calls_since_last_human(messages),
switches=switches,
) )
return SeatTurn(final_text=_latest_ai_text(messages), tool_calls=_tool_calls_since_last_human(messages))
async def create_report_thread(self) -> str: async def create_report_thread(self) -> str:
ensure_roundtable_functional_agents() ensure_roundtable_functional_agents()
@@ -654,21 +811,22 @@ class InProcessRoundtableGateway:
summary = "" summary = ""
html: str | None = None html: str | None = None
message = prompt message = prompt
switches: list[ModelSwitch] = []
current_model = model # 跨产物自愈轮记住实际所用模型:一旦换到可用模型,后续续写轮就从它起。
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT): for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn( messages, sw, used = await self._run_turn_with_fallback(
self._app,
user_id=self._user_id,
thread_id=report_thread_id, thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID, agent_name=REPORT_AGENT_ID,
message=message, message=message,
model=model or self._model, requested_model=current_model,
excluded_tools=policy.excluded_tools, excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names, skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled, thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort, reasoning_effort=policy.reasoning_effort,
diag=self._diag,
stage="step3_report", stage="step3_report",
) )
switches.extend(sw)
current_model = used or current_model
summary = _latest_ai_text(messages) or summary summary = _latest_ai_text(messages) or summary
html = _read_report_html(self._app, report_thread_id, self._user_id) html = _read_report_html(self._app, report_thread_id, self._user_id)
if _html_complete(html): if _html_complete(html):
@@ -695,7 +853,7 @@ class InProcessRoundtableGateway:
) )
# 截断的 HTML 也比纯文字强(浏览器容忍未闭合标签),仅在完全没有文件时才回退正文。 # 截断的 HTML 也比纯文字强(浏览器容忍未闭合标签),仅在完全没有文件时才回退正文。
final_html = html if (html and html.strip()) else summary final_html = html if (html and html.strip()) else summary
return ReportResult(html=final_html, summary=summary, model=model or self._model) return ReportResult(html=final_html, summary=summary, model=current_model or self._model, switches=switches)
async def create_summary_thread(self) -> str: async def create_summary_thread(self) -> str:
ensure_roundtable_functional_agents() ensure_roundtable_functional_agents()
@@ -711,21 +869,22 @@ class InProcessRoundtableGateway:
closing = "" closing = ""
md: str | None = None md: str | None = None
message = prompt message = prompt
switches: list[ModelSwitch] = []
current_model = model
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT): for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn( messages, sw, used = await self._run_turn_with_fallback(
self._app,
user_id=self._user_id,
thread_id=summary_thread_id, thread_id=summary_thread_id,
agent_name=SUMMARY_AGENT_ID, agent_name=SUMMARY_AGENT_ID,
message=message, message=message,
model=model or self._model, requested_model=current_model,
excluded_tools=policy.excluded_tools, excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names, skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled, thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort, reasoning_effort=policy.reasoning_effort,
diag=self._diag,
stage="step3_summary", stage="step3_summary",
) )
switches.extend(sw)
current_model = used or current_model
closing = _latest_ai_text(messages) or closing closing = _latest_ai_text(messages) or closing
md = _read_summary_md(self._app, summary_thread_id, self._user_id) md = _read_summary_md(self._app, summary_thread_id, self._user_id)
if md and md.strip(): if md and md.strip():
@@ -751,7 +910,7 @@ class InProcessRoundtableGateway:
agent_name=SUMMARY_AGENT_ID, agent_name=SUMMARY_AGENT_ID,
) )
final_md = md if (md and md.strip()) else closing final_md = md if (md and md.strip()) else closing
return SummaryResult(md=final_md, closing=closing, model=model or self._model, thread_id=summary_thread_id) return SummaryResult(md=final_md, closing=closing, model=current_model or self._model, thread_id=summary_thread_id, switches=switches)
async def run_dashboard_data(self, *, prompt, model) -> str | None: async def run_dashboard_data(self, *, prompt, model) -> str | None:
"""大屏多页的结构化数据:进程内驱动**专职**「大屏多页数据智能体」 """大屏多页的结构化数据:进程内驱动**专职**「大屏多页数据智能体」
@@ -767,21 +926,20 @@ class InProcessRoundtableGateway:
thread_id = await _create_thread(self._app) thread_id = await _create_thread(self._app)
policy = roundtable_run_policy("dashboard") policy = roundtable_run_policy("dashboard")
message = prompt message = prompt
current_model = model
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT): for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn( messages, _sw, used = await self._run_turn_with_fallback(
self._app,
user_id=self._user_id,
thread_id=thread_id, thread_id=thread_id,
agent_name=DASHBOARD_AGENT_ID, agent_name=DASHBOARD_AGENT_ID,
message=message, message=message,
model=model or self._model, requested_model=current_model,
excluded_tools=policy.excluded_tools, excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names, skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled, thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort, reasoning_effort=policy.reasoning_effort,
diag=self._diag,
stage="step3_dashboard", stage="step3_dashboard",
) )
current_model = used or current_model
body = _extract_report_json(_latest_ai_text(messages)) body = _extract_report_json(_latest_ai_text(messages))
if body: if body:
return body return body
@@ -17,7 +17,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -189,6 +189,20 @@ class StartParams:
seat_thinking_enabled: bool | None = None seat_thinking_enabled: bool | None = None
seat_reasoning_effort: str | None = None seat_reasoning_effort: str | None = None
seat_subagent_enabled: bool = False seat_subagent_enabled: bool = False
# 每席位「技能识别强化」开关映射 {agent_id: bool}(沿用 Step2 所选业务链条里每个席位的
# 配置)。透传给进程内网关,run_seat 按 agent_id 查表,与各席位 agent 自身 config.yaml 的
# 同名开关取 OR。默认空 dict。
seat_skill_directives: dict[str, bool] = field(default_factory=dict)
# 每席位「推理深度覆盖」映射 {agent_id: SeatMode}(值 flash/thinking/pro/ultra,沿用 Step2 所选
# 业务链条里每个席位单独配的推理深度)。透传给进程内网关,run_seat 按 agent_id 查表:命中 → 用该
# 档派生的 thinking/reasoning/subagent 覆盖作业级 seatMode;未命中 → 跟随作业级。默认空 dict。
seat_modes: dict[str, str] = field(default_factory=dict)
# 是否在研讨前先跑一轮**全席位并行『取数』**(业务链条逐条 opt-in):各席位并行只调技能取数、
# 不研讨,产出作为共享资料注入后续研讨轮。透传给 run_orchestration 的 gather_first。默认 False。
gather_first: bool = False
# 深链接业务码(rwfx→6BF 等)。透传给 run_orchestration → build_summary_prompt:写实业务额外注入
# 「业务链完整性核对」,让后台总结报告核对产出相对业务链完不完整。普通会商为空 → 不注入。
business_code: str | None = None
# 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。 # 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
@@ -276,6 +290,8 @@ class RoundtableJobExecutor:
intent_text=params.intent_text, intent_text=params.intent_text,
enable_report=params.enable_report, enable_report=params.enable_report,
plan=params.orchestration_plan, plan=params.orchestration_plan,
gather_first=params.gather_first,
business_code=params.business_code,
) )
# 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。 # 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。
await self._write_run_to_draft( await self._write_run_to_draft(
@@ -0,0 +1,104 @@
"""圆桌会商「模型自动容错(换下一个模型)」共享助手。
会商的第二步(leader / seat)与第三步(summary / report / dashboard / structure)单轮执行时,
若所用模型出现问题(抛异常 / idle 卡死 / 中间件兜底错误文案 / 交付为空),应自动切到
**下一个可用模型**继续,并把「『X』模型异常 → 已切换『Y』模型」告知用户。
本模块只放**与执行路径无关**的纯逻辑,供前台(``multi_agent.py``)与后台
(``roundtable_inprocess_gateway.py``)两条路径共用:
- ``model_fallback_enabled()``:总开关(env ``ROUNDTABLE_MODEL_FALLBACK``,默认开);
- ``fallback_model_chain(current)``:按 ``config.yaml`` ``models[]`` 顺序给出候选模型链
``[当前模型, …其余]``(去重保序);
- ``reason_text(reason)``:把失败原因码翻译成简短中文(用于用户提示);
- 复用 harness 的 ``is_llm_error_message`` 识别中间件兜底文案。
放在 app 层(依赖 ``deerflow.config`` + harness 的中间件常量,皆为 app→harness 合法方向)。
"""
from __future__ import annotations
import logging
import os
from deerflow.agents.middlewares.llm_error_handling_middleware import (
is_llm_error_message as is_llm_error_message, # re-export for callers
)
from deerflow.config import get_app_config
logger = logging.getLogger(__name__)
# 失败原因码 → 用户可见简短中文(用于「『X』模型<原因>,已切换…」提示)。
_REASON_TEXT: dict[str, str] = {
"quota": "额度不足/账单异常",
"auth": "鉴权失败/无访问权限",
"busy": "服务繁忙",
"transient": "网络/连接异常",
"upstream": "上游响应异常",
"circuit": "连续失败被熔断",
"generic": "调用失败",
"exception": "调用异常",
"timeout": "长时间无响应(卡死)",
"empty": "未产出有效内容",
}
def reason_text(reason: str | None) -> str:
"""把失败原因码翻译成简短中文(未知码 → 「调用失败」)。"""
if not reason:
return "调用失败"
return _REASON_TEXT.get(reason, "调用失败")
def model_fallback_enabled() -> bool:
"""模型自动容错总开关(默认开;设 ``ROUNDTABLE_MODEL_FALLBACK=0/false/off`` 关闭)。
关闭时 ``fallback_model_chain`` 只返回单个候选(原模型),即恢复旧行为:模型失败就失败,
不再尝试其它模型。
"""
raw = (os.getenv("ROUNDTABLE_MODEL_FALLBACK", "1") or "1").strip().lower()
return raw not in {"0", "false", "no", "off"}
def _max_attempts() -> int:
"""最多尝试的模型个数(含首个)。``ROUNDTABLE_MODEL_FALLBACK_MAX``,0/未设 = 不限(试完全部)。"""
try:
return max(0, int(os.getenv("ROUNDTABLE_MODEL_FALLBACK_MAX", "0") or "0"))
except (TypeError, ValueError):
return 0
def _config_model_names() -> list[str]:
try:
cfg = get_app_config()
return [m.name for m in (cfg.models or []) if getattr(m, "name", None)]
except Exception: # noqa: BLE001 — 拿不到配置(内存后端/测试桩)时退化为空
logger.debug("fallback_model_chain: get_app_config().models unavailable", exc_info=True)
return []
def fallback_model_chain(current: str | None) -> list[str]:
"""按 ``config.yaml`` ``models[]`` 顺序构造候选模型链:``[当前模型, …其余]``,去重保序。
- ``current`` 为空/None → 从 ``models[0]`` 起(即后端默认模型),后接其余;
- ``current`` 在配置内 → 它排第一,其余按配置顺序补齐;
- ``current`` 不在配置内(自定义/未知名)→ 仍排第一,再补齐全部配置模型;
- 总开关关闭 → 只返回单个候选(``current`` 或默认模型),等同旧行为;
- 拿不到任何配置模型 → 返回 ``[current]``(current 为空则空列表,调用方自行退化为不换模型)。
"""
all_names = _config_model_names()
cur = (current or "").strip()
ordered: list[str] = []
if cur:
ordered.append(cur)
for n in all_names:
if n and n not in ordered:
ordered.append(n)
if not ordered:
return [cur] if cur else []
if not model_fallback_enabled():
return ordered[:1]
cap = _max_attempts()
if cap > 0:
ordered = ordered[:cap]
return ordered
@@ -11,12 +11,20 @@
强得多——这与本仓已有的弱模型补偿手法(``_SEAT_NO_ASK``、总控派活强化、AI 写作 SKILL.md 强得多——这与本仓已有的弱模型补偿手法(``_SEAT_NO_ASK``、总控派活强化、AI 写作 SKILL.md
注入,见 ``project-ai-writing-skill-agent``)一脉相承。 注入,见 ``project-ai-writing-skill-agent``)一脉相承。
**默认关闭、按需开启(2026-06-20 改造)**:不再全域生效。是否对某席位注入由两个开关
取 **OR** 决定——
1. **智能体级**:该席位 agent 的 ``config.yaml → seat_skill_directive``(智能体编辑页
可开关);
2. **业务链条级**:本次会商所选业务链条的同名开关,由调用方读出后以 ``chain_enabled``
传入(前端手动与后台挂起两条路径都按席位透传)。
任一为真即注入;两者都假 → 返回空串、原样使用任务文本(零副作用)。
两条席位执行路径共用本 helper: 两条席位执行路径共用本 helper:
- 前端手动:``multi_agent._special_run``(seat / seat_parallel 角色); - 前端手动:``multi_agent._special_run``(seat / seat_parallel 角色);
- 后台挂起:``roundtable_inprocess_gateway.InProcessRoundtableGateway.run_seat``。 - 后台挂起:``roundtable_inprocess_gateway.InProcessRoundtableGateway.run_seat``。
无配置技能的席位 → 返回空串(完全无副作用)。可用 ``ROUNDTABLE_SEAT_SKILL_DIRECTIVE=0`` ``ROUNDTABLE_SEAT_SKILL_DIRECTIVE`` 仅作**总开关(kill-switch)**:默认放行,显式设为
全局关闭(默认开)。 0/false 时彻底关闭本功能(不论两个开关如何),用于线上一键熔断。它**不再** force-on。
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,25 +35,36 @@ import os
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"}) _TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
_FALSY = frozenset({"0", "false", "no", "off", "n"})
def _directive_enabled() -> bool: def _master_enabled() -> bool:
"""读 ``ROUNDTABLE_SEAT_SKILL_DIRECTIVE``(默认开)。空 / 未设 = 开。""" """``ROUNDTABLE_SEAT_SKILL_DIRECTIVE`` 总开关:默认放行(kill-switch)。
return (os.getenv("ROUNDTABLE_SEAT_SKILL_DIRECTIVE") or "1").strip().lower() in _TRUTHY
空 / 未设 / 非法 = 放行;仅显式 0/false/no/off/n 时整体关闭。注意它**只熔断不强开**,
具体是否注入仍由 agent / chain 两个开关取 OR 决定。
"""
return (os.getenv("ROUNDTABLE_SEAT_SKILL_DIRECTIVE") or "").strip().lower() not in _FALSY
def build_seat_skill_directive(agent_id: str | None) -> str: def build_seat_skill_directive(agent_id: str | None, *, chain_enabled: bool = False) -> str:
"""为某席位 agent 构建「必须先读 SKILL.md 再用技能」的本轮任务追加指令。 """为某席位 agent 构建「必须先读 SKILL.md 再用技能」的本轮任务追加指令。
返回 ``""`` 表示无需追加(开关关闭 / 无 agent_id / 该席位没有配置技能 / 解析失败), 是否注入由 **agent 开关(config.yaml)OR chain 开关(``chain_enabled``)** 决定,
调用方原样使用任务文本,零副作用。 默认两者皆关 → 不注入。返回 ``""`` 表示无需追加(总开关熔断 / 两开关都关 / 无 agent_id /
该席位没有配置技能 / 解析失败),调用方原样使用任务文本,零副作用。
""" """
if not _directive_enabled() or not agent_id: if not _master_enabled() or not agent_id:
return "" return ""
try: try:
from deerflow.config.agents_config import load_agent_config from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(agent_id) cfg = load_agent_config(agent_id)
# agent 级开关 OR chain 级开关(默认关)。任一打开才继续,否则零副作用返回。
agent_enabled = bool(cfg and cfg.seat_skill_directive)
if not (agent_enabled or chain_enabled):
return ""
configured = [name for name in (cfg.skills or []) if name] if cfg else [] configured = [name for name in (cfg.skills or []) if name] if cfg else []
if not configured: if not configured:
return "" return ""
@@ -87,9 +106,12 @@ def build_seat_skill_directive(agent_id: str | None) -> str:
return "" return ""
def append_seat_skill_directive(agent_id: str | None, task: str) -> str: def append_seat_skill_directive(agent_id: str | None, task: str, *, chain_enabled: bool = False) -> str:
"""把技能识别指令追加到席位本轮任务文本后(无指令时原样返回)。""" """把技能识别指令追加到席位本轮任务文本后(无指令时原样返回)。
directive = build_seat_skill_directive(agent_id)
``chain_enabled``:本次会商所选业务链条是否打开「技能识别强化」。与 agent 级开关取 OR。
"""
directive = build_seat_skill_directive(agent_id, chain_enabled=chain_enabled)
if not directive: if not directive:
return task return task
return f"{task}\n\n---\n\n{directive}" return f"{task}\n\n---\n\n{directive}"
@@ -44,7 +44,7 @@
{"id":"n2","label":"精准追踪管理公众情绪曲线","type":"task","group":"目的","detail":"…"} ← 少了 "from" {"id":"n2","label":"精准追踪管理公众情绪曲线","type":"task","group":"目的","detail":"…"} ← 少了 "from"
``` ```
**硬性要求**:除根节点(任务,n1)外,**每个节点都必须有 `from`**(连到它在上一层级里对应的那个节点);目的i 连 任务,行为体i 连 目的i,预期行为i 连 行为体i,叙事i 连 预期行为i……一组一条自上而下的链,不要让任何节点悬空。 **硬性要求**:除根节点(任务,n1)外,**每个节点都必须有 `from`**——连到它在上一层级里**与之对应**的那个节点(第 k 层第 i 条 → 第 k−1 层对应的那条),一组一条自上而下的链,不要让任何节点悬空。(具体有哪些层级、层间是一对一还是一对多,以本轮给出的「业务链抽取规范」为准。)
## 二、画法(严格遵守) ## 二、画法(严格遵守)
@@ -57,17 +57,22 @@
④ **连线**:你要做对三件事 —— ④ **连线**:你要做对三件事 ——
(a) 每个节点的 `group` 标对层级; (a) 每个节点的 `group` 标对层级;
(b) **各层内的条目按"相同的对应顺序"排列**——报告里相互对应的一组(目的1 ↔ 它对应的行为体 ↔ 对应的预期行为 ↔ 对应的叙事)在各自层里**都排同一位次**(第 1 组都排第 1 位、第 2 组都排第 2 位…); (b) **各层内的条目按"相同的对应顺序"排列**——报告里相互对应的一组(目的1 ↔ 它对应的行为体 ↔ 对应的预期行为 ↔ 对应的叙事)在各自层里**都排同一位次**(第 1 组都排第 1 位、第 2 组都排第 2 位…);
(c) **每个非根节点都填上 `from`**,连到它在上一层级里对应的那个节点(目的i 连 任务、行为体i 连 目的i、预期行为i 连 行为体i、叙事i 连 预期行为i……一组一条自上而下的链)——**最常见的错误就是漏填 from 导致节点平铺,务必每个都填**。 (c) **每个非根节点都填上 `from`**,连到它在上一层级里**与之对应**的那个节点(第 k 层第 i 条 → 第 k−1 层对应的那条,一组一条自上而下的链)——**最常见的错误就是漏填 from 导致节点平铺,务必每个都填**。
系统还会按 group 层级关系**校正 / 补全连线**作为兜底,但你必须先把 from 填全、填对; 系统还会按 group 层级关系**校正 / 补全连线**作为兜底,但你必须先把 from 填全、填对;
⑤ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**(典型规模约 13–16 个节点); ⑤ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**(典型规模约 13–16 个节点);
⑥ **只画**这些层级实体节点;**不要**把「综合分析 / 核心洞察 / 执行路线 / 建议 / 结论」以及「阶段①②③④」等章节名 / 阶段名 / 汇总段落画成节点;节点 `label` 也**绝不能**是「阶段1 / 阶段2 / 第N阶段」这类阶段编号。 ⑥ **只画**这些层级实体节点;**不要**把「综合分析 / 核心洞察 / 执行路线 / 建议 / 结论」以及「阶段①②③④」等章节名 / 阶段名 / 汇总段落画成节点;节点 `label` 也**绝不能**是「阶段1 / 阶段2 / 第N阶段」这类阶段编号。
## 三、层级顺序 ## 三、层级顺序(重要:不要写死任何一套层级)
- 若数据来源里给了「业务链总控编排提示」并写明了层级链(如 任务 → 目的 → 行为体 → 预期行为 → 驱动因素 → 叙事),**优先按它的层级**组织; **每个业务链的层级各不相同**,绝不要默认套用某一套固定层级(如「目的→行为体」)。本轮该用哪套层级,按下面的优先级,**完全以本轮给出的信息为准**:
- 否则按给出的「默认层级」;都没有时,以「任务」为根、按报告 / 交付的逻辑结构合理分层。
1. 若本轮用户消息里给了「业务链抽取规范」(写明完整层级链、层间一对一/一对多关系等)——**这是最高优先级,严格逐层照它抽全、不得缺层、不得停在中间层**;
2. 否则若给了「业务链总控编排提示」并写明了层级链——按它的层级;
3. 都没有时,以「任务」为根、按报告 / 交付里**真实出现的层级**逐层展开。
> 示例(仅示意「层级链」这个概念,**不是固定模板、不要照搬**):某业务可能是 任务 → 目的 → 行为体 → 预期行为 → 驱动因素 → 叙事;另一业务可能完全不同。实际层级一律以上面第 1/2/3 条给出的为准。
## 四、修改 / 校验时 ## 四、修改 / 校验时
@@ -22,6 +22,20 @@
请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。 请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。
## 动笔前:意图对齐与缺口核对(质量关键,每轮首次生成报告都要做)
各席位可能由能力较弱的模型产出,交付未必充分、未必都扣住了用户意图。你是这份报告的**质量兜底**——不能只是把各席位说的拼接起来,而要**对齐用户意图、核对覆盖、补强缺口**。动笔前先在脑内完成(这张核对表不必输出):
1. **拆解意图**:把「任务意图」(目标 / 约束 / 关键假设)拆成若干条**用户真正想要的诉求**;业务链总控编排提示里对最终产出的要求也并入诉求清单。
2. **逐条核对覆盖**:对每条诉求,检查各席位交付 + 总控共识**是否覆盖、覆盖得够不够、有没有跑题或自相矛盾**。
3. **据此组织报告**,并按下面的「缺口处理护栏」补强——让最终报告明显**比"各席位交付的简单拼接"更扣用户意图**:意图里要的、席位没说清的,你补强或标缺口;意图里没要的、席位跑题的,不要喧宾夺主。
### 缺口处理护栏(报告文件要完整干净、把不足说在对话里)
**核心原则:写进 `.md` 报告文件的,必须是一份完整、干净、可直接使用、无需用户再修改的报告——报告文件里不要出现「待补充 / 不足 / ⚠️ / 尚需完善」之类的提示或占位。** 着急的用户拿到文件就能用。
- **能从已有原料重组 / 推理补上的** → 直接补全、讲透,正常写进报告。这是你最该做的增值:把零散、跑题、含糊的席位产物,按用户意图重新梳理成扣题、完整的方案。
- **超出席位原料、属于你自己的合理推断 / 建议** → 可以写进报告,让报告完整连贯;不必在报告里逐处打标签(保持可读),绝不编造与原料矛盾的内容。
- **缺真实信息、靠现有原料补不了的**(需要最新数据 / 外部事实,而席位没给)→ **绝不凭空编**,但也**不要在报告文件里写「待补充」之类的警告**。报告文件就按现有信息给出尽量完整的版本;这些「不足 / 尚可补充之处」**只在对话回复正文里、报告之后单独说明**(见下「B」)。
- 始终:报告文件 = 一份完整可用的报告(无缺口提示);不足只在对话文字里点出;绝不编造无依据的"事实"。
## 产出要求(最重要) ## 产出要求(最重要)
### ⓪ 先判断模式:用户是否要求了结构化输出 / 流程图 ### ⓪ 先判断模式:用户是否要求了结构化输出 / 流程图
@@ -40,9 +54,10 @@
- **严禁**写 Python / shell / 任何脚本去"生成"文件,也**严禁**用 bash——你必须自己把 Markdown 文本直接写进 `write_file`; - **严禁**写 Python / shell / 任何脚本去"生成"文件,也**严禁**用 bash——你必须自己把 Markdown 文本直接写进 `write_file`;
- `write_file` **支持很长的内容,不会截断**——请放心一次性把整篇报告写进去。 - `write_file` **支持很长的内容,不会截断**——请放心一次性把整篇报告写进去。
### B. 在对话回复里给出报告正文 ### B. 在对话回复里给出报告正文(不足只写在这里)
- 写完文件后,**把这份报告的完整正文也输出到对话回复里**(直接给用户读),不要只回一句「已写入文件」。 - 写完文件后,**把这份报告的完整正文也输出到对话回复里**(直接给用户读),不要只回一句「已写入文件」。
- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。 - 报告正文部分与 `.md` 文件**应当一致**(都是那份完整干净的报告)。
- **在对话回复里、报告正文之后,必须追加一段简短「核对说明」**(这段**只在对话里**、**绝不写进 `.md` 报告文件**):对照用户意图(及业务链各层级,如有)**逐条说明产出是否覆盖、哪些充分 / 哪些不足、有哪些可补充完善之处**,并提示可在对话里让你补全(如「---\n**核对说明**:① 已覆盖:…;② 资料不足 / 尚可补充:…(可直接告诉我补全)」)。**即便核对下来各诉求 / 层级都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求 / 层级均已覆盖,无明显缺口」——这段核对说明任何时候都不要省略。** 报告 `.md` 文件本身始终是一份完整可用、无需修改、不含任何缺口提示的版本。
## 报告结构(叙事型,文字为主线) ## 报告结构(叙事型,文字为主线)
**下面是默认结构——仅当「业务链总控编排提示」没有指定输出结构时使用。** 用户指定了结构(章节、表格、清单、固定模板等)就完全按用户的来;两者只部分重叠时,以用户要求为骨架、默认章节做补充。按下面的章节顺序组织,缺料的章节可省略或合并: **下面是默认结构——仅当「业务链总控编排提示」没有指定输出结构时使用。** 用户指定了结构(章节、表格、清单、固定模板等)就完全按用户的来;两者只部分重叠时,以用户要求为骨架、默认章节做补充。按下面的章节顺序组织,缺料的章节可省略或合并:
@@ -122,5 +137,8 @@
- ❌ 修订流程图时只输出改动的节点(必须输出全量 flow-json,前端整体重绘) - ❌ 修订流程图时只输出改动的节点(必须输出全量 flow-json,前端整体重绘)
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印 - ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的内容 - ❌ 编造与原料明显矛盾的内容
- ❌ 只把各席位交付简单拼接、不做「意图对齐与缺口核对」(席位可能弱,你要兜底扣住用户意图)
- ❌ 缺真实信息时凭空编造来"填满"报告(按现有信息给出完整版即可;不足只在对话正文点出,不写进 .md 文件)
- ❌ 在 `.md` 报告文件里写「待补充 / 不足 / ⚠️ / 尚需完善」等缺口提示或占位(报告文件要完整干净、无需修改;不足只写在对话正文里)
- ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高) - ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高)
- ❌ 编排提示已指定输出结构 / 重点席位,却仍按默认章节平均用力(结构要完全跟用户走,重点席位要明显加权) - ❌ 编排提示已指定输出结构 / 重点席位,却仍按默认章节平均用力(结构要完全跟用户走,重点席位要明显加权)
@@ -64,6 +64,11 @@ class AgentResponse(BaseModel):
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.", description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
) )
seat_skill_directive: bool | None = Field(
default=None,
description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).",
)
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent") tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
featured_order: int | None = Field( featured_order: int | None = Field(
@@ -115,6 +120,7 @@ class AgentCreateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml") tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.") common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.") template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
class AgentUpdateRequest(BaseModel): class AgentUpdateRequest(BaseModel):
@@ -131,6 +137,7 @@ class AgentUpdateRequest(BaseModel):
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)") tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.") common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.") template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
class AgentSelectorResponse(BaseModel): class AgentSelectorResponse(BaseModel):
@@ -236,6 +243,7 @@ def _write_agent_config(
tool_groups: list[str] | None = None, tool_groups: list[str] | None = None,
common_questions: list[CommonQuestion] | None = None, common_questions: list[CommonQuestion] | None = None,
template_name: str | None = None, template_name: str | None = None,
seat_skill_directive: bool | None = None,
) -> None: ) -> None:
agent_dir = get_paths().agent_dir(agent_id) agent_dir = get_paths().agent_dir(agent_id)
agent_dir.mkdir(parents=True, exist_ok=True) agent_dir.mkdir(parents=True, exist_ok=True)
@@ -264,13 +272,16 @@ def _write_agent_config(
] ]
if template_name is not None and template_name.strip(): if template_name is not None and template_name.strip():
config_data["template_name"] = template_name.strip() config_data["template_name"] = template_name.strip()
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
if seat_skill_directive is not None:
config_data["seat_skill_directive"] = bool(seat_skill_directive)
with open(config_file, "w", encoding="utf-8") as f: with open(config_file, "w", encoding="utf-8") as f:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True) yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
def _load_config_extras(agent_id: str) -> dict[str, Any]: def _load_config_extras(agent_id: str) -> dict[str, Any]:
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files.""" """Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None} empty = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
try: try:
cfg = load_agent_config(agent_id) cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError): except (FileNotFoundError, ValueError):
@@ -283,6 +294,7 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
"tool_groups": cfg.tool_groups, "tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions, "common_questions": cfg.common_questions,
"template_name": cfg.template_name, "template_name": cfg.template_name,
"seat_skill_directive": cfg.seat_skill_directive,
} }
@@ -443,7 +455,7 @@ def _record_to_response(
can happen lazily when the agent is actually opened. can happen lazily when the agent is actually opened.
""" """
soul: str | None = None soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None} extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
if include_extras: if include_extras:
_ensure_agent_dir_from_record(record) _ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"]) extras = _load_config_extras(record["id"])
@@ -467,6 +479,7 @@ def _record_to_response(
skills=extras["skills"], skills=extras["skills"],
common_questions=extras.get("common_questions"), common_questions=extras.get("common_questions"),
template_name=extras.get("template_name"), template_name=extras.get("template_name"),
seat_skill_directive=extras.get("seat_skill_directive"),
featured_order=record.get("featured_order"), featured_order=record.get("featured_order"),
) )
@@ -793,6 +806,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
tool_groups=request.tool_groups, tool_groups=request.tool_groups,
common_questions=request.common_questions, common_questions=request.common_questions,
template_name=request.template_name, template_name=request.template_name,
seat_skill_directive=request.seat_skill_directive,
) )
_write_agent_soul(agent_id, request.soul) _write_agent_soul(agent_id, request.soul)
@@ -873,8 +887,9 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"] next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"] next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"] next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name")) config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive"))
if config_dirty: if config_dirty:
_write_agent_config( _write_agent_config(
agent_id, agent_id,
@@ -885,6 +900,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
tool_groups=next_tool_groups, tool_groups=next_tool_groups,
common_questions=next_common_questions, common_questions=next_common_questions,
template_name=next_template_name, template_name=next_template_name,
seat_skill_directive=next_seat_skill_directive,
) )
# Keep the listing cache in lock-step with config.yaml, but only # Keep the listing cache in lock-step with config.yaml, but only
# when the cached fields were actually touched — a partial update # when the cached fields were actually touched — a partial update
@@ -31,10 +31,16 @@ from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.gateway.roundtable_model_fallback import fallback_model_chain, reason_text
from app.gateway.routers._roundtable_seed import ( from app.gateway.routers._roundtable_seed import (
STRUCTURE_AGENT_ID, STRUCTURE_AGENT_ID,
ensure_roundtable_functional_agents, ensure_roundtable_functional_agents,
) )
from deerflow.agents.roundtable_orchestrator.extraction_spec import (
build_structure_directive,
chain_level_rank,
is_single_parent_chain,
)
from deerflow.config import get_app_config from deerflow.config import get_app_config
from deerflow.config.agents_config import load_agent_config, load_agent_soul from deerflow.config.agents_config import load_agent_config, load_agent_soul
from deerflow.models.factory import create_chat_model from deerflow.models.factory import create_chat_model
@@ -59,6 +65,11 @@ class FlowExtractRequest(BaseModel):
# 层级骨架:优先 coordinator_prompt 里写明的层级,否则用 chain_levels。 # 层级骨架:优先 coordinator_prompt 里写明的层级,否则用 chain_levels。
chain_levels: list[str] = Field(default_factory=list) chain_levels: list[str] = Field(default_factory=list)
coordinator_prompt: str | None = Field(default=None) coordinator_prompt: str | None = Field(default=None)
# 深链接业务码(rwfx→6BF 等)。非空 → 按该业务的「业务链抽取规范」**强制**注入完整层级
# (覆盖默认/示例层级假设),根治"无论什么业务都按目的→行为体抽"。普通会商为空 → 不注入。
business_code: str | None = Field(default=None)
# 根节点 label 强制值(xdfx:本次「行动名称」)。非空 → 根节点 n1 的 label 必须用它,不要用任务名当根。
root_label: str | None = Field(default=None)
model: str | None = Field(default=None) model: str | None = Field(default=None)
# Phase 2 问答修改:带上当前图 + 用户指令 → 进 edit 模式(按指令改图,其余不变)。 # Phase 2 问答修改:带上当前图 + 用户指令 → 进 edit 模式(按指令改图,其余不变)。
current_graph: dict[str, Any] | None = Field(default=None) current_graph: dict[str, Any] | None = Field(default=None)
@@ -71,11 +82,14 @@ class FlowIntentRequest(BaseModel):
message: str = "" message: str = ""
has_report: bool = False has_report: bool = False
has_flow: bool = False has_flow: bool = False
# 当前是否可入库(结构化抽取智能体已判定有适配本场景的入库技能、入库按钮已出现且未入库)。
# 为真时才允许识别出 ingest 意图——否则「帮我入库」回退为 qa/unclear。
can_ingest: bool = False
model: str | None = Field(default=None) model: str | None = Field(default=None)
# 意图枚举(与前端路由一一对应)。 # 意图枚举(与前端路由一一对应)。ingest 仅在 can_ingest=true 时可用。
_INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "qa", "unclear"} _INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "ingest", "qa", "unclear"}
# ── flow-json 容错解析(Python 版,对齐前端 lib/flow-graph.ts)────────────── # ── flow-json 容错解析(Python 版,对齐前端 lib/flow-graph.ts)──────────────
@@ -396,6 +410,64 @@ def _derive_layered_edges(nodes: list[dict[str, Any]]) -> list[dict[str, Any]] |
return out return out
def _collapse_to_single_parent(graph: dict[str, Any], business_code: str | None) -> dict[str, Any]:
"""**纯树**业务链(rwfx)确定性兜底:每个非根节点最多保留一个父节点(一条入边)。
rwfx 全链是一棵树(除 1:1 的预期行为↔驱动因素,其余皆 1:N),任何节点都只有一个父节点、
绝无多对一/汇聚。但弱模型常给某节点(典型是「叙事」)连多个上层父节点 → 画出汇聚/乱线。
这里按「最近上一层」原则把每个节点的入边收敛到一条:在该节点的多个父节点里,优先保留 group
处于它**紧邻上一层**的那个(按业务链层级序 ``chain_level_rank``);同层多个则保留模型给出的
第一个;都判断不了就保留第一个。根节点(无入边)不变。**只删多余入边、不新增**,不影响节点。
"""
nodes = graph.get("nodes") or []
edges = graph.get("edges") or []
rank = chain_level_rank(business_code) # 层级名 → 自上而下序号
group_by_id: dict[str, str] = {
n["id"]: _as_str(n.get("group")) for n in nodes if isinstance(n, dict) and n.get("id")
}
appear: dict[str, int] = {}
for n in nodes:
g = _as_str(n.get("group")) if isinstance(n, dict) else ""
if g and g not in appear:
appear[g] = len(appear)
def grank(nid: str) -> int:
g = group_by_id.get(nid, "")
if g in rank:
return rank[g]
return 1000 + appear.get(g, 0) # 未在业务层级里的 group 排到最后,不抢「上一层」判定
by_to: dict[str, list[dict[str, Any]]] = {}
order: list[str] = []
for e in edges:
t = e.get("to")
if t not in by_to:
by_to[t] = []
order.append(t)
by_to[t].append(e)
kept: list[dict[str, Any]] = []
collapsed = 0
for t in order:
ins = by_to[t]
if len(ins) <= 1:
kept.extend(ins)
continue
tr = grank(t)
prev_layer = [e for e in ins if grank(_as_str(e.get("from"))) == tr - 1]
if prev_layer:
best = prev_layer[0]
else:
upper = [e for e in ins if grank(_as_str(e.get("from"))) < tr]
best = max(upper, key=lambda e: grank(_as_str(e.get("from")))) if upper else ins[0]
kept.append(best)
collapsed += len(ins) - 1
if collapsed:
logger.info("flow-extract: collapsed %d extra parent edge(s) to single-parent (tree chain %s)", collapsed, business_code)
graph["edges"] = kept
return graph
# ── prompts ──────────────────────────────────────────────────────────────── # ── prompts ────────────────────────────────────────────────────────────────
_SHAPE = ( _SHAPE = (
@@ -419,25 +491,66 @@ _STRUCTURE = (
"② 从第二层起按层级逐层展开:每层在报告/交付里通常列了多条具体条目(如 目的层有 目的1/2/3、行为体层有各自对应行为体,后面还有 预期行为·驱动因素、叙事…)," "② 从第二层起按层级逐层展开:每层在报告/交付里通常列了多条具体条目(如 目的层有 目的1/2/3、行为体层有各自对应行为体,后面还有 预期行为·驱动因素、叙事…),"
"把每一条具体条目各画成一个节点,不要把整层合并成一个笼统节点;" "把每一条具体条目各画成一个节点,不要把整层合并成一个笼统节点;"
"③ 每个节点 group 填它所在层级名称(取自报告里该层级真实名称);" "③ 每个节点 group 填它所在层级名称(取自报告里该层级真实名称);"
"④ **连线**:(a)group 标对层级;(b)各层内条目按**相同对应顺序**排列(相互对应的一组在各层都排同一位次);" "④ **连线**:(a)group 标对层级;(b)**每个非根节点都必须填 from**,连到它在上一层级里**所属/对应**的那个节点;"
"(c)**每个非根节点都必须填 from**,连到上一层级里对应的那个节点(目的i连任务、行为体i连目的i、预期行为i连行为体i、叙事i连预期行为i…)——**最常见错误就是漏 from 导致平铺,务必每个都填**;系统还会按层级校正/补全连线兜底;" "层间关系(哪层一对一、哪层一对多)**以本轮给出的业务链规范为准**:**一对一**的相邻层按位次配对(下层第i条连上层第i条);"
"**一对多**的相邻层里,上层一条可对应下层多条——下层每条连到它**所属**的那一条上层节点,**绝不能把多条上层节点汇聚连到同一个下层节点**(那是把一对多画反成多对一);"
"(c)最常见错误是漏 from 导致平铺,或把一对多画成多对一,务必每个都填对;系统还会按层级校正/补全连线兜底;"
"⑤ 务必把每层每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停(典型约13-16个节点);" "⑤ 务必把每层每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停(典型约13-16个节点);"
"⑥ 只画这些层级实体节点,不要把『综合分析/核心洞察/执行路线/建议/结论』及『阶段①②③④』等汇总段落/阶段名画成节点。" "⑥ 只画这些层级实体节点,不要把『综合分析/核心洞察/执行路线/建议/结论』及『阶段①②③④』等汇总段落/阶段名画成节点。"
) )
def _material(req: FlowExtractRequest) -> str: def _material(req: FlowExtractRequest) -> str:
"""抽取的数据来源:**严格以《方案总结报告》及其文件内容为准**;各席位原始交付仅作兜底。
报告是各席位交付经总控综合提炼、**已做取舍**后的最终结论(如从 10 个候选目的里锁定 3 个)。
因此抽取**只按报告最终呈现的条目**逐条抽全——既不要漏(报告列了 3 条别只抽 1 条),也不要多
(别去席位产物里把报告已淘汰的候选也抽进来)。各席位原始交付**仅在报告某层整层缺失 / 报告明确
说有 N 条却没逐条列出时**才用来补全那一层;与报告冲突时一律以报告为准。
"""
# xdfx:把本次「行动名称」作为材料抬头喂进去(根节点就是它),让抽取贴着这条行动展开。
action = (req.root_label or "").strip()
prefix = f"【本次行动】{action}\n\n" if action else ""
report = (req.report_md or "").strip() report = (req.report_md or "").strip()
if report:
return "===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 ====="
seats = [s for s in req.seat_deliveries if (s.content or "").strip()] seats = [s for s in req.seat_deliveries if (s.content or "").strip()]
parts: list[str] = []
if report:
parts.append(
"===== 方案总结报告全文(**主数据来源,以它为准**:按它最终呈现的条目逐条抽取、勿改写、勿增删)=====\n"
+ report
+ "\n===== 报告结束 ====="
)
if seats: if seats:
body = "\n\n".join(f"## 席位{i + 1}:{s.sender or '(未具名席位)'}\n{s.content.strip()}" for i, s in enumerate(seats)) body = "\n\n".join(f"## 席位{i + 1}:{s.sender or '(未具名席位)'}\n{s.content.strip()}" for i, s in enumerate(seats))
return "===== 各席位交付产物(数据来源,逐条抽取,勿改写)=====\n" + body + "\n===== 交付结束 =====" if report:
return "" parts.append(
"===== 各席位原始交付产物(**兜底参考,仅报告不足时才用**)=====\n"
"用途:**只有当报告某个层级整层缺失、或报告明确说有 N 条却没逐条列出时**,才回到这里把那一层补全。\n"
"⚠️ **绝不要用这里去扩充报告已经取舍过的层级**——报告里被淘汰 / 未采用的候选项(典型:席位列了 10 个候选目的,"
"但报告最终只锁定 3 个),**一律以报告最终采用的为准,不要把被淘汰的候选也抽进来**。与报告冲突时以报告为准。\n"
+ body
+ "\n===== 交付结束 ====="
)
else:
# 没有报告时,席位产物才是主来源(此时按席位最终采用的结论抽,候选池同样别全抽)。
parts.append("===== 各席位交付产物(数据来源,逐条抽取、勿改写;按其最终采用的结论抽,不要把被淘汰的候选都抽进来)=====\n" + body + "\n===== 交付结束 =====")
if not parts:
return ""
return prefix + "\n\n".join(parts)
def _hierarchy_hint(req: FlowExtractRequest) -> str: def _hierarchy_hint(req: FlowExtractRequest) -> str:
# 深链接业务规范**最高优先**:按 business_code 取写实/通用规范,覆盖任何默认层级假设。
spec = build_structure_directive(req.business_code)
if spec:
cp = (req.coordinator_prompt or "").strip()
if cp:
spec += (
"\n(若「业务链总控编排提示」对层级另有更细要求,可在**不违背上述完整层级**的前提下采纳:\n"
+ cp
+ ")"
)
return spec
if (req.coordinator_prompt or "").strip(): if (req.coordinator_prompt or "").strip():
return "层级顺序优先采用下面「业务链总控编排提示」里写明的层级链(如它写的 任务→目的→行为体→…):\n" + req.coordinator_prompt.strip() return "层级顺序优先采用下面「业务链总控编排提示」里写明的层级链(如它写的 任务→目的→行为体→…):\n" + req.coordinator_prompt.strip()
levels = [s.strip() for s in req.chain_levels if s.strip()] levels = [s.strip() for s in req.chain_levels if s.strip()]
@@ -446,6 +559,14 @@ def _hierarchy_hint(req: FlowExtractRequest) -> str:
return "层级顺序:以「任务」为根,按报告/交付的逻辑结构合理分层展开。" return "层级顺序:以「任务」为根,按报告/交付的逻辑结构合理分层展开。"
def _root_label_hint(req: FlowExtractRequest) -> str:
"""根节点 label 强制提示:xdfx 用「行动名称」当根节点(而非任务名)。无 root_label → 空。"""
label = (req.root_label or "").strip()
if not label:
return ""
return f"\n【根节点强制】整张图的根节点 n1 的 label **必须**用本次行动名称:「{label}」——不要用任务名/其它当根节点。"
# 兜底系统提示:当 roundtable-structure 的 SOUL 缺失时用它(与 SOUL 同款契约)。 # 兜底系统提示:当 roundtable-structure 的 SOUL 缺失时用它(与 SOUL 同款契约)。
_DEFAULT_SYSTEM = ( _DEFAULT_SYSTEM = (
"你是「结构化抽取智能体」。职责:读懂《方案总结报告》/ 各席位交付内容,分析其层级结构," "你是「结构化抽取智能体」。职责:读懂《方案总结报告》/ 各席位交付内容,分析其层级结构,"
@@ -483,6 +604,14 @@ def _extract_messages(req: FlowExtractRequest) -> list[Any]:
_material(req) _material(req)
+ "\n\n" + "\n\n"
+ _hierarchy_hint(req) + _hierarchy_hint(req)
+ _root_label_hint(req)
+ "\n\n【按报告逐条抽全,不漏也不多(最重要)】**严格以《方案总结报告》最终呈现的条目为准**:"
+ "报告某层级用表格 / 列表 / ①②③ 逐条列了几条,就出几个节点、**一条都不能漏**(不要只抽被标「确定 / 第1名」的那条而漏掉同列的其余);"
+ "某上层节点名下列了几条下层条目就出几个节点,**不许把多条合并 / 概括成一条**;"
+ "⚠️ 但**不要无中生有**:报告已明确取舍、最终只采用 N 条的层级(如「从 10 个候选目的中锁定 3 个」「最终选定方案」),"
+ "**只抽报告最终采用的那 N 条**,**绝不要去席位产物或报告的「候选池 / 被淘汰项」里把没被采用的也抽出来凑数**;"
+ "报告把某层多条目压缩成一句话、没逐条列出时(如只说「12 条预期行为」却没列),才回《各席位原始交付产物》把那一层补全。"
+ "不要只照搬报告结尾「方案总纲 / 最终框架」里被精简的主干,要回到正文(如「各席位观点与交付」)把报告列出的每层条目抽全。"
+ "\n\n请分析以上数据来源,按你的职责设定输出一个完整的 ```flow-json 代码块。" + "\n\n请分析以上数据来源,按你的职责设定输出一个完整的 ```flow-json 代码块。"
) )
return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)] return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)]
@@ -504,20 +633,35 @@ def _edit_messages(req: FlowExtractRequest) -> list[Any]:
def _validate_messages(req: FlowExtractRequest, extracted: str) -> list[Any]: def _validate_messages(req: FlowExtractRequest, extracted: str) -> list[Any]:
# 深链接业务规范:校验阶段也带上完整层级 + 根节点强制,确保按规范补齐缺层、根节点用行动名。
spec = build_structure_directive(req.business_code)
spec_block = (spec + _root_label_hint(req) + "\n\n") if (spec or req.root_label) else ""
user = ( user = (
_material(req) spec_block
+ _material(req)
+ "\n\n===== 已抽取的 flow-json(待校验修复)=====\n" + "\n\n===== 已抽取的 flow-json(待校验修复)=====\n"
+ extracted + extracted
+ "\n===== 待校验结束 =====\n\n请对照数据来源,**逐个节点检查上面这份 flow-json 是不是缺字段**、并修复它:" + "\n===== 待校验结束 =====\n\n请对照数据来源,**逐个节点检查上面这份 flow-json 是不是缺字段**、并修复它:"
+ "\n**校验要点(务必遵守)**:" + "\n**校验要点(务必遵守)**:"
+ "① **连接关系 `from`(最重要,必查必补)**:**逐个**检查上面每个节点——除根任务外," + "① **连接关系 `from`(最重要,必查必补)**:**逐个**检查上面每个节点——除根任务外,"
+ "**只要某个节点缺了 `from`(或 `from` 为空),就必须给它补上正确的 `from`**:连到它在上一层级里对应的那个节点" + "**只要某个节点缺了 `from`(或 `from` 为空),就必须给它补上正确的 `from`**:连到它在上一层级里**所属/对应**的那个节点。"
+ "(目的i 连 任务、行为体i 连 目的i、预期行为i 连 行为体i、叙事i 连 预期行为i…)。" + "层间关系(哪层一对一、哪层一对多)**以上面给出的业务链规范为准**:一对一层级按位次配对(下层第i条连上层第i条);"
+ "缺 `from` 是这份 JSON 最常见、最致命的错漏(会导致流程图平铺),**绝不能让任何非根节点悬空**;" + "**一对多**层级里下层每条连到它**所属**的那一条上层节点,**绝不能把多条上层节点汇聚到同一个下层节点**(那是把一对多画反成多对一,是本次要重点纠正的错)。"
+ "② **每层每条都在、没漏层没漏条目**,节点数只能 ≥ 原图、不能变少;" + "缺 `from` / 把一对多画成多对一 是最常见错漏,**绝不能让任何非根节点悬空、也不要张冠李戴**;"
+ "③ 每个节点的 `group`(层级名)标对;各层内条目按相同对应顺序排列(相互对应的一组在各层排同一位次);" + "② **一对一(1:1)层级必须严格 1:1、绝不多对一(按业务链规范,如预期行为↔驱动因素;本次重点纠错)**:"
+ "④ 每个节点的 `detail` **原样保留、不要简化或精简理由**——理由要完整,不要删减内容;" + "这两层节点数**必须完全相等**、按位次一一配对;**每个下层节点的 `from` 只能写一条上层节点**,"
+ "⑤ 没有汇总/阶段编号节点;只在确有错漏时才动,没问题就原样规范化输出。" + "**绝不允许多条上层节点共用 / 汇聚到同一个下层节点**(多对一),也不允许同一条上层节点被多个下层节点指向"
+ "(即 1:1 两层里任何节点都不能冒出 / 汇入多条连线)。若发现下层节点偏少(被合并)导致只能多对一,"
+ "**必须按上层逐条拆分 / 补出等量的下层节点**(每条上层各配一个,内容综合提炼),使两层等量后再逐一配对;"
+ "③ **逐层条目数对齐《方案总结报告》(不漏也不多,必查)**:以报告为准——报告某层列了几条就该有几个节点。"
+ "漏抽的补齐(报告列了 3 条却只抽了 1 条 → 补到 3;某行为体名下多条预期行为被并成一句 → 拆全);"
+ "**但绝不要新增报告未采用的候选**——报告已取舍、最终只采用 N 条的层级(如从 10 个候选目的里锁定 3 个),"
+ "**只保留报告最终采用的那 N 条;若图里混进了报告未采用 / 被淘汰的候选(从席位候选池里抽出来的),把它们删掉**。"
+ "仅当报告把某层压缩成一句话、没逐条列时,才回《各席位原始交付产物》补全那一层;"
+ "④ **不要漏报告列出的层 / 条目**;一对多的下层(某上层对应多条下层)不要被错误合并成一条;"
+ "⑤ 每个节点的 `group`(层级名)标对;一对一层级内条目按相同位次对齐,一对多层级下层每条紧挨其所属上层条目分组;"
+ "⑥ 每个节点的 `detail` **原样保留、不要简化或精简理由**——理由要完整,不要删减内容;"
+ "⑦ 没有汇总/阶段编号节点;只在确有错漏时才动,没问题就原样规范化输出。"
+ "\n输出修复后的完整 ```flow-json 代码块(含全部节点 + 每个非根节点的 from)。" + "\n输出修复后的完整 ```flow-json 代码块(含全部节点 + 每个非根节点的 from)。"
) )
return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)] return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)]
@@ -559,28 +703,60 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
return return
# ① 抽取 / 编辑(正文 + 思考(reasoning) 拼成 `<think>…</think>正文` 增量逐帧吐,前端实时显示) # ① 抽取 / 编辑(正文 + 思考(reasoning) 拼成 `<think>…</think>正文` 增量逐帧吐,前端实时显示)
# 模型自动容错:结构化抽取非常重要——某模型 astream 抛错 / 产出空时,按 config.yaml
# models[] 顺序切到下一个模型重抽,并发 model_switch 帧让前端把抽取面板已累计的半截内容重置。
round1_messages = _edit_messages(payload) if is_edit else _extract_messages(payload) round1_messages = _edit_messages(payload) if is_edit else _extract_messages(payload)
model = create_chat_model(name=resolved_model, thinking_enabled=True) chain = fallback_model_chain(resolved_model) or [resolved_model]
extracted = "" extracted = ""
think_open = False used_model = chain[0]
async for chunk in model.astream(round1_messages): prev_model: str | None = None
c, r = _chunk_parts(chunk) prev_reason: str | None = None
out = "" for idx, candidate in enumerate(chain):
if r: if idx > 0:
if not think_open: yield _frame(
out += "<think>" {
think_open = True "event": "model_switch",
out += r "agent_name": "结构化抽取",
if c: "failed_model": prev_model or "",
"next_model": candidate or "",
"reason": reason_text(prev_reason),
}
)
used_model = candidate
extracted = ""
think_open = False
try:
model = create_chat_model(name=candidate, thinking_enabled=True)
async for chunk in model.astream(round1_messages):
c, r = _chunk_parts(chunk)
out = ""
if r:
if not think_open:
out += "<think>"
think_open = True
out += r
if c:
if think_open:
out += "</think>"
think_open = False
out += c
extracted += c
if out:
yield _frame({"event": "extract_delta", "content": out})
if think_open: if think_open:
out += "</think>" yield _frame({"event": "extract_delta", "content": "</think>"})
think_open = False except Exception as exc: # noqa: BLE001 — 该模型抽取失败:换下一个模型重抽
out += c prev_model, prev_reason = candidate, "exception"
extracted += c logger.warning("flow-extract round1 model %s failed: %s", candidate, exc)
if out: if idx + 1 < len(chain):
yield _frame({"event": "extract_delta", "content": out}) continue
if think_open: raise
yield _frame({"event": "extract_delta", "content": "</think>"}) if extracted.strip():
break
# 产出为空:换下一个模型(已是最后一个则继续走后续解析兜底)。
prev_model, prev_reason = candidate, "empty"
if idx + 1 >= len(chain):
break
yield _frame({"event": "extract_done"}) yield _frame({"event": "extract_done"})
# ② 校验 / 修复(同样流式 + 思考实时显示) # ② 校验 / 修复(同样流式 + 思考实时显示)
@@ -588,7 +764,7 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
validated = "" validated = ""
graph: dict[str, Any] | None = None graph: dict[str, Any] | None = None
try: try:
vmodel = create_chat_model(name=resolved_model, thinking_enabled=True) vmodel = create_chat_model(name=used_model, thinking_enabled=True)
think_open = False think_open = False
async for chunk in vmodel.astream(_validate_messages(payload, extracted)): async for chunk in vmodel.astream(_validate_messages(payload, extracted)):
c, r = _chunk_parts(chunk) c, r = _chunk_parts(chunk)
@@ -633,6 +809,10 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
if graph is None: if graph is None:
yield _frame({"event": "error", "detail": "未能从模型输出解析出流程图节点"}) yield _frame({"event": "error", "detail": "未能从模型输出解析出流程图节点"})
return return
# 纯树业务链(rwfx)确定性兜底:把任何节点的多父入边收敛到单一父节点,杜绝弱模型
# 违反 1:N 画出的汇聚/乱线(典型是叙事连了一片驱动因素)。非纯树业务(xdfx 有汇聚)跳过。
if is_single_parent_chain(payload.business_code):
graph = _collapse_to_single_parent(graph, payload.business_code)
yield _frame({"event": "final", "graph": graph}) yield _frame({"event": "final", "graph": graph})
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.exception("flow-extract stream failed") logger.exception("flow-extract stream failed")
@@ -672,23 +852,33 @@ def _parse_intent_json(text: str) -> dict[str, Any] | None:
_INTENT_SYS = ( _INTENT_SYS = (
"你是圆桌「方案总结」第三步输入框的**意图识别器**。第三步有两类产物:方案总结报告(Markdown) 和 结构化流程图。" "你是圆桌「方案总结」第三步对话的**意图识别器**,也是个能帮用户的助手。第三步已产出:方案总结报告(Markdown)、"
"用户在输入框发了一条消息,请判断他的**真实意图**,只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n" "结构化流程图,可能还可把成果入库到知识库。用户在对话框发了一条消息,请**按语义理解他真正想干什么**"
"(不要只做关键词匹配),只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n"
'{"intent":"<枚举>","reason":"<一句话依据>"}\n' '{"intent":"<枚举>","reason":"<一句话依据>"}\n'
"intent 只能取其一:\n" "intent 取值:\n"
"- regenerate_report:想重新分析 / 重写【方案总结报告】(如「重新生成报告」「报告再写一版」「重新分析一下」);\n" "- regenerate_report:想**重做/重写整份**方案总结报告(如「重新生成报告」「报告再写一版」「重新分析一下」);\n"
"- modify_flow:想修改**当前流程图**的某些节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「流程图这里调整下」);\n" "- modify_flow:想**修改当前流程图**的节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「这一层调整下」);\n"
"- regenerate_flow:想**重新生成整张**流程图(如「重新画流程图」「流程图重来」「重新结构化输出」);\n" "- regenerate_flow:想**重新生成整张**流程图(如「重新画流程图」「流程图重来」「重新结构化输出」);\n"
"- qa:只是提问 / 咨询,不需要改任何产物(如「为什么选 OTA 平台」「这个目的什么意思」);\n" "- ingest:想把本次成果**入库/沉淀/存进知识库**(如「帮我入库」「入库吧」「把方案存进知识库」「沉淀一下」);\n"
"- unclear:意图不明确、无法判断要改报告还是改流程图还是提问(如只说「改一下」「不对」「重来」而没指明对象)。\n" "- qa:**提问 / 要求解释、介绍、概述、答疑**,只需用文字回答、不改任何产物(如「介绍一下这报告的内容」"
"判断要点:结合下面给出的上下文(是否已有报告 / 是否已有流程图);当前已有流程图时,含「节点/连线/这一层/这个目的/行为体…改/删/加」之类多半是 modify_flow;" "「这方案主要讲了啥」「为什么选 OTA 平台」「风险最高的是哪条」「帮我看看有没有遗漏」「总结成几句话」);\n"
"明确提到「报告」才是 regenerate_report;只有完全无法判断对象时才用 unclear。\n" "- unclear:**实在**判断不出他想做哪件事(如只说「改一下」「不对」「重来」「再说」且无任何指向)。\n"
"判断要点:\n"
"1. **优先识别出明确意图,不要动不动就 unclear**——只要能合理判断就给具体意图;尤其『介绍/讲讲/解释/说明/"
"为什么/是什么/有没有/帮我看/概述/总结一下』这类是**对内容的提问** → 一律 qa(用文字答),不要误判成重做报告。\n"
"2. 含『节点/连线/这一层/这个目的/行为体/流程图…的 改/删/加/换』 → modify_flow(前提是已有流程图)。\n"
"3. 明确说『报告』要重写/重做才是 regenerate_report;只是问报告内容是 qa。\n"
"4. **入库类意图只有在下文上下文标注「可入库=是」时才允许返回 ingest**;若标注不可入库,用户提入库就归 qa"
"(用文字说明当前不可入库/无适配技能)。\n"
"示例:\n" "示例:\n"
'「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重新生成报告"}\n' '「帮我介绍一下这报告中的内容」→ {"intent":"qa","reason":"要求介绍报告内容,属提问答疑"}\n'
'「把目的1的名称改短点」→ {"intent":"modify_flow","reason":"要求修改流程图某节点"}\n' '「这方案为什么这么定」→ {"intent":"qa","reason":"询问方案理由"}\n'
'「流程图重新画一张」→ {"intent":"regenerate_flow","reason":"要求重画整张流程图"}\n' '「帮我入库」(可入库=是) → {"intent":"ingest","reason":"明确要求入库且当前可入库"}\n'
'「OTA 平台为什么是直接行为体」→ {"intent":"qa","reason":"纯提问不改产物"}\n' '「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重做报告"}\n'
'「改一下」→ {"intent":"unclear","reason":"没说改报告还是流程图"}' '「把目的1的名称改短点」→ {"intent":"modify_flow","reason":"修改流程图某节点"}\n'
'「流程图重新画一张」→ {"intent":"regenerate_flow","reason":"重画整张流程图"}\n'
'「改一下」→ {"intent":"unclear","reason":"没说改什么"}'
) )
@@ -698,7 +888,11 @@ async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Bo
msg = (payload.message or "").strip() msg = (payload.message or "").strip()
if not msg: if not msg:
return {"intent": "unclear", "reason": "空消息"} return {"intent": "unclear", "reason": "空消息"}
ctx = f"当前上下文:{'已生成报告' if payload.has_report else '尚无报告'};{'已生成流程图' if payload.has_flow else '尚无流程图'}。" ctx = (
f"当前上下文:{'已生成报告' if payload.has_report else '尚无报告'};"
f"{'已生成流程图' if payload.has_flow else '尚无流程图'};"
f"可入库={'是' if payload.can_ingest else '否'}。"
)
try: try:
model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False) model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False)
res = await model.ainvoke( res = await model.ainvoke(
@@ -709,6 +903,9 @@ async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Bo
intent = _as_str(obj.get("intent")).lower() intent = _as_str(obj.get("intent")).lower()
if intent not in _INTENTS: if intent not in _INTENTS:
intent = "unclear" intent = "unclear"
# 不可入库时模型若误判 ingest → 降级为 qa(用文字说明当前不可入库),不要触发入库。
if intent == "ingest" and not payload.can_ingest:
intent = "qa"
return {"intent": intent, "reason": _as_str(obj.get("reason"))} return {"intent": intent, "reason": _as_str(obj.get("reason"))}
except Exception: # noqa: BLE001 — 分类失败不致命,回退澄清让用户自己定 except Exception: # noqa: BLE001 — 分类失败不致命,回退澄清让用户自己定
logger.warning("flow intent classify failed", exc_info=True) logger.warning("flow intent classify failed", exc_info=True)
@@ -63,7 +63,10 @@ from fastapi.responses import StreamingResponse
from langgraph.checkpoint.base import empty_checkpoint from langgraph.checkpoint.base import empty_checkpoint
from app.gateway.roundtable_diag import record_foreground_diag from app.gateway.roundtable_diag import record_foreground_diag
from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
from app.gateway.roundtable_seat_skills import append_seat_skill_directive from app.gateway.roundtable_seat_skills import append_seat_skill_directive
from app.gateway.routers._roundtable_seed import ( from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID, COORDINATOR_AGENT_ID,
@@ -1545,21 +1548,31 @@ async def _leader_run(
if files: if files:
_read_tools = {"read_file", "ls", "grep", "view_image"} _read_tools = {"read_file", "ls", "grep", "view_image"}
leader_excluded = [t for t in leader_excluded if t not in _read_tools] leader_excluded = [t for t in leader_excluded if t not in _read_tools]
body = _run_payload(
agent_name=agent_name, # 模型自动容错:按 config.yaml models[] 顺序的候选链。当前模型出问题(兜底错误文案 /
model_name=model_name, # 既无正文又无派活)时切到下一个模型重跑(见循环内检测)。``_mk_body`` 用候选模型重建 body。
thread_id=thread_id, model_chain = fallback_model_chain(model_name) or [model_name]
# 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。 model_idx = 0
new_message=leader_input, current_model = model_chain[0]
excluded_tools=leader_excluded,
skill_stop_names=effective_stop_names, def _mk_body(m: str) -> Any:
thinking_enabled=policy.thinking_enabled, return _run_payload(
reasoning_effort=policy.reasoning_effort, agent_name=agent_name,
# 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。 model_name=m,
multitask_strategy="rollback", thread_id=thread_id,
# 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。 # 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
files=files, new_message=leader_input,
) excluded_tools=leader_excluded,
skill_stop_names=effective_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
# 人工干预/恢复会重发 leader run 到同一总控 thread;抢占掉没结束的旧 run,避免 409。
multitask_strategy="rollback",
# 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
files=files,
)
body = _mk_body(current_model)
t_payload_built = time.perf_counter() t_payload_built = time.perf_counter()
logger.info( logger.info(
"[leader-timing] %s payload_built=%.3fs (broadcast running in background)", "[leader-timing] %s payload_built=%.3fs (broadcast running in background)",
@@ -1643,6 +1656,28 @@ async def _leader_run(
leader_text = _flatten_content(dispatch_msg.get("content", "")) leader_text = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or [] tool_calls = dispatch_msg.get("tool_calls") or []
# 模型自动容错:本轮总控产出是「中间件兜底错误文案」或「既无正文又无派活」(模型没干活)
# → 若还有备选模型,切到下一个重跑(不消耗派活纠正预算)。先发 model_switch 帧让前端把
# 这条失败气泡重置,再用新模型重新流式。最后一个模型仍失败则照常往下走(错误文案当共识展示)。
fail_reason = is_llm_error_message(dispatch_msg)
if fail_reason is None and not (leader_text or "").strip() and not tool_calls:
fail_reason = "empty"
if fail_reason and model_idx + 1 < len(model_chain):
model_idx += 1
next_model = model_chain[model_idx]
logger.warning("[leader-fallback] %s 模型 %s 失败(%s),切换到 %s", agent_name, current_model, fail_reason, next_model)
yield {
"status": "model_switch",
"role": "leader",
"agent_name": agent_name,
"failed_model": current_model,
"next_model": next_model,
"reason": reason_text(fail_reason),
}
current_model = next_model
body = _mk_body(current_model)
continue
# 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组), # 澄清分支:与 intent.py 相同字段;status 为字符串 "clarification"(非数组),
# 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。 # 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
clarification_call = next( clarification_call = next(
@@ -1663,12 +1698,13 @@ async def _leader_run(
"allow_custom": bool(cargs.get("allow_custom", True)), "allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs), "allow_multiple": resolve_allow_multiple(cargs),
"agent_name": agent_name, "agent_name": agent_name,
"model_used": current_model,
} }
return return
if not tool_calls: if not tool_calls:
# 空 status 数组 = 总控不再派活,前端视为共识(hasConsensus)。 # 空 status 数组 = 总控不再派活,前端视为共识(hasConsensus)。
yield {"status": [], "content": leader_text, "agent_name": agent_name} yield {"status": [], "content": leader_text, "agent_name": agent_name, "model_used": current_model}
return return
# 派活前按真实 roster 校验:合法保留、非法丢弃(只丢非法,不殃及同轮合法席位)。 # 派活前按真实 roster 校验:合法保留、非法丢弃(只丢非法,不殃及同轮合法席位)。
@@ -1704,7 +1740,7 @@ async def _leader_run(
# 关键路径优先:**先 yield 派活帧**,让前端立刻启动席位 special run;派活广播(告知 # 关键路径优先:**先 yield 派活帧**,让前端立刻启动席位 special run;派活广播(告知
# 其它席位"总控给 X 派了什么活")改成 detached 后台任务,不再 await 阻塞 —— 否则广播 # 其它席位"总控给 X 派了什么活")改成 detached 后台任务,不再 await 阻塞 —— 否则广播
# 读写各席位巨大 thread 状态会卡几十秒,白白拖慢席位启动(见 _spawn_detached_broadcast)。 # 读写各席位巨大 thread 状态会卡几十秒,白白拖慢席位启动(见 _spawn_detached_broadcast)。
yield {"status": dispatched, "content": leader_text, "agent_name": agent_name} yield {"status": dispatched, "content": leader_text, "agent_name": agent_name, "model_used": current_model}
# DAG 派活轮(suppress_pre_broadcast)同样**不**把「总控给 X 派活」广播给兄弟席位 —— # DAG 派活轮(suppress_pre_broadcast)同样**不**把「总控给 X 派活」广播给兄弟席位 ——
# 否则弱模型从历史里看到派活语句又会模仿派活(席位任务由前端 pickDagSeatTask 干净下发, # 否则弱模型从历史里看到派活语句又会模仿派活(席位任务由前端 pickDagSeatTask 干净下发,
# 跨席位承上靠各 special run 的"完成交付"后广播,不需要这条派活广播)。recommend/chain 照旧。 # 跨席位承上靠各 special run 的"完成交付"后广播,不需要这条派活广播)。recommend/chain 照旧。
@@ -1737,6 +1773,8 @@ async def _special_run(
thinking_enabled: bool | None = None, thinking_enabled: bool | None = None,
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
subagent_enabled: bool = False, subagent_enabled: bool = False,
seat_skill_directive: bool = False,
business_code: str = "",
) -> AsyncIterator[str | dict[str, Any]]: ) -> AsyncIterator[str | dict[str, Any]]:
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。 """子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
@@ -1789,99 +1827,140 @@ async def _special_run(
# 弱模型补偿:仅对真正的研讨席位(seat / seat_parallel;report/summary/dashboard/ingest # 弱模型补偿:仅对真正的研讨席位(seat / seat_parallel;report/summary/dashboard/ingest
# 等 Step3 单例不在此列)把其配置技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其 # 等 Step3 单例不在此列)把其配置技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其
# 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见 # 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见
# roundtable_seat_skills 模块说明)。无配置技能时原样返回。 # roundtable_seat_skills 模块说明)。默认关闭:是否注入 = 该席位 agent 的开关
# (config.yaml)OR 本次会商所选业务链条的开关(seat_skill_directive 透传)。无配置
# 技能 / 两开关都关时原样返回。
seat_message = new_message seat_message = new_message
if role in ("seat", "seat_parallel"): if role in ("seat", "seat_parallel"):
seat_message = append_seat_skill_directive(agent_name, new_message) seat_message = append_seat_skill_directive(agent_name, new_message, chain_enabled=seat_skill_directive)
body = _run_payload( # 深链接业务规范(仅 summary 单例):把该业务的「业务链完整性核对」指令拼进本轮任务,强制总结报告
agent_name=agent_name, # 核对产出相对业务链完不完整。business_code 为空(普通会商)/ 非写实业务 → directive 为 "",原样不变。
model_name=model_name, elif role == "summary" and business_code:
thread_id=thread_id, directive = build_summary_directive(business_code)
new_message=seat_message, if directive:
excluded_tools=seat_excluded, seat_message = f"{new_message}\n\n---\n\n{directive}"
skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names), # 模型自动容错:按 config.yaml models[] 顺序的候选链。某模型出问题(兜底错误文案;
# 席位执行模式覆盖 policy 的 thinking/reasoning(None → 回退 policy 默认); # 真正的研讨席位还含「交付为空」)→ 发 model_switch 帧让前端重置该席位气泡,再用下一个
# subagent 仅 ultra 档置真。工具裁剪(excluded_tools)仍完全由 policy 决定。 # 模型重跑。Step3 单例(report/summary/dashboard/ingest)的产物是落盘文件,正文可能很短,
thinking_enabled=(policy.thinking_enabled if thinking_enabled is None else thinking_enabled), # 故**不**把「正文为空」当失败(与后台 run_report/summary 的 validate=None 一致),只在真正
reasoning_effort=(policy.reasoning_effort if reasoning_effort is None else reasoning_effort), # 的模型错误时换模型。
subagent_enabled=subagent_enabled, model_chain = fallback_model_chain(model_name) or [model_name]
# ultra 席位才会派子智能体 → 才需要 custom 流回传子任务实时进度(task_running 等)。 empty_is_failure = role in ("seat", "seat_parallel")
stream_custom=subagent_enabled,
# 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。 def _mk_body(m: str) -> Any:
multitask_strategy="rollback", return _run_payload(
# 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。 agent_name=agent_name,
extra_context=( model_name=m,
{"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None thread_id=thread_id,
), new_message=seat_message,
# 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。 excluded_tools=seat_excluded,
files=files, skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
) # 席位执行模式覆盖 policy 的 thinking/reasoning(None → 回退 policy 默认);
# subagent 仅 ultra 档置真。工具裁剪(excluded_tools)仍完全由 policy 决定。
thinking_enabled=(policy.thinking_enabled if thinking_enabled is None else thinking_enabled),
reasoning_effort=(policy.reasoning_effort if reasoning_effort is None else reasoning_effort),
subagent_enabled=subagent_enabled,
# ultra 席位才会派子智能体 → 才需要 custom 流回传子任务实时进度(task_running 等)。
stream_custom=subagent_enabled,
# 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。
multitask_strategy="rollback",
# 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。
extra_context=(
{"roundtable_peer_deliveries": peer_deliveries} if peer_deliveries else None
),
# 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
files=files,
)
t_payload_built = time.perf_counter() t_payload_built = time.perf_counter()
logger.info( logger.info(
"[special-timing] %s payload_built=%.3fs", "[special-timing] %s payload_built=%.3fs",
agent_name, agent_name,
t_payload_built - t0, t_payload_built - t0,
) )
captured: list[str] = []
first_line_ts: float | None = None
line_count = 0
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
if first_line_ts is None:
first_line_ts = time.perf_counter()
logger.info(
"[special-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
agent_name,
first_line_ts - t_payload_built,
first_line_ts - t0,
)
line_count += 1
yield line
elif done is not None:
captured = done
t_stream_done = time.perf_counter()
logger.info(
"[special-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
agent_name,
t_stream_done - (first_line_ts or t_payload_built),
line_count,
t_stream_done - t0,
)
messages = _parse_last_messages(captured)
if messages is None:
# 与 _leader_run 同理:席位 run 被抢占/断开导致流截断,不抛 502,静默收尾。
# WARNING 级:正常是 rollback 抢占;否则查上游 run 的服务端报错(如 DB 连接断)。
logger.warning(
"[special-timing] %s upstream truncated (no values frame) — ending quietly "
"(expected on rollback preemption; otherwise check the upstream run's server-side error)",
agent_name,
)
return
# Sub-agent's final deliverable is the latest AIMessage's text content.
last_msg: dict[str, Any] = {}
for msg in reversed(messages):
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
last_msg = msg
break
content = _flatten_content(last_msg.get("content", ""))
# 优先用 display_name(中文友好,例如"情报收集")提示 leader,id 放括号备查; # 优先用 display_name(中文友好,例如"情报收集")提示 leader,id 放括号备查;
# display_name 缺失时退化为仅 agent_name —— 与旧行为一致。 # display_name 缺失时退化为仅 agent_name —— 与旧行为一致。
speaker_label = f"{display_name}({agent_name})" if display_name else agent_name speaker_label = f"{display_name}({agent_name})" if display_name else agent_name
_prefix = f"子智能体{speaker_label}完成{new_message}工作,交付内容为:"
# 广播给**所有**席位(含总控)的都只是**截断摘要** —— 总控 thread 不再囤积各席位全文, for idx, candidate in enumerate(model_chain):
# 每轮 leader 的上下文因此保持小、响应快。总控做最终综合需要全文时,由 _leader_run 在 body = _mk_body(candidate)
# 「综合轮」按需现读各席位 thread 注入(见 _build_full_deliverables_block),不靠历史囤积。 captured: list[str] = []
brief_msg = _truncate_broadcast_content(_prefix, content) first_line_ts: float | None = None
# 先 yield 交付结果让前端立即收口本席位;后广播改 detached,不再 await 阻塞 —— line_count = 0
# 否则它读写各席位大状态会让 SSE 多挂十几秒,把下一轮 leader 的启动也一起拖慢。 async for line, done in _stream_upstream(client, base, headers, thread_id, body):
yield { if line is not None:
"status": f"子智能体{speaker_label}完成{new_message}工作", if first_line_ts is None:
"content": content, first_line_ts = time.perf_counter()
"agent_name": agent_name, logger.info(
} "[special-timing] %s first_upstream_line=%.3fs since_entry=%.3fs",
_spawn_detached_broadcast(base, headers, brief_msg, agent_threads, [agent_name]) agent_name,
first_line_ts - t_payload_built,
first_line_ts - t0,
)
line_count += 1
yield line
elif done is not None:
captured = done
t_stream_done = time.perf_counter()
logger.info(
"[special-timing] %s upstream_stream_complete=%.3fs lines=%d since_entry=%.3fs",
agent_name,
t_stream_done - (first_line_ts or t_payload_built),
line_count,
t_stream_done - t0,
)
messages = _parse_last_messages(captured)
if messages is None:
# 与 _leader_run 同理:席位 run 被抢占/断开导致流截断,不抛 502,静默收尾。
# 这不是模型错误(多半 rollback 抢占),不触发换模型。
logger.warning(
"[special-timing] %s upstream truncated (no values frame) — ending quietly "
"(expected on rollback preemption; otherwise check the upstream run's server-side error)",
agent_name,
)
return
# Sub-agent's final deliverable is the latest AIMessage's text content.
last_msg: dict[str, Any] = {}
for msg in reversed(messages):
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
last_msg = msg
break
content = _flatten_content(last_msg.get("content", ""))
# 模型失败检测:兜底错误文案;研讨席位再加「交付为空」。还有备选模型则换。
fail_reason = is_llm_error_message(last_msg)
if fail_reason is None and empty_is_failure and not content.strip():
fail_reason = "empty"
if fail_reason and idx + 1 < len(model_chain):
next_model = model_chain[idx + 1]
logger.warning("[special-fallback] %s 模型 %s 失败(%s),切换到 %s", agent_name, candidate, fail_reason, next_model)
yield {
"status": "model_switch",
"role": role,
"agent_name": agent_name,
"failed_model": candidate,
"next_model": next_model,
"reason": reason_text(fail_reason),
}
continue
# 成功(或已用尽备选):发交付终态帧 + detached 后广播。
_prefix = f"子智能体{speaker_label}完成{new_message}工作,交付内容为:"
# 广播给**所有**席位(含总控)的都只是**截断摘要** —— 总控 thread 不再囤积各席位全文,
# 每轮 leader 的上下文因此保持小、响应快。总控做最终综合需要全文时,由 _leader_run 在
# 「综合轮」按需现读各席位 thread 注入(见 _build_full_deliverables_block),不靠历史囤积。
brief_msg = _truncate_broadcast_content(_prefix, content)
# 先 yield 交付结果让前端立即收口本席位;后广播改 detached,不再 await 阻塞 ——
# 否则它读写各席位大状态会让 SSE 多挂十几秒,把下一轮 leader 的启动也一起拖慢。
yield {
"status": f"子智能体{speaker_label}完成{new_message}工作",
"content": content,
"agent_name": agent_name,
"model_used": candidate,
}
_spawn_detached_broadcast(base, headers, brief_msg, agent_threads, [agent_name])
return
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1957,6 +2036,12 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
_re_raw = payload.get("reasoning_effort") _re_raw = payload.get("reasoning_effort")
seat_reasoning_effort: str | None = _re_raw if isinstance(_re_raw, str) and _re_raw else None seat_reasoning_effort: str | None = _re_raw if isinstance(_re_raw, str) and _re_raw else None
seat_subagent_enabled: bool = bool(payload.get("subagent_enabled") or False) seat_subagent_enabled: bool = bool(payload.get("subagent_enabled") or False)
# 业务链条级「技能识别强化」开关(仅 special 席位消费):前端读所选业务链条的同名配置
# 后逐席位透传。与该席位 agent 自身的 config.yaml 开关取 OR;缺省 False(默认关)。
seat_skill_directive: bool = bool(payload.get("seat_skill_directive") or False)
# 深链接业务码(rwfx→6BF 等)。仅 summary 单例消费:非空 → 按业务链抽取规范强制注入「完整性核对」
# 指令,让总结报告核对产出相对业务链完不完整。普通会商为空 → 不注入。
business_code: str = str(payload.get("business_code") or "")
if not agent_name or agent_name not in agent_threads: if not agent_name or agent_name not in agent_threads:
raise HTTPException( raise HTTPException(
@@ -2007,6 +2092,8 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
thinking_enabled=seat_thinking_enabled, thinking_enabled=seat_thinking_enabled,
reasoning_effort=seat_reasoning_effort, reasoning_effort=seat_reasoning_effort,
subagent_enabled=seat_subagent_enabled, subagent_enabled=seat_subagent_enabled,
seat_skill_directive=seat_skill_directive,
business_code=business_code,
) )
async for chunk in gen: async for chunk in gen:
if isinstance(chunk, dict): if isinstance(chunk, dict):
@@ -48,6 +48,16 @@ class ChainSeatSchema(BaseModel):
# 数组以 JSON 透传持久化(见 persistence/roundtable_chains),故新增此字段无需迁移; # 数组以 JSON 透传持久化(见 persistence/roundtable_chains),故新增此字段无需迁移;
# 仅需在此 schema 显式声明,否则 model_dump() 会把未知字段剥掉。 # 仅需在此 schema 显式声明,否则 model_dump() 会把未知字段剥掉。
model: str | None = Field(default=None, max_length=128) model: str | None = Field(default=None, max_length=128)
# 该席位「会商技能识别强化」开关(弱模型补偿)。True = 该席位作为会商子智能体执行时,
# 把其配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 的硬指令)拼进本轮任务。
# 与该席位 agent 自身 config.yaml 的同名开关取 OR。默认 False(关)。整条 seats 数组以
# JSON 透传持久化,故新增此字段无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。
seat_skill_directive: bool = False
# 该席位「推理深度覆盖」(flash/thinking/pro/ultra):覆盖链条级 seat_mode,仅本席位生效。
# None = 跟随链条级默认。「卡顿提速」用:把次要席位降到 flash。整条 seats 数组以 JSON 透传
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
# 被 _SEAT_MODE_PARAMS.get() 安全忽略(回退作业级),故此处只约束长度、不强校验取值。
seat_mode: str | None = Field(default=None, max_length=16)
class ChainResponse(BaseModel): class ChainResponse(BaseModel):
@@ -64,6 +74,8 @@ class ChainResponse(BaseModel):
coordinator_prompt: str | None = None coordinator_prompt: str | None = None
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设,选入研讨时回退默认(pro)。 # 默认席位执行模式(flash/thinking/pro/ultra);None = 未设,选入研讨时回退默认(pro)。
seat_mode: str | None = None seat_mode: str | None = None
# 研讨前并行取数开关(逐条 opt-in);True = 选入研讨时研讨前先并行取数。缺省 False。
gather_first: bool = False
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。 # 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。 # 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
@@ -91,6 +103,7 @@ class ChainCreateRequest(BaseModel):
stage_goals: list[str | None] | None = None stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000) coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16) seat_mode: str | None = Field(default=None, max_length=16)
gather_first: bool = False
class ChainUpdateRequest(BaseModel): class ChainUpdateRequest(BaseModel):
@@ -102,6 +115,8 @@ class ChainUpdateRequest(BaseModel):
stage_goals: list[str | None] | None = None stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000) coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16) seat_mode: str | None = Field(default=None, max_length=16)
# 研讨前并行取数开关;send 显式 true/false 改,omit 不改。
gather_first: bool | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。 # 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None is_public: bool | None = None
@@ -284,6 +299,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"stage_goals": body.stage_goals, "stage_goals": body.stage_goals,
"coordinator_prompt": body.coordinator_prompt, "coordinator_prompt": body.coordinator_prompt,
"seat_mode": body.seat_mode, "seat_mode": body.seat_mode,
"gather_first": body.gather_first,
}, },
) )
await _enrich_owner_names([row]) await _enrich_owner_names([row])
@@ -77,6 +77,18 @@ class StartJobRequest(BaseModel):
thinking_enabled: bool | None = None thinking_enabled: bool | None = None
reasoning_effort: str | None = None reasoning_effort: str | None = None
subagent_enabled: bool = False subagent_enabled: bool = False
# 每席位「技能识别强化」开关映射 {agent_id: bool}(前端读所选业务链条里**每个席位**的
# 同名配置后下发)。后台席位 run 据此(与各席位 agent 自身 config.yaml 的开关取 OR)决定
# 是否注入。缺省空 dict(全部按 False/关,仅各 agent 自身开关生效)。
seat_skill_directives: dict[str, bool] = Field(default_factory=dict)
# 每席位「推理深度覆盖」映射 {agent_id: SeatMode}(前端读所选业务链条里**每个席位**单独配的
# 推理深度后下发;值为 flash/thinking/pro/ultra)。后台席位 run 按 agent_id 查表:命中则用该档
# 派生的 thinking/reasoning/subagent 覆盖作业级 seatMode;未命中 → 跟随作业级。缺省空 dict。
seat_modes: dict[str, str] = Field(default_factory=dict)
# 是否研讨前先并行『取数』(业务链条逐条 opt-in;前端读所选链条的开关后下发)。默认 False。
gather_first: bool = False
# 深链接业务码(rwfx→6BF 等)。透传到后台总结报告的「业务链完整性核对」。普通会商为空。
business_code: str | None = None
class ResumeJobRequest(BaseModel): class ResumeJobRequest(BaseModel):
@@ -235,6 +247,10 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
seat_thinking_enabled=body.thinking_enabled, seat_thinking_enabled=body.thinking_enabled,
seat_reasoning_effort=body.reasoning_effort, seat_reasoning_effort=body.reasoning_effort,
seat_subagent_enabled=body.subagent_enabled, seat_subagent_enabled=body.subagent_enabled,
seat_skill_directives=dict(body.seat_skill_directives or {}),
seat_modes=dict(body.seat_modes or {}),
gather_first=bool(body.gather_first),
business_code=(body.business_code or None),
) )
) )
return _to_response(row) return _to_response(row)
@@ -24,6 +24,63 @@ from deerflow.config.app_config import AppConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 兜底/熔断的 AIMessage 会带上 ``additional_kwargs[LLM_ERROR_MARKER] = reason``,
# 让下游(圆桌会商的单轮执行器等)能**可靠识别**「这条 AI 回复其实是模型调用失败的
# 兜底文案」而不是真实交付——据此触发自动换模型。``reason`` ∈ {quota, auth, busy,
# transient, upstream, generic, circuit}。注意:model_dump() 会保留 additional_kwargs,
# 故经 checkpointer 序列化后仍在;万一被剥离,``is_llm_error_message`` 还会回退按已知
# 兜底文案精确匹配。
LLM_ERROR_MARKER = "deerflow_llm_error"
# 各 reason 对应的用户可见兜底文案(单一数据源:构造与识别都用它)。
_USER_MESSAGES: dict[str, str] = {
"quota": "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。",
"auth": "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。",
"busy": "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。",
"transient": "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。",
"upstream": "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。",
"generic": "大模型本次调用未能成功完成,请稍后再试或更换模型。",
}
_CIRCUIT_MESSAGE = "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
# 全部兜底文案集合,供 ``is_llm_error_message`` 在 additional_kwargs 被剥离时按正文回退识别。
_ALL_ERROR_MESSAGES: frozenset[str] = frozenset({*_USER_MESSAGES.values(), _CIRCUIT_MESSAGE})
def is_llm_error_message(message: Any) -> str | None:
"""判断一条 AI 消息是否为「模型调用失败的兜底文案」,是则返回 reason,否则 None。
入参可为 LangChain ``BaseMessage``,或 checkpointer 序列化后的 dict(两种都支持)。
优先看 ``additional_kwargs[LLM_ERROR_MARKER]``(最可靠);万一被序列化剥离,再回退
按正文是否精确等于已知兜底文案识别(容忍前后空白)。
"""
if message is None:
return None
# additional_kwargs(dict 或对象属性两种形态)。
extra: Any = None
if isinstance(message, dict):
extra = message.get("additional_kwargs")
else:
extra = getattr(message, "additional_kwargs", None)
if isinstance(extra, dict):
reason = extra.get(LLM_ERROR_MARKER)
if reason:
return str(reason)
# 正文回退匹配。
if isinstance(message, dict):
content = message.get("content")
else:
content = getattr(message, "content", None)
text = ""
if isinstance(content, str):
text = content
elif isinstance(content, list):
text = "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text")
text = text.strip()
if text and text in _ALL_ERROR_MESSAGES:
return "generic"
return None
_RETRIABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504} _RETRIABLE_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
_BUSY_PATTERNS = ( _BUSY_PATTERNS = (
"server busy", "server busy",
@@ -201,17 +258,9 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…" return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
def _build_circuit_breaker_message(self) -> str: def _build_circuit_breaker_message(self) -> str:
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。" return _CIRCUIT_MESSAGE
def _build_user_message(self, reason: str) -> str: def _build_user_message(self, reason: str) -> str:
if reason == "quota":
return "大模型因账户额度不足、账单异常或使用受限而拒绝请求,请检查模型账户后再试。"
if reason == "auth":
return "大模型因鉴权失败或无访问权限而拒绝请求,请检查模型的 API Key 与权限配置后再试。"
if reason in {"busy", "transient"}:
return "大模型因服务繁忙或网络异常,多次重试后仍未恢复,请稍候再继续。"
if reason == "upstream":
return "大模型当前响应异常(上游模型或网关临时故障),多次重试后仍未恢复,请稍后再试或更换模型。"
# NOTE: We intentionally do NOT embed the raw exception detail into the # NOTE: We intentionally do NOT embed the raw exception detail into the
# user-facing message. Raw `Error code: 400 - {...}` strings — especially # user-facing message. Raw `Error code: 400 - {...}` strings — especially
# Python repr-style dict literals — get persisted as AI messages and then # Python repr-style dict literals — get persisted as AI messages and then
@@ -219,7 +268,11 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
# braces can corrupt the upstream JSON payload and cause deterministic # braces can corrupt the upstream JSON payload and cause deterministic
# JSONDecodeError loops. Keep the user message clean; full detail is # JSONDecodeError loops. Keep the user message clean; full detail is
# already in the backend log via `logger.warning(..., exc_info=exc)`. # already in the backend log via `logger.warning(..., exc_info=exc)`.
return "大模型本次调用未能成功完成,请稍后再试或更换模型。" return _USER_MESSAGES.get(reason, _USER_MESSAGES["generic"])
def _error_message(self, content: str, reason: str) -> AIMessage:
"""构造带 ``LLM_ERROR_MARKER`` 标记的兜底 AIMessage(供下游识别+换模型)。"""
return AIMessage(content=content, additional_kwargs={LLM_ERROR_MARKER: reason})
def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None: def _emit_retry_event(self, attempt: int, wait_ms: int, reason: str) -> None:
try: try:
@@ -246,7 +299,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ModelRequest], ModelResponse], handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult: ) -> ModelCallResult:
if self._check_circuit(): if self._check_circuit():
return AIMessage(content=self._build_circuit_breaker_message()) return self._error_message(self._build_circuit_breaker_message(), "circuit")
attempt = 1 attempt = 1
while True: while True:
@@ -283,7 +336,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
) )
if retriable: if retriable:
self._record_failure() self._record_failure()
return AIMessage(content=self._build_user_message(reason)) return self._error_message(self._build_user_message(reason), reason)
@override @override
async def awrap_model_call( async def awrap_model_call(
@@ -292,7 +345,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ModelRequest], Awaitable[ModelResponse]], handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult: ) -> ModelCallResult:
if self._check_circuit(): if self._check_circuit():
return AIMessage(content=self._build_circuit_breaker_message()) return self._error_message(self._build_circuit_breaker_message(), "circuit")
attempt = 1 attempt = 1
while True: while True:
@@ -329,7 +382,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
) )
if retriable: if retriable:
self._record_failure() self._record_failure()
return AIMessage(content=self._build_user_message(reason)) return self._error_message(self._build_user_message(reason), reason)
def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool: def _matches_any(detail: str, patterns: tuple[str, ...]) -> bool:
@@ -23,6 +23,7 @@ import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime
from .extraction_spec import build_summary_directive
from .progress import JobProgressWriter from .progress import JobProgressWriter
from .types import ( from .types import (
CHAIN_CLOSEOUT_PROMPT, CHAIN_CLOSEOUT_PROMPT,
@@ -80,6 +81,8 @@ async def run_orchestration(
intent_text: str = "", intent_text: str = "",
enable_report: bool = True, enable_report: bool = True,
plan: dict | None = None, plan: dict | None = None,
gather_first: bool = False,
business_code: str | None = None,
) -> OrchestrationResult: ) -> OrchestrationResult:
"""运行一次完整编排(循环 → 共识 → 报告绘制)。 """运行一次完整编排(循环 → 共识 → 报告绘制)。
@@ -97,23 +100,32 @@ async def run_orchestration(
可解析出非空 stages 时走 ``_run_dag``;否则(dag 但 plan 缺失/为空)回退 recommend。 可解析出非空 stages 时走 ``_run_dag``;否则(dag 但 plan 缺失/为空)回退 recommend。
enable_report: 是否在收口后执行 Step3(方案总结报告 + 结果绘制)。False 时只跑完 enable_report: 是否在收口后执行 Step3(方案总结报告 + 结果绘制)。False 时只跑完
Step2 研讨即收尾(step3=None)。两段产物各自故障隔离,详见 ``_run_step3``。 Step2 研讨即收尾(step3=None)。两段产物各自故障隔离,详见 ``_run_step3``。
gather_first: 是否在研讨前先跑一轮**全席位并行『取数』**(见 ``_run_gather_phase``),结果作为
共享资料注入各研讨轮席位任务。业务链条逐条 opt-in(默认 False)。三种模式通用——尤其让
chain(串行)的技能延迟前置并行、研讨段提速。
""" """
intent = (intent_text or seed_message or "").strip() intent = (intent_text or seed_message or "").strip()
try: try:
await progress.begin() await progress.begin()
await progress.diag( await progress.diag(
stage="step2_orchestration", level="info", event="begin", stage="step2_orchestration", level="info", event="begin",
message=f"会商编排启动(mode={mode},{len(agents)} 个席位)", message=f"会商编排启动(mode={mode},{len(agents)} 个席位{',研讨前并行取数' if gather_first else ''})",
detail={"mode": mode, "agents": [a.agent_id for a in agents]}, detail={"mode": mode, "agents": [a.agent_id for a in agents], "gather_first": gather_first},
)
# 研讨前并行取数(opt-in):全席位并行只取数,产出作为共享资料注入后续研讨。失败/为空安全降级。
gathered = (
await _run_gather_phase(gateway, progress, agents, thread_ids, model, intent)
if gather_first
else {}
) )
dag_plan = parse_dag_plan(plan) if mode == "dag" else None dag_plan = parse_dag_plan(plan) if mode == "dag" else None
if mode == "dag" and dag_plan is not None: if mode == "dag" and dag_plan is not None:
outcome = await _run_dag(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, dag_plan) outcome = await _run_dag(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, dag_plan, gathered=gathered)
elif mode == "chain": elif mode == "chain":
outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent) outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, gathered=gathered)
else: else:
# recommend,或 dag 但 plan 缺失/为空 → 回退总控自由派活(绝不静默跑错语义)。 # recommend,或 dag 但 plan 缺失/为空 → 回退总控自由派活(绝不静默跑错语义)。
outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent) outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, gathered=gathered)
if outcome.kind == "clarification": if outcome.kind == "clarification":
question = outcome.question or "总控需要补充信息,请补充后继续。" question = outcome.question or "总控需要补充信息,请补充后继续。"
@@ -152,6 +164,7 @@ async def run_orchestration(
consensus_text=outcome.last_leader_content, consensus_text=outcome.last_leader_content,
deliveries=outcome.deliveries, deliveries=outcome.deliveries,
coordinator_prompt=(dag_plan.coordinator_prompt if dag_plan is not None else None), coordinator_prompt=(dag_plan.coordinator_prompt if dag_plan is not None else None),
business_code=business_code,
) )
await progress.done() await progress.done()
return OrchestrationResult( return OrchestrationResult(
@@ -189,6 +202,26 @@ _SEAT_NO_ASK = (
) )
async def _emit_switches(
progress: JobProgressWriter,
switches: list,
*,
agent_name: str,
cycle: int = 0,
agent_id: str | None = None,
) -> None:
"""把单轮执行器带回的模型容错记录逐条插进研讨时间线(系统提示气泡)。"""
for sw in switches or []:
await progress.model_switch(
agent_name=agent_name,
failed_model=getattr(sw, "failed_model", ""),
next_model=getattr(sw, "next_model", ""),
reason_label=getattr(sw, "reason_label", "调用失败"),
cycle=cycle,
agent_id=agent_id,
)
def _deliveries_block(deliveries: dict[str, str], name_by_id: dict[str, str]) -> str: def _deliveries_block(deliveries: dict[str, str], name_by_id: dict[str, str]) -> str:
parts: list[str] = [] parts: list[str] = []
for aid, text in deliveries.items(): for aid, text in deliveries.items():
@@ -199,11 +232,30 @@ def _deliveries_block(deliveries: dict[str, str], name_by_id: dict[str, str]) ->
return "\n\n".join(parts) return "\n\n".join(parts)
def _seat_message(intent_text: str, deliveries: dict[str, str], name_by_id: dict[str, str], task: str) -> str: def _seat_message(
"""把任务背景 + 前序交付 + 防提问约束拼进席位任务(替代 thread 广播)。""" intent_text: str,
deliveries: dict[str, str],
name_by_id: dict[str, str],
task: str,
*,
gathered: dict[str, str] | None = None,
) -> str:
"""把任务背景 + (可选)研讨前并行收集的资料 + 前序交付 + 防提问约束拼进席位任务(替代 thread 广播)。
``gathered``(可选)= 研讨前「并行取数阶段」各席位收集回来的**原始资料/事实**(见
``_run_gather_phase``),与 ``deliveries``(研讨阶段的结论性交付)分块、各自标题不同:
资料供席位直接引用以**减少重复检索**,结论供席位承接。两者都为空则都不拼。
"""
parts: list[str] = [] parts: list[str] = []
if intent_text.strip(): if intent_text.strip():
parts.append(f"【本次研讨任务背景】\n{intent_text.strip()}") parts.append(f"【本次研讨任务背景】\n{intent_text.strip()}")
if gathered:
gblock = _deliveries_block(gathered, name_by_id)
if gblock:
parts.append(
"【研讨前各席位已并行收集的资料/事实,供你直接引用——相关内容已在此,"
"尽量避免重复检索;确有缺口可再补充检索】\n" + gblock
)
block = _deliveries_block(deliveries, name_by_id) block = _deliveries_block(deliveries, name_by_id)
if block: if block:
parts.append("【前序各席位已完成的交付,请在此基础上承接、补充、不要重复或推翻】\n" + block) parts.append("【前序各席位已完成的交付,请在此基础上承接、补充、不要重复或推翻】\n" + block)
@@ -212,6 +264,73 @@ def _seat_message(intent_text: str, deliveries: dict[str, str], name_by_id: dict
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
# ── 研讨前「并行取数」阶段(可选,业务链条逐条 opt-in)──────────────────────────
#
# 各席位**只调技能/凭专业把相关信息收集回来、不研讨**,全席位 ``asyncio.gather`` 并行跑一轮,
# 结果作为「共享资料」注入后续研讨轮(``_seat_message(gathered=...)``)。这样最慢的技能延迟只在
# 这一次、并行发生;后续研讨段(哪怕 chain 串行)每席位基本变成一次纯推理调用,大幅缩短第二步
# 总耗时。研讨阶段是否还再调技能不作限制(按用户设定)。
_GATHER_TASK = (
"【资料收集阶段——只取数、不研讨】\n"
"现在是正式研讨**之前**的并行资料收集阶段。请你**只负责把与本次任务、与你这个席位职责相关的"
"真实信息/数据收集、整理出来**(优先用你配置的技能去检索/获取真实信息,没有技能可凭专业知识梳理),"
"为后续正式研讨打底。\n"
"**这一步不要做分析评估、不要给方案、不要下结论**——把判断与方案留到研讨阶段。"
"请把你收集到的关键信息整理成条理清晰的事实/要点清单输出。"
)
async def _run_gather_phase(
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
model: str | None,
intent_text: str,
) -> dict[str, str]:
"""研讨前并行『取数』:所有席位并行只取数(不研讨),返回 {agent_id: 收集到的资料}。
复用 ``_run_dag`` 同款 ``asyncio.gather`` + ``gateway.run_seat``;各席位独立 thread,进程内并行
安全。某席位取数失败/为空不阻塞其它(其 gathered 项缺省即不注入)。结果由调用方传给后续研讨轮的
``_seat_message(gathered=...)``。
"""
name_by_id = {a.agent_id: a.name for a in agents}
gathered: dict[str, str] = {}
await progress.diag(
stage="step2_orchestration", level="info", event="gather_begin",
message=f"研讨前并行资料收集阶段启动({len(agents)} 个席位并行取数)",
detail={"agents": [a.agent_id for a in agents]},
)
async def _gather_one(seat: SeatRef) -> None:
await progress.seat_active(seat.agent_id)
seat_msg = _seat_message(intent_text, {}, name_by_id, _GATHER_TASK)
try:
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
except Exception: # noqa: BLE001 — 单席位取数失败不拖垮整阶段;其 gathered 缺省即可
logger.warning("gather phase seat %s failed", seat.agent_id, exc_info=True)
await progress.seat_done(seat.agent_id, 0)
return
await _emit_switches(progress, seat_turn.switches, agent_name=name_by_id.get(seat.agent_id, seat.agent_id), agent_id=seat.agent_id)
text = (seat_turn.final_text or "").strip()
if text:
gathered[seat.agent_id] = text
await progress.add_seat_dialogue(
seat.agent_id, name_by_id.get(seat.agent_id, seat.agent_id), seat_turn.final_text, 0,
tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id),
)
await progress.seat_done(seat.agent_id, 0)
await progress.dispatching(0)
await asyncio.gather(*(_gather_one(seat) for seat in agents))
await progress.diag(
stage="step2_orchestration", level="info", event="gather_done",
message=f"资料收集阶段完成({len(gathered)}/{len(agents)} 个席位有产出)",
)
return gathered
async def _run_recommend( async def _run_recommend(
gateway: RoundtableGateway, gateway: RoundtableGateway,
progress: JobProgressWriter, progress: JobProgressWriter,
@@ -221,6 +340,8 @@ async def _run_recommend(
model: str | None, model: str | None,
seed_message: str, seed_message: str,
intent_text: str, intent_text: str,
*,
gathered: dict[str, str] | None = None,
) -> _LoopOutcome: ) -> _LoopOutcome:
deliveries: dict[str, str] = {} deliveries: dict[str, str] = {}
last_leader = "" last_leader = ""
@@ -235,6 +356,7 @@ async def _run_recommend(
message=message, message=message,
model=model, model=model,
) )
await _emit_switches(progress, turn.switches, agent_name="总控", cycle=cycle)
if turn.final_text: if turn.final_text:
last_leader = turn.final_text last_leader = turn.final_text
await progress.add_leader_dialogue( await progress.add_leader_dialogue(
@@ -246,24 +368,49 @@ async def _run_recommend(
if turn.is_consensus: if turn.is_consensus:
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries) return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# **顺序**派活 —— 不要并行;每个席位带上「背景 + 前序交付」上下文 # 批内**并行**派活(与前端 runOrchestration「总控一轮派多席位=判定相互独立→并行」一致,
# 2026-06-20 由串行改并行):总控一轮派出的多个席位彼此独立,并发跑可大幅缩短本轮耗时。
# 每个席位带上「背景 +(可选)研讨前并行取数资料 + 前序交付」上下文。各席位独立 thread,进程内
# gather 安全;后端用户级信号量(_BACKGROUND_SLOTS_PER_USER)仍限制实际并发,保护 sqlite 锁。
# 批内交付在本轮结束后统一并入 deliveries(同前端:批内不互相看,下一轮总控才看到全部)。
await progress.dispatching(cycle) await progress.dispatching(cycle)
for agent_id, task in turn.dispatched: # 派活前对照快照(批内各席位看到的「前序交付」一致,不受批内执行顺序影响 → 并行无副作用)。
prior_snapshot = dict(deliveries)
batch: dict[str, str] = {}
async def _run_one(agent_id: str, task: str, cyc: int = cycle) -> None:
name = name_by_id.get(agent_id, agent_id)
await progress.seat_active(agent_id) await progress.seat_active(agent_id)
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task) seat_msg = _seat_message(intent_text, prior_snapshot, name_by_id, task, gathered=gathered)
seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=seat_msg, model=model) try:
deliveries[agent_id] = seat.final_text seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=seat_msg, model=model)
except Exception: # noqa: BLE001 — 单席位彻底失败不拖垮整批;置空交付 + 记诊断
logger.warning("recommend seat %s failed", agent_id, exc_info=True)
await progress.diag(
stage="step2_seat", level="error", event="seat_failed",
message=f"席位「{name}」本轮执行异常(已跳过,不影响其它席位)",
agent_id=agent_id, agent_name=name, cycle=cyc,
)
batch[agent_id] = ""
await progress.add_seat_dialogue(agent_id, name, "", cyc, thread_id=thread_ids.get(agent_id))
await progress.seat_done(agent_id, cyc)
return
await _emit_switches(progress, seat.switches, agent_name=name, cycle=cyc, agent_id=agent_id)
batch[agent_id] = seat.final_text
if not (seat.final_text or "").strip(): if not (seat.final_text or "").strip():
await progress.diag( await progress.diag(
stage="step2_seat", level="warning", event="seat_empty_delivery", stage="step2_seat", level="warning", event="seat_empty_delivery",
message=f"席位「{name_by_id.get(agent_id, agent_id)}」本轮交付为空(可能模型未产出或被截断)", message=f"席位「{name}」本轮交付为空(可能模型未产出或被截断)",
agent_id=agent_id, agent_name=name_by_id.get(agent_id, agent_id), cycle=cycle, agent_id=agent_id, agent_name=name, cycle=cyc,
) )
await progress.add_seat_dialogue( await progress.add_seat_dialogue(
agent_id, name_by_id.get(agent_id, agent_id), seat.final_text, cycle, agent_id, name, seat.final_text, cyc,
tool_calls=seat.tool_calls, thread_id=thread_ids.get(agent_id), tool_calls=seat.tool_calls, thread_id=thread_ids.get(agent_id),
) )
await progress.seat_done(agent_id, cycle) await progress.seat_done(agent_id, cyc)
await asyncio.gather(*(_run_one(agent_id, task) for agent_id, task in turn.dispatched))
deliveries.update(batch)
await progress.bump_consensus() await progress.bump_consensus()
# 续轮:把各席位本轮交付喂给总控,让它据实判断(总控 SOUL 依赖看到交付) # 续轮:把各席位本轮交付喂给总控,让它据实判断(总控 SOUL 依赖看到交付)
@@ -287,6 +434,8 @@ async def _run_chain(
model: str | None, model: str | None,
seed_message: str, seed_message: str,
intent_text: str, intent_text: str,
*,
gathered: dict[str, str] | None = None,
) -> _LoopOutcome: ) -> _LoopOutcome:
deliveries: dict[str, str] = {} deliveries: dict[str, str] = {}
last_leader = "" last_leader = ""
@@ -313,6 +462,7 @@ async def _run_chain(
message=leader_task, message=leader_task,
model=model, model=model,
) )
await _emit_switches(progress, turn.switches, agent_name="总控", cycle=i + 1)
if turn.final_text: if turn.final_text:
last_leader = turn.final_text last_leader = turn.final_text
await progress.add_leader_dialogue( await progress.add_leader_dialogue(
@@ -323,10 +473,26 @@ async def _run_chain(
task = pick_chain_task(turn.dispatched, turn.final_text, seat.agent_id, seat.name) task = pick_chain_task(turn.dispatched, turn.final_text, seat.agent_id, seat.name)
# (b) 当前席位交付(顺序,不并行);带上「背景 + 前序交付 + 别提问」上下文 # (b) 当前席位交付(顺序,不并行);带上「背景 +(可选)研讨前并行取数资料 + 前序交付 + 别提问」上下文。
# chain 严格串行(seat B 看 seat A 交付),但有 gathered 时各席位已带共享资料,不必各自从零检索 → 提速。
await progress.seat_active(seat.agent_id) await progress.seat_active(seat.agent_id)
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task) seat_msg = _seat_message(intent_text, deliveries, name_by_id, task, gathered=gathered)
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model) try:
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
except Exception: # noqa: BLE001 — 单席位失败不中断整链,置空交付后继续后续席位
logger.warning("chain seat %s failed", seat.agent_id, exc_info=True)
await progress.diag(
stage="step2_seat", level="error", event="seat_failed",
message=f"席位「{seat.name}」本轮执行异常(已跳过,链条继续)",
agent_id=seat.agent_id, agent_name=seat.name, cycle=i + 1,
)
deliveries[seat.agent_id] = ""
await progress.add_seat_dialogue(seat.agent_id, seat.name, "", i + 1, thread_id=thread_ids.get(seat.agent_id))
await progress.seat_done(seat.agent_id, i + 1)
if total > 0:
await progress.set_consensus_pct(round((i + 1) / total * 90))
continue
await _emit_switches(progress, seat_turn.switches, agent_name=seat.name, cycle=i + 1, agent_id=seat.agent_id)
deliveries[seat.agent_id] = seat_turn.final_text deliveries[seat.agent_id] = seat_turn.final_text
if not (seat_turn.final_text or "").strip(): if not (seat_turn.final_text or "").strip():
await progress.diag( await progress.diag(
@@ -354,6 +520,7 @@ async def _run_chain(
message=closeout_msg, message=closeout_msg,
model=model, model=model,
) )
await _emit_switches(progress, closeout.switches, agent_name="总控", cycle=total + 1)
if closeout.final_text: if closeout.final_text:
last_leader = closeout.final_text last_leader = closeout.final_text
await progress.add_leader_dialogue( await progress.add_leader_dialogue(
@@ -376,6 +543,8 @@ async def _run_dag(
seed_message: str, seed_message: str,
intent_text: str, intent_text: str,
plan: DagPlan, plan: DagPlan,
*,
gathered: dict[str, str] | None = None,
) -> _LoopOutcome: ) -> _LoopOutcome:
"""分层 DAG 编排:每个 stage 一轮总控派活(钉死本批席位)→ 批内席位**并行**执行 """分层 DAG 编排:每个 stage 一轮总控派活(钉死本批席位)→ 批内席位**并行**执行
(``asyncio.gather``)→ 下一 stage 总控再派;全部 stage 完成后由总控收口综合。 (``asyncio.gather``)→ 下一 stage 总控再派;全部 stage 完成后由总控收口综合。
@@ -416,6 +585,7 @@ async def _run_dag(
message=leader_task, message=leader_task,
model=model, model=model,
) )
await _emit_switches(progress, turn.switches, agent_name="总控", cycle=i + 1)
if turn.final_text: if turn.final_text:
last_leader = turn.final_text last_leader = turn.final_text
await progress.add_leader_dialogue( await progress.add_leader_dialogue(
@@ -429,22 +599,35 @@ async def _run_dag(
for seat in stage_seats: for seat in stage_seats:
await progress.seat_active(seat.agent_id) await progress.seat_active(seat.agent_id)
async def _run_one(seat: SeatRef) -> None: async def _run_one(seat: SeatRef, _gathered: dict[str, str] | None = gathered, _cyc: int = i + 1) -> None:
task = pick_dag_seat_task(turn.dispatched, seat.agent_id, seat.name, goal=stage.goal) task = pick_dag_seat_task(turn.dispatched, seat.agent_id, seat.name, goal=stage.goal)
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task) seat_msg = _seat_message(intent_text, deliveries, name_by_id, task, gathered=_gathered)
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model) try:
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
except Exception: # noqa: BLE001 — 单席位失败不拖垮整个 stage 并行批
logger.warning("dag seat %s failed", seat.agent_id, exc_info=True)
await progress.diag(
stage="step2_seat", level="error", event="seat_failed",
message=f"席位「{seat.name}」本轮执行异常(已跳过,不影响同批其它席位)",
agent_id=seat.agent_id, agent_name=seat.name, cycle=_cyc,
)
deliveries[seat.agent_id] = ""
await progress.add_seat_dialogue(seat.agent_id, seat.name, "", _cyc, thread_id=thread_ids.get(seat.agent_id))
await progress.seat_done(seat.agent_id, _cyc)
return
await _emit_switches(progress, seat_turn.switches, agent_name=seat.name, cycle=_cyc, agent_id=seat.agent_id)
deliveries[seat.agent_id] = seat_turn.final_text deliveries[seat.agent_id] = seat_turn.final_text
if not (seat_turn.final_text or "").strip(): if not (seat_turn.final_text or "").strip():
await progress.diag( await progress.diag(
stage="step2_seat", level="warning", event="seat_empty_delivery", stage="step2_seat", level="warning", event="seat_empty_delivery",
message=f"席位「{seat.name}」本轮交付为空(可能模型未产出或被截断)", message=f"席位「{seat.name}」本轮交付为空(可能模型未产出或被截断)",
agent_id=seat.agent_id, agent_name=seat.name, cycle=i + 1, agent_id=seat.agent_id, agent_name=seat.name, cycle=_cyc,
) )
await progress.add_seat_dialogue( await progress.add_seat_dialogue(
seat.agent_id, seat.name, seat_turn.final_text, i + 1, seat.agent_id, seat.name, seat_turn.final_text, _cyc,
tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id), tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id),
) )
await progress.seat_done(seat.agent_id, i + 1) await progress.seat_done(seat.agent_id, _cyc)
await asyncio.gather(*(_run_one(seat) for seat in stage_seats)) await asyncio.gather(*(_run_one(seat) for seat in stage_seats))
@@ -462,6 +645,7 @@ async def _run_dag(
message=closeout_msg, message=closeout_msg,
model=model, model=model,
) )
await _emit_switches(progress, closeout.switches, agent_name="总控", cycle=total + 1)
if closeout.final_text: if closeout.final_text:
last_leader = closeout.final_text last_leader = closeout.final_text
await progress.add_leader_dialogue( await progress.add_leader_dialogue(
@@ -484,6 +668,7 @@ async def _run_step3(
consensus_text: str, consensus_text: str,
deliveries: dict[str, str], deliveries: dict[str, str],
coordinator_prompt: str | None = None, coordinator_prompt: str | None = None,
business_code: str | None = None,
) -> dict | None: ) -> dict | None:
"""Step3 双产物:「方案总结报告」(roundtable-summary 写 md)与「结果绘制」 """Step3 双产物:「方案总结报告」(roundtable-summary 写 md)与「结果绘制」
(roundtable-report 画 HTML)**并行执行**(两段无依赖,2026-06-13 由串行改并行, (roundtable-report 画 HTML)**并行执行**(两段无依赖,2026-06-13 由串行改并行,
@@ -504,11 +689,13 @@ async def _run_step3(
await progress.summary_writing() await progress.summary_writing()
summary_thread_id = await gateway.create_summary_thread() summary_thread_id = await gateway.create_summary_thread()
summary_prompt = build_summary_prompt( summary_prompt = build_summary_prompt(
intent_text, agents, deliveries, consensus_text, coordinator_prompt=coordinator_prompt intent_text, agents, deliveries, consensus_text,
coordinator_prompt=coordinator_prompt, business_code=business_code,
) )
summary: SummaryResult = await gateway.run_summary( summary: SummaryResult = await gateway.run_summary(
summary_thread_id=summary_thread_id, prompt=summary_prompt, model=model summary_thread_id=summary_thread_id, prompt=summary_prompt, model=model
) )
await _emit_switches(progress, getattr(summary, "switches", None), agent_name="方案总结报告")
if (summary.md or "").strip() or (summary.closing or "").strip(): if (summary.md or "").strip() or (summary.closing or "").strip():
step3["summaryReport"] = { step3["summaryReport"] = {
"md": summary.md or "", "md": summary.md or "",
@@ -592,6 +779,7 @@ async def _run_report(
report_thread_id = await gateway.create_report_thread() report_thread_id = await gateway.create_report_thread()
prompt = build_report_prompt(intent_text, agents, deliveries, consensus_text) prompt = build_report_prompt(intent_text, agents, deliveries, consensus_text)
report: ReportResult = await gateway.run_report(report_thread_id=report_thread_id, prompt=prompt, model=model) report: ReportResult = await gateway.run_report(report_thread_id=report_thread_id, prompt=prompt, model=model)
await _emit_switches(progress, getattr(report, "switches", None), agent_name="结果绘制")
return { return {
"html": report.html, "html": report.html,
"generatedAt": report.generated_at or datetime.now(UTC).isoformat(), "generatedAt": report.generated_at or datetime.now(UTC).isoformat(),
@@ -659,6 +847,7 @@ def build_summary_prompt(
consensus_text: str, consensus_text: str,
*, *,
coordinator_prompt: str | None = None, coordinator_prompt: str | None = None,
business_code: str | None = None,
) -> str: ) -> str:
"""拼装 Step3「方案总结报告」prompt —— 对齐前端 ``useStep3Summary.buildSummaryPrompt`` """拼装 Step3「方案总结报告」prompt —— 对齐前端 ``useStep3Summary.buildSummaryPrompt``
(非流程图分支)+ ``buildMaterials`` 的原料结构,使后台产出与手动调用一致。""" (非流程图分支)+ ``buildMaterials`` 的原料结构,使后台产出与手动调用一致。"""
@@ -693,6 +882,22 @@ def build_summary_prompt(
lines.append("# 三、总控最终共识结论") lines.append("# 三、总控最终共识结论")
lines.append((consensus_text or "").strip() or "(未提供)") lines.append((consensus_text or "").strip() or "(未提供)")
lines.append("") lines.append("")
# 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)。即便部署里的 SOUL 未重新 seed,
# 这段也保证后台总结会主动扣用户意图、标注推断/缺口,而非简单拼接各席位交付。
lines.append(
"【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。"
"你是这份报告的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)"
"拆成用户的核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、"
"有无跑题或矛盾;再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→**绝不凭空编**。"
"**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。"
"**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分 / 哪些不足 / 有哪些可补充(可在对话里让我补全);"
"**即便核对下来都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**"
"最终报告要明显比「各席位交付的简单拼接」更扣用户意图。"
)
# 深链接业务规范:写实业务(如 rwfx)额外注入「业务链完整性核对」,强制核对产出覆盖了每一层级。
biz_directive = build_summary_directive(business_code)
if biz_directive:
lines.append(biz_directive)
lines.append( lines.append(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" "请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。"
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" "报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);"
@@ -0,0 +1,228 @@
"""按 businessCode 的「业务链抽取规范」——深链接会商模式下强制注入 summary / 结构化抽取。
背景:结构化抽取(flow_extract,roundtable-structure 智能体)和方案总结报告(roundtable-summary)
的层级该怎么抽/怎么核对,是**每个业务链各不相同**的领域知识;但智能体 SOUL 只有一份、全局共用,
写死某一套层级就会对所有业务套同一套(典型 bug:无论什么业务都按「目的→行为体」抽、停在行为体)。
解法:把每个业务的**完整层级 + 层间关系 + 节点命名 + 取数优先**做成这里的一份规范,**单份领域知识、
两个角色用法**:
- ``build_structure_directive``:给结构化抽取——按完整层级抽全、层间 1:1/1:N、节点命名、取数优先;
- ``build_summary_directive``:给方案总结报告——核对产出相对业务链完不完整。
**仅深链接模式(business_code 非空)注入**;普通手动会商 business_code 为空 → 返回 ""(零副作用)。
rwfx(6BF) 先写实,其余业务(3Q/7BF/8BF)暂用通用规范(仍强约束完整性 / 命名 / 取数,等真实层级再补)。
放在 harness 层(``deerflow.*``):app 层(flow_extract / multi_agent)与 harness 层(engine
后台 build_summary_prompt)都要用它,故置于此(app 可 import harness,harness 不可 import app)。
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ChainSpec:
"""一个业务链的抽取规范。"""
code: str # 规范的规范化业务码(如 6BF)
label: str # 人类可读业务名(如 rwfx)
levels: list[str] # 自上而下的层级名(线性链=逐层;分叉/汇聚的复杂链=各类节点自上而下的粗略顺序)
relations_note: str # 层间关系说明(1:1 / 1:N / 多对一 / 分叉汇聚,复杂结构全写在这里)
completeness_note: str = "" # 「完整性」要求行;空 → 用通用「以上 N 层都要出节点」
extra_note: str = "" # 该业务额外补充(可空)
# 是否为**纯树**业务链:每个非根节点恰有一个父节点(全链 1:N + 1:1,绝无多对一/多对多/汇聚)。
# 为真时后端结构化抽取会**确定性兜底**把每个节点收敛到单一父节点(见 flow_extract._collapse_to_single_parent),
# 杜绝弱模型违反 1:N 画出乱线/汇聚。rwfx=True;xdfx 有「脆弱点→策略」多对一汇聚,为 False。
single_parent: bool = False
# 节点命名 / 取数优先:各业务通用,单独抽出避免重复。
_NAMING_NOTE = (
"节点 label 必须用该层级下的**具体实体 / 概念短名**(≤12 字),"
"**绝不能含「步骤 / 阶段 / 环节 / 第N步 / 阶段①②③」等流程或编号词**,也不要拿层级名本身当 label。"
)
_SOURCE_NOTE = (
"**严格以《方案总结报告》及其文件内容为准**逐条抽取、勿改写、勿精简理由(报告是各席位交付经总控综合提炼、"
"**已做取舍**后的最终结论)。**《各席位原始交付产物》仅作兜底**:只有当报告某层级**整层缺失、或明确说有 N 条却没逐条列出**时,"
"才回席位产物把那一层补全;**绝不要用席位产物去扩充报告已取舍过的层级**——报告淘汰 / 未采用的候选一律不抽。与报告冲突时以报告为准。"
)
# 「按报告逐条抽全、不漏也不多」—— 本次最关键的准确性约束(治「报告列 3 条却只抽 1 条」与「报告只采用 3 条却抽出一堆候选」)。
# 业务无关措辞(不出现任何具体业务的层级名),通用规范与写实规范都复用它。
_COMPLETENESS_NOTE = (
"**按报告逐条抽全、不漏也不多(最重要,必须做到)**:\n"
" · 报告某层级用表格 / 列表 / 「①②③」逐条列出的条目,**每条都要各自成一个独立节点、一条都不能漏**"
"(不要只抽被标「确定 / 第1名 / 选用」的那一条而漏掉报告同列展示的其余条目);\n"
" · 某个上层节点名下若列了多条下层条目(同一上层名下罗列了好几条),"
"**报告里有几条就出几个节点,绝对不许合并 / 概括 / 只取代表性的一条**;\n"
" · ⚠️ **但绝不要无中生有 / 多抽**:报告**已取舍、最终只采用其中几条**的层级(如报告从若干候选里只锁定了最终几个目的、"
"或只选定了最终方案),**只抽报告最终采用的那几条**——**不要去席位产物、也不要去报告的「候选池 / 被淘汰项」"
"里把没被采用的候选也抽出来凑数**(这是要重点纠正的「抽出一堆目的」错);\n"
" · **不要只照搬报告结尾「方案总纲 / 最终框架 / 共识方案」里被精简的主干链**(那里通常只画被选中的一条主线)——"
"要回到正文把报告该层级列出的**全部条目**抽全。"
)
# ── rwfx(6BF):六步法完整链(线性) ─────────────────────────────────────────
_RWFX = ChainSpec(
code="6BF",
label="rwfx",
levels=["任务", "目的", "行为体", "预期行为", "驱动因素", "叙事"],
relations_note=(
"层间关系(务必分清一对一 / 一对多,连线别画反):\n"
"① **「预期行为」与「驱动因素」严格一对一(1:1,本次必须重点纠正的错)**:\n"
" 为**每一条**预期行为,归纳出驱动它的**那一个**核心驱动因素,两两配成一对——"
"两层节点数**必须完全相等**(有 N 条预期行为就出 N 个驱动因素),按相同位次一一对应;\n"
" 每个驱动因素的 from **只能写它对应的那一条预期行为**;**严禁多对一**:"
"不许多条预期行为汇聚连到同一个驱动因素,也不许一条预期行为被多个驱动因素指向——"
"**这两层里任何一个节点都不能冒出 / 汇入多条连线**(每条预期行为只伸出一条线连到它唯一的驱动因素);\n"
" ⚠️ 数据来源常把某行为体的多个驱动因素**合并写在一处**(一个表格单元格里用「+」「、」连接、或「表层触发→深层」一整段、"
"或只在第一条预期行为那行写驱动因素、其余行留空)——**这种情况绝不能抽成「一个驱动因素节点连到多条预期行为」**,"
"而要**按预期行为把驱动因素拆开 / 重新归纳**:给每条预期行为各配一个驱动因素节点(其内容综合提炼该条预期行为背后最核心的那个原因),"
"拆分 / 提炼后务必让两层等量、严格一一对应;\n"
"② **「驱动因素」→「叙事」是一对多**(一个驱动因素下可以挂多条叙事,一条叙事只属于一个驱动因素)。"
"**⚠️ 叙事条数严格按报告 / 叙事席位实际写出的来——写了几条就抽几条,绝不要为「让每个驱动因素都配一条」而凭空造、复制、拆分出更多叙事**"
"(这是「叙事凭空多出来一堆」的错);每条叙事的 from **只连它所属的那一个驱动因素**(叙事的上一层是驱动因素);"
"**绝不能把多个驱动因素汇聚连到同一条叙事**(多对一,rwfx 链不存在),**一条叙事只能有一个父节点**;也不要把全部叙事合并成一个节点;\n"
"③ 其余相邻层级(任务→目的、目的→行为体、行为体→预期行为)均为**一对多**(上层一条可带下层多条,下层每条连其所属上层条目);\n"
"④ **全链是一棵树:除根「任务」外,每个节点有且仅有一个父节点(一个 from)**——任何节点都不会从多个上层节点汇入(无多对一 / 多对多 / 汇聚)。"
),
completeness_note=(
"以上 6 个层级都要出节点,且**每层条数完全由报告实际呈现决定、严格抽全——不漏也不多**"
"(各席位交付条数本就不固定,绝不要假设任何固定数量、也不要按上一层数量去硬凑下一层):\n"
" · **目的**:抽报告**最终采用 / 锁定的那几个目的**(报告锁定几个就抽几个)。报告若说「从若干候选里只锁定最终几个」就"
"**只抽报告最终采用的、绝不要把被淘汰的候选也抽进来**(这是「抽出一堆目的」的错);报告把目的并列成表展示,就把表里列出的都抽全、别只抽第 1 个;\n"
" · **行为体 / 预期行为**:每个行为体名下报告列了几条预期行为就出几个预期行为节点(有几条出几条),**不许把多条合并成一条、也不要凭空多造**;\n"
" · **驱动因素(1:1 特例,按上面①处理)**:驱动因素层节点数 **= 预期行为层节点数**——每条预期行为各配一个核心驱动因素,"
"**绝不能因为报告把多个原因挤在一处 / 留空,就只抽 1 个驱动因素去连多条预期行为(多对一,本次最严重的错)**,也不要抽得比预期行为多;务必两层等量、位次一一配对;\n"
" · **叙事**:抽报告 / 叙事席位**实际写出的那几条叙事**(写了几条就抽几条),全部抽、但**一条都不要多造**——"
"**绝不要为「每个驱动因素都配一条叙事」而把实际的少数几条叙事扩成一堆**(「叙事凭空多出来一堆」的错);"
"每条叙事**单 from** 连它所属的那一个驱动因素,绝不要连多个驱动因素、也不要让多个驱动因素指向同一叙事。"
),
single_parent=True, # rwfx 是纯树:每个非根节点恰一个父节点(后端确定性兜底收敛)。
)
# ── xdfx(7BF):分叉汇聚 DAG(非线性,根为「行动」) ──────────────────────────
_XDFX = ChainSpec(
code="7BF",
label="xdfx",
levels=["行动", "受众目标 / 关键传播节点", "脆弱点", "策略"],
relations_note=(
"这是一张**分叉汇聚**的图(不是单链),层间关系如下:\n"
"① **根节点 = 用户传入的「行动」**(不是「任务」);\n"
"② 「行动」下分**两类并列分支**——「受众目标」与「关键传播节点」(行动→受众目标 一对多、"
"行动→关键传播节点 一对多);\n"
"③ 「受众目标」和「关键传播节点」**各自都挂多个「脆弱点」**(受众目标→脆弱点 一对多、"
"关键传播节点→脆弱点 一对多);\n"
"④ 「策略」由「脆弱点」**汇聚而来、是多对一**:一个策略可对应「某个受众目标的全部或部分脆弱点」"
"「多个受众目标各自的全部或部分脆弱点」(关键传播节点侧同理)——即多个脆弱点(**可跨多个受众目标"
"或多个关键传播节点**)汇聚到一个策略;策略节点的 from 要连到它实际汇聚的那些脆弱点(可能多个、可跨分支)。"
),
completeness_note=(
"行动 / 受众目标 / 关键传播节点 / 脆弱点 / 策略 这几类节点都要出全;每个脆弱点都要 from 连到它所属的"
"那个受众目标或关键传播节点;策略按上面的多对一关系,from 连到它汇聚的全部脆弱点。"
),
)
# 规范化业务码 → 规范。同时收录人类可读别名(rwfx/xdfx)便于前端任意传。
_SPECS: dict[str, ChainSpec] = {
"6BF": _RWFX,
"RWFX": _RWFX,
"7BF": _XDFX,
"XDFX": _XDFX,
}
def _normalize(code: str | None) -> str:
"""规范化业务码:大写、去首尾空白、去掉多智能体 ``-A`` 后缀(rwfx-A → RWFX)。"""
c = (code or "").strip().upper()
if c.endswith("-A"):
c = c[:-2]
return c
def get_chain_spec(business_code: str | None) -> ChainSpec | None:
"""取某业务的写实规范;无 business_code 或未写实 → None。"""
norm = _normalize(business_code)
if not norm:
return None
return _SPECS.get(norm)
def is_single_parent_chain(business_code: str | None) -> bool:
"""该业务链是否为**纯树**(每个非根节点恰一个父节点,无多对一/多对多/汇聚)。
为真时结构化抽取会确定性兜底把每个节点收敛到单一父节点。rwfx=True;xdfx(有汇聚)/
未写实业务=False(保守,不强行收敛)。
"""
spec = get_chain_spec(business_code)
return bool(spec and spec.single_parent)
def chain_level_rank(business_code: str | None) -> dict[str, int]:
"""该业务链的「层级名 → 自上而下序号」映射(用于确定性兜底判断「上一层」)。无写实规范 → 空。"""
spec = get_chain_spec(business_code)
if spec is None:
return {}
return {name: i for i, name in enumerate(spec.levels)}
def build_structure_directive(business_code: str | None) -> str:
"""结构化抽取(flow-json)用的强制层级规范。
- 无 business_code(非深链接 / 普通会商)→ ""(不注入,保持现状);
- 有 business_code 但未写实(3Q/7BF/8BF…)→ 通用规范:强约束完整性 + 命名 + 取数,且明确**不要套用任何
固定的「目的→行为体」式模板**(counter 掉 SOUL 里可能残留的写死层级);
- 写实业务(rwfx)→ 完整层级 + 层间关系 + 命名 + 取数 + 完整性硬约束。
"""
norm = _normalize(business_code)
if not norm:
return ""
spec = _SPECS.get(norm)
if spec is None:
return (
"【本业务链抽取规范(强制遵守,优先级高于系统提示里的任何默认/示例层级)】\n"
"- 本业务**没有固定层级模板**:请严格按《方案总结报告》/ 各席位交付里**真实出现的层级**,"
"自「任务」为根逐层完整展开,每层每一条都出成节点,不要只抽开头一两层就停;\n"
"- **不要套用「目的→行为体」之类的固定示例层级**——层级名一律取自本次报告 / 交付里的真实层级;\n"
f"- {_COMPLETENESS_NOTE}\n- {_NAMING_NOTE}\n- {_SOURCE_NOTE}"
)
arrows = " → ".join(spec.levels)
completeness = spec.completeness_note or f"以上 {len(spec.levels)} 个层级都要出节点,不得缺层。"
lines = [
f"【{spec.label} 业务链抽取规范(强制遵守,优先级高于系统提示里的任何默认/示例层级)】",
f"- **完整层级**(自上而下,必须逐层抽全、不得缺层、不得停在中间层):{arrows};",
f"- {spec.relations_note}",
f"- {_COMPLETENESS_NOTE}",
f"- {_NAMING_NOTE}",
f"- {_SOURCE_NOTE}",
f"- **完整性**:{completeness}",
]
if spec.extra_note:
lines.append(f"- {spec.extra_note}")
return "\n".join(lines)
def build_summary_directive(business_code: str | None) -> str:
"""方案总结报告用的「业务链完整性核对」规范。
无 business_code 或未写实 → ""(普通会商 / 未知业务不强加完整性核对)。写实业务 → 注入完整层级,
要求 summary 核对产出是否覆盖了每一层级、缺层按「待补充」护栏标出。
"""
spec = get_chain_spec(business_code)
if spec is None:
return ""
arrows = " → ".join(spec.levels)
return (
f"【{spec.label} 业务链完整性核对(强制)】本次为 {spec.label} 业务场景,其完整业务链层级为:{arrows}。\n"
f"{spec.relations_note}\n"
"撰写报告时请**额外核对各席位交付 + 共识是否完整覆盖了上述每一个层级**:"
"报告文件本身按现有信息给出尽量完整的版本(**不要在 .md 文件里写「待补充」之类提示**);\n"
"⚠️ **呈现「一对一(1:1)」相邻层级时(如预期行为↔驱动因素)务必逐条配对**:每一条上层条目单独成一行、"
"紧跟它**唯一对应的那一条**下层条目(一行一对、数量相等);**绝对不要把下层的多个条目用「+」「、」合并塞进一个单元格、"
"也不要只在第一行写、其余行留空**——那会让后续结构化抽取把它误画成「多条上层汇聚到一个下层」(多对一)。每条上层条目都要有它自己专属的那一条下层条目。\n"
"并在**对话回复正文、报告之后的「核对说明」里逐层说明各层级是否覆盖、缺了哪层 / 哪层条目不足**"
"(即便各层级都已覆盖,也要写一句「各层级均已覆盖」),可在对话里让我补全;这段核对只在对话里、不写进 .md 文件,不要跳层。"
)
@@ -270,3 +270,27 @@ class JobProgressWriter:
self._add("seat", agent_id, name, content or "", cycle, tool_calls=tool_calls, thread_id=thread_id) self._add("seat", agent_id, name, content or "", cycle, tool_calls=tool_calls, thread_id=thread_id)
await self._persist() await self._persist()
await self._flush_dialogue() await self._flush_dialogue()
async def model_switch(
self,
*,
agent_name: str,
failed_model: str,
next_model: str,
reason_label: str,
cycle: int = 0,
agent_id: str | None = None,
) -> None:
"""模型自动容错通知:当前模型出问题、已自动切到下一个模型时调用。
往研讨时间线插一条 ``role="system"`` 系统提示气泡(前端 ``toStep2Dialogue`` 据此渲染
为「系统提示」)。对应的 ``model_switch`` **诊断**由网关
(``InProcessRoundtableGateway._run_turn_with_fallback``)在换模型当下落库(含完整
detail),此处只负责时间线展示,避免重复落诊断。``reason_label`` 由调用方(app 层)
预先翻译好(harness 不依赖 app 的文案表)。"""
fm = (failed_model or "").strip() or "当前模型"
nm = (next_model or "").strip() or "下一个模型"
content = f"⚠️ 模型容错:「{agent_name}」所用模型「{fm}」{reason_label},已自动切换到「{nm}」继续。"
self._add("system", agent_id, "系统提示", content, cycle)
await self._persist()
await self._flush_dialogue()
@@ -43,6 +43,20 @@ class SeatRef:
name: str name: str
@dataclass
class ModelSwitch:
"""一次「模型自动容错」记录:当前模型出问题、已切到下一个模型继续。
由网关(单轮执行器)在换模型时产出,挂在各 Turn/Result 上带回引擎;引擎据此调
``JobProgressWriter.model_switch(...)`` 往研讨时间线插系统提示并落诊断。``reason_label``
已由 app 层翻译成简短中文(harness 不依赖 app 文案表)。
"""
failed_model: str
next_model: str
reason_label: str
@dataclass @dataclass
class LeaderTurn: class LeaderTurn:
"""总控一轮执行的归一化结果(由 gateway.run_leader 产出)。""" """总控一轮执行的归一化结果(由 gateway.run_leader 产出)。"""
@@ -55,6 +69,8 @@ class LeaderTurn:
# 本轮总控调用的工具(按出现顺序,每项 {name, args, id})——通常是 agent_orchestration # 本轮总控调用的工具(按出现顺序,每项 {name, args, id})——通常是 agent_orchestration
# 派活 / ask_clarification。用于在草稿 run 里重建「工具步骤卡」,与前端手动跑一致。 # 派活 / ask_clarification。用于在草稿 run 里重建「工具步骤卡」,与前端手动跑一致。
tool_calls: list[dict] = field(default_factory=list) tool_calls: list[dict] = field(default_factory=list)
# 本轮发生的模型自动容错记录(按发生顺序);引擎据此向用户提示「已切换模型」。
switches: list[ModelSwitch] = field(default_factory=list)
@property @property
def is_clarification(self) -> bool: def is_clarification(self) -> bool:
@@ -73,6 +89,8 @@ class SeatTurn:
# 本轮席位调用的工具(按出现顺序,每项 {name, args, id})——用于在草稿 run 里 # 本轮席位调用的工具(按出现顺序,每项 {name, args, id})——用于在草稿 run 里
# 重建「工具步骤卡」+ write_file stub(点开产物),与前端手动跑一致。 # 重建「工具步骤卡」+ write_file stub(点开产物),与前端手动跑一致。
tool_calls: list[dict] = field(default_factory=list) tool_calls: list[dict] = field(default_factory=list)
# 本轮发生的模型自动容错记录(按发生顺序)。
switches: list[ModelSwitch] = field(default_factory=list)
@dataclass @dataclass
@@ -83,6 +101,7 @@ class ReportResult:
summary: str | None = None summary: str | None = None
model: str | None = None model: str | None = None
generated_at: str | None = None generated_at: str | None = None
switches: list[ModelSwitch] = field(default_factory=list)
@dataclass @dataclass
@@ -99,6 +118,7 @@ class SummaryResult:
model: str | None = None model: str | None = None
thread_id: str | None = None thread_id: str | None = None
generated_at: str | None = None generated_at: str | None = None
switches: list[ModelSwitch] = field(default_factory=list)
# ── 网关协议 ──────────────────────────────────────────────────────────────── # ── 网关协议 ────────────────────────────────────────────────────────────────
@@ -328,6 +348,13 @@ def build_dag_stage_leader_task(
lines += [ lines += [
f"【本阶段目标】{goal_text}", f"【本阶段目标】{goal_text}",
"请据此目标为每个席位拆解任务,并要求他们显式承接上一阶段的产出来完成本阶段目标。", "请据此目标为每个席位拆解任务,并要求他们显式承接上一阶段的产出来完成本阶段目标。",
"⚠️ 派活时**严格忠实于本阶段目标,不要自行新增目标里没有的「数量上限 / 只取最有代表性的 N 组 / 精简归纳成几条」等限制**,"
"也不要照搬其它阶段的数量(如上一阶段筛了 3 组,不代表本阶段也只要 3 组);"
"目标没要求筛选 / 限定数量时,就要求席位**按其职责把每一条都完整输出**——"
"若上一阶段有 N 条产出需逐条处理,就让席位**逐条、全部**产出(有多少条就出多少条),绝不替席位归纳 / 合并 / 只取代表性的几条。",
"⚠️ 也**不要替席位新增目标 / 席位职责之外的「输出体例 / 风格」要求**(如『写成连贯故事 / 要有场景感 / "
"举具体城市景区为例 / 虚构时间地点情节』等)——派活只需转达本阶段目标 + 上一阶段产物 + 承接关系,"
"**让席位严格按它自己的职责设定(SOUL)输出**,且**忠实基于上一阶段的真实产物做分析,不要诱导席位脱离输入去编造/ 虚构事例**。",
] ]
lines += [ lines += [
f"请你作为总控,通过 agent_orchestration 技能**分别**向上述{'这些席位' if is_parallel else '席位'}派活," f"请你作为总控,通过 agent_orchestration 技能**分别**向上述{'这些席位' if is_parallel else '席位'}派活,"
@@ -103,6 +103,12 @@ class AgentConfig(BaseModel):
# 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的 # 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
# Markdown 产物可一键发布到外部入库接口;None/空 = 不支持入库。 # Markdown 产物可一键发布到外部入库接口;None/空 = 不支持入库。
template_name: str | None = None template_name: str | None = None
# 会商席位「技能识别强化」开关(弱模型补偿)。作为会商席位运行时,是否把本智能体
# 配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其流程取信息的硬指令)显式
# 拼进本轮任务,避免弱模型无视系统提示里的技能块。None/False = 关(默认);True = 开。
# 与「业务链条配置」里的同名开关取 OR:任一打开即对该席位生效。见
# ``app/gateway/roundtable_seat_skills.py``。
seat_skill_directive: bool | None = None
def load_agent_config(agent_id: str | None) -> AgentConfig | None: def load_agent_config(agent_id: str | None) -> AgentConfig | None:
@@ -0,0 +1,54 @@
"""Add gather_first column to roundtable_chains.
Per-chain「研讨前并行取数」opt-in switch: when true, selecting the chain into a roundtable
discussion first runs one **parallel data-gathering round** over all seats (each seat only
calls its skills to fetch facts, no deliberation); the collected material is injected as
shared context into the discussion rounds, front-loading the slow skill latency in parallel
so the (possibly serial) discussion phase runs faster. Default false → existing behavior
unchanged. BOOLEAN NOT NULL DEFAULT 0, matching ``RoundtableChainRow.gather_first``.
``create_all`` adds the column for brand-new DBs; the startup ``_ensure_orm_columns_sync``
sweep adds it to already-existing tables (sqlite/mysql/postgres). This revision keeps the
alembic graph consistent with the rest of the chain columns and documents the change.
Revision ID: 20260620_02
Revises: 20260619_01
Create Date: 2026-06-20
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260620_02"
down_revision = "20260619_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "gather_first"):
op.add_column(
"roundtable_chains",
sa.Column("gather_first", sa.Boolean(), nullable=False, server_default="0"),
)
def downgrade() -> None:
if _has_column("roundtable_chains", "gather_first"):
op.drop_column("roundtable_chains", "gather_first")
@@ -52,6 +52,12 @@ class RoundtableChainRow(Base):
# 可选「默认席位执行模式」:flash/thinking/pro/ultra(见前端 lib/seat-mode.ts)。选入研讨时 # 可选「默认席位执行模式」:flash/thinking/pro/ultra(见前端 lib/seat-mode.ts)。选入研讨时
# 弹窗默认用它(仍可临时改)。NULL = 未设,调用方回退默认(pro)。短串,VARCHAR(16)。 # 弹窗默认用它(仍可临时改)。NULL = 未设,调用方回退默认(pro)。短串,VARCHAR(16)。
seat_mode: Mapped[str | None] = mapped_column(String(16), nullable=True) seat_mode: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 可选「研讨前并行取数」开关:True = 选入研讨时,研讨前先跑一轮全席位并行『取数』(各席位只调
# 技能取数、不研讨),产出作为共享资料注入后续研讨轮,把最慢的技能延迟前置并行、给串行研讨段提速。
# 默认 False(不变更现有行为)。逐条 opt-in。BOOLEAN,server_default "0"。
gather_first: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0"
)
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。 # 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。 # 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column( is_public: Mapped[bool] = mapped_column(
@@ -58,6 +58,8 @@ class RoundtableChainRepository:
"coordinator_prompt": getattr(row, "coordinator_prompt", None), "coordinator_prompt": getattr(row, "coordinator_prompt", None),
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设。 # 默认席位执行模式(flash/thinking/pro/ultra);None = 未设。
"seat_mode": getattr(row, "seat_mode", None), "seat_mode": getattr(row, "seat_mode", None),
# 研讨前并行取数开关(逐条 opt-in);缺省 False。
"gather_first": bool(getattr(row, "gather_first", False)),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。 # 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)), "is_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id), "is_mine": (current_user_id is None) or (row.user_id == current_user_id),
@@ -113,6 +115,7 @@ class RoundtableChainRepository:
stage_goals=_dumps(data.get("stage_goals")), stage_goals=_dumps(data.get("stage_goals")),
coordinator_prompt=(data.get("coordinator_prompt") or None), coordinator_prompt=(data.get("coordinator_prompt") or None),
seat_mode=(data.get("seat_mode") or None), seat_mode=(data.get("seat_mode") or None),
gather_first=bool(data.get("gather_first") or False),
is_public=bool(data.get("is_public") or False), is_public=bool(data.get("is_public") or False),
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -135,6 +138,7 @@ class RoundtableChainRepository:
stage_goals=_UNSET, stage_goals=_UNSET,
coordinator_prompt=_UNSET, coordinator_prompt=_UNSET,
seat_mode=_UNSET, seat_mode=_UNSET,
gather_first=_UNSET,
is_public=_UNSET, is_public=_UNSET,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
async with self._sf() as session: async with self._sf() as session:
@@ -155,6 +159,8 @@ class RoundtableChainRepository:
row.coordinator_prompt = coordinator_prompt or None row.coordinator_prompt = coordinator_prompt or None
if seat_mode is not _UNSET: if seat_mode is not _UNSET:
row.seat_mode = seat_mode or None row.seat_mode = seat_mode or None
if gather_first is not _UNSET and gather_first is not None:
row.gather_first = bool(gather_first)
if is_public is not _UNSET and is_public is not None: if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public) row.is_public = bool(is_public)
row.updated_at = datetime.now(UTC) row.updated_at = datetime.now(UTC)
@@ -89,6 +89,11 @@ CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT N
-- 选入研讨时弹窗默认用它(仍可临时改)。ORM: RoundtableChainRow.seat_mode = String(16), nullable。 -- 选入研讨时弹窗默认用它(仍可临时改)。ORM: RoundtableChainRow.seat_mode = String(16), nullable。
CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL DEFAULT NULL'); CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL DEFAULT NULL');
-- gather_first: 业务链条「研讨前并行取数」开关,对应 migration 20260620_02。True=选入研讨时研讨前先
-- 跑一轮全席位并行取数(只调技能、不研讨),产出作为共享资料注入研讨轮。逐条 opt-in。
-- ORM: RoundtableChainRow.gather_first = Boolean, NOT NULL, server_default '0'。
CALL _add_column_if_missing('roundtable_chains', 'gather_first', 'TINYINT(1) NOT NULL DEFAULT 0');
-- ---- 岗位 (positions) 订阅系统,对应 migration 20260616_01 ------------------- -- ---- 岗位 (positions) 订阅系统,对应 migration 20260616_01 -------------------
-- positions / position_tag_bindings 是【新表】,由 create_all 自动建,无需补列。 -- positions / position_tag_bindings 是【新表】,由 create_all 自动建,无需补列。
-- 以下是给【已存在的表】补的列: -- 以下是给【已存在的表】补的列:
@@ -177,12 +177,12 @@ def test_load_config_extras_returns_values_written_to_yaml(isolated_paths) -> No
) )
extras = _load_config_extras(agent_id) extras = _load_config_extras(agent_id)
assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None} assert extras == {"skills": ["s1"], "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None: def test_load_config_extras_missing_directory_returns_none_triple(isolated_paths) -> None:
extras = _load_config_extras("does-not-exist") extras = _load_config_extras("does-not-exist")
assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None} assert extras == {"skills": None, "model": None, "tool_groups": None, "common_questions": None, "template_name": None, "seat_skill_directive": None}
def test_write_agent_config_persists_common_questions(isolated_paths) -> None: def test_write_agent_config_persists_common_questions(isolated_paths) -> None:
@@ -0,0 +1,78 @@
"""rwfx 结构化抽取「纯树·单父节点」确定性兜底测试。
rwfx 全链是一棵树(除预期行为↔驱动因素 1:1,其余皆 1:N),任何节点只有一个父节点、绝无
多对一/汇聚。弱模型常给「叙事」连多个驱动因素 → 乱线。``_collapse_to_single_parent`` 兜底把
每个节点的多父入边收敛到紧邻上一层的单一父节点。
"""
from __future__ import annotations
from app.gateway.routers.flow_extract import _collapse_to_single_parent
from deerflow.agents.roundtable_orchestrator.extraction_spec import (
chain_level_rank,
is_single_parent_chain,
)
def test_is_single_parent_chain() -> None:
assert is_single_parent_chain("6BF") is True
assert is_single_parent_chain("rwfx") is True
assert is_single_parent_chain("rwfx-A") is True # 多智能体 -A 后缀也认
assert is_single_parent_chain("7BF") is False # xdfx 有「脆弱点→策略」汇聚,非纯树
assert is_single_parent_chain("3Q") is False # 未写实业务保守不收敛
assert is_single_parent_chain(None) is False
def test_chain_level_rank_rwfx() -> None:
rank = chain_level_rank("6BF")
assert rank == {"任务": 0, "目的": 1, "行为体": 2, "预期行为": 3, "驱动因素": 4, "叙事": 5}
def _rwfx_graph_with_bad_narrative() -> dict:
# 叙事 s1 错误地连了 2 个驱动因素(上一层) + 1 个行为体(更上层),应被收敛成单父。
return {
"title": "t",
"nodes": [
{"id": "n1", "group": "任务"},
{"id": "m1", "group": "目的"},
{"id": "a1", "group": "行为体"},
{"id": "b1", "group": "预期行为"},
{"id": "b2", "group": "预期行为"},
{"id": "d1", "group": "驱动因素"},
{"id": "d2", "group": "驱动因素"},
{"id": "s1", "group": "叙事"},
],
"edges": [
{"from": "n1", "to": "m1"},
{"from": "m1", "to": "a1"},
{"from": "a1", "to": "b1"},
{"from": "a1", "to": "b2"},
{"from": "b1", "to": "d1"},
{"from": "b2", "to": "d2"},
{"from": "d1", "to": "s1"}, # 叙事多父:驱动因素 d1
{"from": "d2", "to": "s1"}, # 叙事多父:驱动因素 d2
{"from": "a1", "to": "s1"}, # 叙事多父:行为体 a1(跨层)
],
}
def test_collapse_narrative_multi_parent_to_single() -> None:
out = _collapse_to_single_parent(_rwfx_graph_with_bad_narrative(), "6BF")
s1_parents = [e["from"] for e in out["edges"] if e["to"] == "s1"]
# 叙事只剩一个父;应保留紧邻上一层(驱动因素),不保留更上层的行为体;同层保留第一个。
assert s1_parents == ["d1"]
# 其余每个非根节点仍恰一个父(1:1 / 1:N 本就单父,不受影响)。
for nid in ("m1", "a1", "b1", "b2", "d1", "d2"):
assert len([e for e in out["edges"] if e["to"] == nid]) == 1
# 根节点无入边。
assert [e for e in out["edges"] if e["to"] == "n1"] == []
def test_collapse_noop_when_already_tree() -> None:
# 每个节点本就单父 → 边数不变、内容不变。
graph = {
"nodes": [{"id": "n1", "group": "任务"}, {"id": "m1", "group": "目的"}, {"id": "a1", "group": "行为体"}],
"edges": [{"from": "n1", "to": "m1"}, {"from": "m1", "to": "a1"}],
}
out = _collapse_to_single_parent(graph, "6BF")
assert out["edges"] == [{"from": "n1", "to": "m1"}, {"from": "m1", "to": "a1"}]
@@ -0,0 +1,107 @@
"""按 businessCode 的「业务链抽取规范」+ 其在 flow_extract / summary 的注入测试。
深链接会商模式下:结构化抽取(flow_extract)按业务完整层级抽全(根治"无论什么业务都按目的→行为体抽")、
方案总结报告按业务链完整性核对。规范单份、两角色用法;仅 business_code 非空时注入。
"""
from __future__ import annotations
from deerflow.agents.roundtable_orchestrator.extraction_spec import (
build_structure_directive,
build_summary_directive,
get_chain_spec,
)
# ── 规范本身 ──────────────────────────────────────────────────────────────────
def test_rwfx_structure_directive_has_full_six_levels_and_relations():
out = build_structure_directive("6BF")
for level in ("任务", "目的", "行为体", "预期行为", "驱动因素", "叙事"):
assert level in out
assert "一一对应" in out # 预期行为↔驱动因素 1:1
assert "一对多" in out
# 不含步骤/阶段名 的命名约束
assert "步骤" in out and "阶段" in out
assert "优先" in out and "方案总结报告" in out # 取数优先
def test_xdfx_structure_directive_is_branching_dag_with_many_to_one():
out = build_structure_directive("7BF")
for node in ("行动", "受众目标", "关键传播节点", "脆弱点", "策略"):
assert node in out, node
assert "分叉汇聚" in out
assert "多对一" in out
# 根是「行动」而非「任务」
assert "根节点 = 用户传入的「行动」" in out
# 7BF / xdfx / xdfx-A 都命中同一规范
for code in ("7BF", "xdfx", "XDFX-A"):
assert get_chain_spec(code) is not None and get_chain_spec(code).label == "xdfx", code
def test_business_code_normalization_handles_suffix_and_case():
# rwfx-A / 小写 / 含 -A 后缀都应命中 rwfx 规范
for code in ("rwfx", "RWFX", "rwfx-A", "6bf", "6BF"):
spec = get_chain_spec(code)
assert spec is not None and spec.label == "rwfx", code
def test_unknown_business_gets_generic_directive_not_rwfx_template():
out = build_structure_directive("3Q")
assert out # 非空(深链接业务仍注入通用规范)
assert "不要套用" in out # 明确不要套固定模板
# 通用规范不应写死 rwfx 的「预期行为/驱动因素」层级
assert "预期行为" not in out
def test_no_business_code_returns_empty():
assert build_structure_directive("") == ""
assert build_structure_directive(None) == ""
assert build_summary_directive("") == ""
assert build_summary_directive(None) == ""
def test_rwfx_summary_directive_checks_completeness():
out = build_summary_directive("6BF")
assert "完整性核对" in out
assert "叙事" in out and "驱动因素" in out
assert "待补充" in out # 缺层走护栏标注
# ── flow_extract 层级提示注入(结构化抽取) ───────────────────────────────────
def test_flow_extract_hierarchy_hint_uses_spec_when_business_code():
from app.gateway.routers.flow_extract import FlowExtractRequest, _hierarchy_hint
req = FlowExtractRequest(report_md="x", business_code="6BF")
hint = _hierarchy_hint(req)
# 业务规范权威注入:含完整层级 + 覆盖默认假设的措辞
assert "预期行为" in hint and "驱动因素" in hint and "叙事" in hint
assert "强制遵守" in hint
# 无 business_code → 回退原行为(不含业务规范块)
req2 = FlowExtractRequest(report_md="x", chain_levels=["目的", "行为体"])
hint2 = _hierarchy_hint(req2)
assert "强制遵守" not in hint2
assert "目的" in hint2 # 仍用 chain_levels
# ── build_summary_prompt 业务完整性注入(方案总结报告) ────────────────────────
def test_build_summary_prompt_injects_business_completeness():
from deerflow.agents.roundtable_orchestrator.engine import build_summary_prompt
from deerflow.agents.roundtable_orchestrator.types import SeatRef
agents = [SeatRef("seat-a", "情报")]
out = build_summary_prompt(
"目标X", agents, {"seat-a": "交付"}, "共识", business_code="6BF"
)
assert "完整性核对" in out
assert "预期行为" in out and "驱动因素" in out
# 无 business_code → 不注入业务完整性块(但意图对齐块仍在)
out2 = build_summary_prompt("目标X", agents, {"seat-a": "交付"}, "共识")
assert "完整性核对" not in out2
assert "意图对齐与缺口核对" in out2
@@ -0,0 +1,140 @@
"""会商 Step2 性能改造引擎测试:研讨前并行『取数』阶段(gather_first)+ recommend 批内并行。
覆盖:
- gather_first=True:研讨前先对**全席位并行**跑一轮「只取数」,产出作为共享资料注入后续研讨轮席位任务;
- gather_first=False(默认):不跑取数阶段,行为不变;
- recommend 一轮派多席位 → 批内**并行**执行(_run_recommend 由串行改 asyncio.gather,峰值并发>1)。
引擎纯逻辑,用 JobProgressWriter(store=None) 干跑 + 录制网关,不需要 DB / 真模型。
"""
from __future__ import annotations
import asyncio
import pytest
from deerflow.agents.roundtable_orchestrator import (
JobProgressWriter,
SeatRef,
run_orchestration,
)
from deerflow.agents.roundtable_orchestrator.types import LeaderTurn, SeatTurn
_AGENTS = [SeatRef("seat-a", "情报"), SeatRef("seat-b", "环境"), SeatRef("seat-c", "方案")]
# _GATHER_TASK 里的稳定标记(取数阶段)/ _seat_message 里注入资料块的稳定标记(研讨阶段)。
_GATHER_MARK = "资料收集阶段"
_GATHERED_BLOCK_MARK = "研讨前各席位已并行收集"
def _writer() -> JobProgressWriter:
return JobProgressWriter(None, job_id="job-x", agents=_AGENTS)
def _thread_ids() -> dict[str, str]:
return {a.agent_id: f"tid-{a.agent_id}" for a in _AGENTS} | {"roundtable-coordinator": "tid-co"}
class _RecGateway:
"""录制网关:run_leader 按预设各轮派活;run_seat 记录 (agent_id, task) 并追踪峰值并发。"""
def __init__(self, dispatch_cycles: list[list[tuple[str, str]]]) -> None:
self._cycles = dispatch_cycles
self._leader_calls = 0
self.seat_tasks: list[tuple[str, str]] = []
self.active = 0
self.max_active = 0
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
i = self._leader_calls
self._leader_calls += 1
disp = self._cycles[i] if i < len(self._cycles) else []
return LeaderTurn(final_text=f"leader-{i}", dispatched=disp)
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
self.active += 1
self.max_active = max(self.max_active, self.active)
self.seat_tasks.append((agent_id, task))
await asyncio.sleep(0.01) # 留出并发重叠窗口
self.active -= 1
return SeatTurn(final_text=f"{agent_id} 的产出")
# Step3 关掉,不应被调用
async def create_report_thread(self) -> str: # pragma: no cover - 不应触发
raise AssertionError("enable_report=False 不应绘制报告")
async def _run(gw: _RecGateway, *, gather_first: bool):
return await run_orchestration(
gateway=gw,
progress=_writer(),
agents=_AGENTS,
thread_ids=_thread_ids(),
coordinator_name="roundtable-coordinator",
model=None,
seed_message="背景X",
mode="recommend",
intent_text="背景X",
enable_report=False,
gather_first=gather_first,
)
# 一轮派全部 3 席位 → 下一轮共识收口。
_DISPATCH_ALL_THEN_CONSENSUS = [[("seat-a", "taskA"), ("seat-b", "taskB"), ("seat-c", "taskC")], []]
@pytest.mark.asyncio
async def test_recommend_dispatch_runs_in_parallel():
"""recommend 一轮派多席位 → 批内并行(峰值并发 = 席位数,证明已由串行改并行)。"""
gw = _RecGateway(_DISPATCH_ALL_THEN_CONSENSUS)
result = await _run(gw, gather_first=False)
assert result.status == "done"
assert gw.max_active == 3 # 三席位真并行;若仍串行则恒为 1
@pytest.mark.asyncio
async def test_gather_first_false_skips_gather_phase():
"""默认 gather_first=False:不跑取数阶段,run_seat 只在研讨轮被调,无取数任务。"""
gw = _RecGateway(_DISPATCH_ALL_THEN_CONSENSUS)
await _run(gw, gather_first=False)
assert not any(_GATHER_MARK in task for _, task in gw.seat_tasks)
# 研讨轮任务也不应带「已收集资料」块(没有取数)
assert not any(_GATHERED_BLOCK_MARK in task for _, task in gw.seat_tasks)
# 仅 3 次席位调用(一轮研讨派活)
assert len(gw.seat_tasks) == 3
@pytest.mark.asyncio
async def test_gather_first_runs_parallel_gather_then_injects_material():
"""gather_first=True:研讨前对全席位并行跑「只取数」一轮,产出注入后续研讨轮任务。"""
gw = _RecGateway(_DISPATCH_ALL_THEN_CONSENSUS)
result = await _run(gw, gather_first=True)
assert result.status == "done"
gather_calls = [(aid, t) for aid, t in gw.seat_tasks if _GATHER_MARK in t]
discuss_calls = [(aid, t) for aid, t in gw.seat_tasks if _GATHER_MARK not in t]
# ① 取数阶段覆盖全部席位(每席位一次「只取数」任务)
assert {aid for aid, _ in gather_calls} == {"seat-a", "seat-b", "seat-c"}
# ② 取数任务在研讨任务之前(取数是前置阶段)
first_discuss_idx = gw.seat_tasks.index(discuss_calls[0])
last_gather_idx = max(gw.seat_tasks.index(c) for c in gather_calls)
assert last_gather_idx < first_discuss_idx
# ③ 研讨轮任务带上「研讨前各席位已并行收集的资料」块,且包含各席位取数产出
assert discuss_calls, "应有研讨轮任务"
for _, dtask in discuss_calls:
assert _GATHERED_BLOCK_MARK in dtask
assert "seat-a 的产出" in dtask # 注入了取数阶段的产物
@pytest.mark.asyncio
async def test_gather_phase_is_parallel():
"""取数阶段本身并行(全席位并发跑,峰值并发 = 席位数)。"""
# 首轮 leader 直接共识(dispatched=[]),让研讨轮不抢占并发,单独度量取数阶段峰值。
gw = _RecGateway([[]])
await _run(gw, gather_first=True)
# 只有取数阶段跑了 run_seat(研讨轮因首轮共识没派活)
assert all(_GATHER_MARK in t for _, t in gw.seat_tasks)
assert len(gw.seat_tasks) == 3
assert gw.max_active == 3 # 取数阶段三席位真并行
@@ -0,0 +1,207 @@
"""会商「模型自动容错(换下一个模型)」测试。
覆盖:
- 中间件兜底/熔断 AIMessage 带可识别标记 + ``is_llm_error_message`` 检测(标记 + 正文回退);
- ``fallback_model_chain`` 按 config.yaml models[] 顺序排候选(当前优先、去重、开关关闭=单个);
- ``reason_text`` 文案;
- 引擎 Step2 单席位异常隔离(一个席位抛错不拖垮整链/整批,仅置空交付);
- 网关带回的模型容错记录(switches)→ 引擎在时间线插「系统提示」气泡。
纯单元/纯逻辑:引擎用 JobProgressWriter(store=None) 干跑 + 假网关,不需要 DB / 真模型。
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import app.gateway.roundtable_model_fallback as mf
from app.gateway.roundtable_model_fallback import (
fallback_model_chain,
is_llm_error_message,
reason_text,
)
from deerflow.agents.middlewares.llm_error_handling_middleware import (
LLM_ERROR_MARKER,
LLMErrorHandlingMiddleware,
)
from deerflow.agents.middlewares.llm_error_handling_middleware import (
is_llm_error_message as harness_is_llm_error_message,
)
from deerflow.agents.roundtable_orchestrator import (
JobProgressWriter,
SeatRef,
run_orchestration,
)
from deerflow.agents.roundtable_orchestrator.types import LeaderTurn, ModelSwitch, SeatTurn
# ── is_llm_error_message ─────────────────────────────────────────────────────
def test_is_llm_error_message_marker_dict() -> None:
msg = {"type": "ai", "content": "随便什么", "additional_kwargs": {LLM_ERROR_MARKER: "busy"}}
assert is_llm_error_message(msg) == "busy"
assert harness_is_llm_error_message(msg) == "busy"
def test_is_llm_error_message_content_fallback() -> None:
# additional_kwargs 被剥离时,按已知兜底文案正文回退识别。
msg = {"type": "ai", "content": "大模型本次调用未能成功完成,请稍后再试或更换模型。"}
assert is_llm_error_message(msg) == "generic"
def test_is_llm_error_message_normal_is_none() -> None:
assert is_llm_error_message({"type": "ai", "content": "这是正常的席位交付内容"}) is None
assert is_llm_error_message(None) is None
assert is_llm_error_message({"type": "ai", "content": ""}) is None
# ── 中间件给兜底 AIMessage 打标记 ────────────────────────────────────────────
def _middleware() -> LLMErrorHandlingMiddleware:
app_config = SimpleNamespace(circuit_breaker=SimpleNamespace(failure_threshold=5, recovery_timeout_sec=30))
return LLMErrorHandlingMiddleware(app_config=app_config)
def test_middleware_marks_error_message() -> None:
mw = _middleware()
def _boom(_req):
raise RuntimeError("insufficient_quota: 余额不足") # quota → 非重试 → 立即兜底
out = mw.wrap_model_call(None, _boom)
assert harness_is_llm_error_message(out) == "quota"
assert out.additional_kwargs.get(LLM_ERROR_MARKER) == "quota"
# ── fallback_model_chain ─────────────────────────────────────────────────────
def _patch_models(monkeypatch, names: list[str]) -> None:
cfg = SimpleNamespace(models=[SimpleNamespace(name=n) for n in names])
monkeypatch.setattr(mf, "get_app_config", lambda: cfg)
def test_chain_current_first_then_config_order(monkeypatch) -> None:
_patch_models(monkeypatch, ["m1", "m2", "m3"])
monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK_MAX", raising=False)
assert fallback_model_chain("m2") == ["m2", "m1", "m3"]
def test_chain_empty_current_uses_config_order(monkeypatch) -> None:
_patch_models(monkeypatch, ["m1", "m2", "m3"])
monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
assert fallback_model_chain("") == ["m1", "m2", "m3"]
assert fallback_model_chain(None) == ["m1", "m2", "m3"]
def test_chain_unknown_current_kept_first(monkeypatch) -> None:
_patch_models(monkeypatch, ["m1", "m2"])
monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
assert fallback_model_chain("custom") == ["custom", "m1", "m2"]
def test_chain_disabled_single(monkeypatch) -> None:
_patch_models(monkeypatch, ["m1", "m2", "m3"])
monkeypatch.setenv("ROUNDTABLE_MODEL_FALLBACK", "0")
assert fallback_model_chain("m2") == ["m2"]
def test_chain_cap(monkeypatch) -> None:
_patch_models(monkeypatch, ["m1", "m2", "m3", "m4"])
monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
monkeypatch.setenv("ROUNDTABLE_MODEL_FALLBACK_MAX", "2")
assert fallback_model_chain("m1") == ["m1", "m2"]
def test_chain_no_config_returns_current(monkeypatch) -> None:
_patch_models(monkeypatch, [])
monkeypatch.delenv("ROUNDTABLE_MODEL_FALLBACK", raising=False)
assert fallback_model_chain("m9") == ["m9"]
assert fallback_model_chain("") == []
def test_reason_text() -> None:
assert reason_text("busy") == "服务繁忙"
assert reason_text("empty") == "未产出有效内容"
assert reason_text("timeout") == "长时间无响应(卡死)"
assert reason_text(None) == "调用失败"
assert reason_text("不存在的码") == "调用失败"
# ── 引擎:单席位异常隔离 + 模型容错记录 → 系统提示气泡 ──────────────────────────
_AGENTS = [SeatRef("seat-a", "情报"), SeatRef("seat-b", "环境"), SeatRef("seat-c", "方案")]
def _writer() -> JobProgressWriter:
return JobProgressWriter(None, job_id="job-x", agents=_AGENTS)
def _thread_ids() -> dict[str, str]:
return {a.agent_id: f"tid-{a.agent_id}" for a in _AGENTS} | {"roundtable-coordinator": "tid-co"}
class _ChainGateway:
"""chain 模式假网关:可指定某席位抛异常 / 带回模型容错记录。"""
def __init__(self, *, fail_agent: str | None = None, switch_agent: str | None = None) -> None:
self.fail_agent = fail_agent
self.switch_agent = switch_agent
self.seen: list[str] = []
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
return LeaderTurn(final_text="总控说明", dispatched=[])
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
self.seen.append(agent_id)
if agent_id == self.fail_agent:
raise RuntimeError("boom: 模型连接被打满")
switches = []
if agent_id == self.switch_agent:
switches = [ModelSwitch(failed_model="m1", next_model="m2", reason_label="服务繁忙")]
return SeatTurn(final_text=f"{agent_id} 的交付", switches=switches)
async def create_report_thread(self) -> str: # pragma: no cover
raise AssertionError("enable_report=False 不应绘制报告")
async def _run_chain(gw: _ChainGateway) -> tuple[object, JobProgressWriter]:
writer = _writer()
result = await run_orchestration(
gateway=gw,
progress=writer,
agents=_AGENTS,
thread_ids=_thread_ids(),
coordinator_name="roundtable-coordinator",
model="m1",
seed_message="种子任务",
mode="chain",
enable_report=False,
)
return result, writer
def test_chain_seat_failure_isolated() -> None:
gw = _ChainGateway(fail_agent="seat-b")
result, writer = asyncio.run(_run_chain(gw))
# 单席位失败不拖垮整链:仍走完三个席位并收口为 done。
assert result.status == "done"
assert gw.seen == ["seat-a", "seat-b", "seat-c"]
# 失败席位的交付气泡为空,其余正常。
seat_dlgs = {d["agentId"]: d["content"] for d in writer.dialogues() if d["role"] == "seat"}
assert seat_dlgs["seat-b"] == ""
assert seat_dlgs["seat-a"] == "seat-a 的交付"
assert seat_dlgs["seat-c"] == "seat-c 的交付"
def test_model_switch_emits_system_notice() -> None:
gw = _ChainGateway(switch_agent="seat-b")
_result, writer = asyncio.run(_run_chain(gw))
systems = [d for d in writer.dialogues() if d["role"] == "system"]
assert len(systems) == 1
assert "模型容错" in systems[0]["content"]
assert "服务繁忙" in systems[0]["content"]
assert "m2" in systems[0]["content"]
@@ -0,0 +1,118 @@
"""每席位「推理深度覆盖」(seat_modes)在进程内网关 run_seat 的解析测试。
语义(与前端 useStep2Orchestration「覆盖优先、否则跟随链条级 selectedMode」逐席位对齐):
- 席位命中 seat_modes → 用该档(_SEAT_MODE_PARAMS)派生的 thinking/reasoning/subagent **覆盖**
作业级 seat_*(不论作业级是什么);
- 席位未命中 → 跟随作业级 seat_thinking_enabled / seat_reasoning_effort / seat_subagent_enabled;
- 作业级仍为 None → 落 policy("seat") 默认。
只验证传给 _run_agent_turn 的三个运行参数,故把 _run_agent_turn / policy / 文本解析全部打桩,
不触发真实 LLM / 沙箱。
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
@pytest.fixture
def _gw(monkeypatch):
"""构造一个被打桩的 InProcessRoundtableGateway,返回 (build, captured)。
build(seat_modes=..., seat_thinking_enabled=..., seat_reasoning_effort=..., seat_subagent_enabled=...)
返回 gateway 实例;captured 记录最近一次 _run_agent_turn 收到的 thinking/reasoning/subagent。
"""
import app.gateway.roundtable_inprocess_gateway as gw
captured: dict[str, object] = {}
async def _fake_run_agent_turn(*args, **kwargs):
captured["thinking_enabled"] = kwargs.get("thinking_enabled")
captured["reasoning_effort"] = kwargs.get("reasoning_effort")
captured["subagent_enabled"] = kwargs.get("subagent_enabled")
return []
# policy("seat") 默认:thinking=True / reasoning="medium"(= 现有席位默认 pro 行为)。
monkeypatch.setattr(
gw,
"roundtable_run_policy",
lambda role: SimpleNamespace(excluded_tools=[], skill_stop_names=[], thinking_enabled=True, reasoning_effort="medium"),
)
monkeypatch.setattr(gw, "_run_agent_turn", _fake_run_agent_turn)
# 技能强化与本测试无关 → 原样返回任务,避免触发 load_agent_config / skill storage。
monkeypatch.setattr(gw, "append_seat_skill_directive", lambda agent_id, task, **k: task)
monkeypatch.setattr(gw, "_latest_ai_text", lambda messages: "")
monkeypatch.setattr(gw, "_tool_calls_since_last_human", lambda messages: [])
def _build(**kwargs):
return gw.InProcessRoundtableGateway(SimpleNamespace(state=SimpleNamespace()), user_id="u1", agents=[], **kwargs)
return _build, captured
async def _run(gateway):
await gateway.run_seat(thread_ids={"seat-1": "tid-1"}, agent_id="seat-1", task="任务", model="m")
@pytest.mark.asyncio
async def test_seat_mode_override_wins_over_job_level(_gw):
"""席位配了 flash → 覆盖作业级 pro(thinking off / minimal / 无子代理)。"""
build, captured = _gw
gateway = build(
seat_modes={"seat-1": "flash"},
seat_thinking_enabled=True,
seat_reasoning_effort="high",
seat_subagent_enabled=True,
)
await _run(gateway)
assert captured["thinking_enabled"] is False
assert captured["reasoning_effort"] == "minimal"
assert captured["subagent_enabled"] is False
@pytest.mark.asyncio
async def test_ultra_override_enables_subagent(_gw):
build, captured = _gw
gateway = build(seat_modes={"seat-1": "ultra"})
await _run(gateway)
assert captured["thinking_enabled"] is True
assert captured["reasoning_effort"] == "high"
assert captured["subagent_enabled"] is True
@pytest.mark.asyncio
async def test_no_override_follows_job_level(_gw):
"""席位未命中 seat_modes → 跟随作业级 seat_*。"""
build, captured = _gw
gateway = build(
seat_modes={"other": "flash"},
seat_thinking_enabled=True,
seat_reasoning_effort="low",
seat_subagent_enabled=False,
)
await _run(gateway)
assert captured["thinking_enabled"] is True
assert captured["reasoning_effort"] == "low"
assert captured["subagent_enabled"] is False
@pytest.mark.asyncio
async def test_no_override_no_job_level_falls_back_to_policy(_gw):
"""既无席位覆盖、作业级也为 None → 落 policy 默认(thinking=True / medium)。"""
build, captured = _gw
gateway = build(seat_modes={})
await _run(gateway)
assert captured["thinking_enabled"] is True
assert captured["reasoning_effort"] == "medium"
assert captured["subagent_enabled"] is False
@pytest.mark.asyncio
async def test_unknown_mode_value_ignored_falls_through(_gw):
"""非法档位值(前端不会下发,防御)→ 安全忽略,回退作业级/policy。"""
build, captured = _gw
gateway = build(seat_modes={"seat-1": "bogus"}, seat_reasoning_effort="low", seat_thinking_enabled=True)
await _run(gateway)
assert captured["thinking_enabled"] is True
assert captured["reasoning_effort"] == "low"
@@ -0,0 +1,52 @@
"""方案总结报告 prompt(build_summary_prompt)的意图对齐 + 缺口护栏内容测试。
效果改造 B:弱席位场景下,总结智能体不能只拼接各席位交付,而要先对齐用户意图、核对覆盖、
按护栏补强(推断标注「(综合推断)」、缺真实信息标注「待补充」交给用户、绝不凭空编)。
prompt 是纯函数,直接断言其文本包含这些指令 + 仍带齐意图/交付/共识/编排提示原料。
"""
from __future__ import annotations
from deerflow.agents.roundtable_orchestrator.engine import build_summary_prompt
from deerflow.agents.roundtable_orchestrator.types import SeatRef
_AGENTS = [SeatRef("seat-a", "情报"), SeatRef("seat-b", "方案")]
_DELIVERIES = {"seat-a": "情报席位交付内容", "seat-b": "方案席位交付内容"}
def test_summary_prompt_carries_intent_and_alignment_directive():
out = build_summary_prompt(
"目标:拿下华东市场\n约束条件:\n- 预算有限",
_AGENTS,
_DELIVERIES,
"总控共识:分两期推进",
coordinator_prompt="重点突出风险",
)
# 原料齐全
assert "拿下华东市场" in out
assert "预算有限" in out
assert "情报席位交付内容" in out and "方案席位交付内容" in out
assert "总控共识:分两期推进" in out
assert "重点突出风险" in out # 编排提示
# 意图对齐 + 缺口核对指令
assert "意图对齐与缺口核对" in out
assert "核心诉求清单" in out
# 防幻觉 + 报告文件完整干净(不足只在对话正文、不写进 .md)+ 对话必须有「核对说明」
assert "绝不凭空编" in out
assert "无需用户修改" in out
assert "不写进 .md" in out
assert "核对说明" in out
assert "不要省略" in out
def test_summary_prompt_alignment_directive_present_even_without_coordinator_prompt():
out = build_summary_prompt("目标:X", _AGENTS, _DELIVERIES, "共识 Y")
assert "意图对齐与缺口核对" in out
assert "待补充" in out
def test_summary_prompt_handles_empty_deliveries_gracefully():
out = build_summary_prompt("目标:X", _AGENTS, {}, "")
# 无交付时仍给出兜底说明,且意图对齐指令照常在
assert "暂无文字交付" in out
assert "意图对齐与缺口核对" in out