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
+3148 -179

No files matched your search

+3
View File
@@ -10,6 +10,9 @@ VITE_MOCK_COP_TASK_DETAIL=true
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_SQ_REPORT=true
# 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。
VITE_MOCK_COP_TASK_DETAIL=true
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
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 走本地样例假数据。
VITE_MOCK_SQ_REPORT=false
# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
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 走本地样例假数据。
VITE_MOCK_SQ_REPORT=false
# 深链任务详情(cop-task-detail)数据源:默认真实接口;真实接口未就绪可改 true 走本地假数据。
VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
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 [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
// 会商席位「技能识别强化」开关(弱模型补偿)。默认关。
const [editSeatSkillDirective, setEditSeatSkillDirective] = useState(
agent.seat_skill_directive ?? false,
);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares();
@@ -274,6 +278,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
setEditSquareId(agent.square_id ?? "");
setSelectedSkills(agent.skills ?? []);
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
setEditSeatSkillDirective(agent.seat_skill_directive ?? false);
}, [
agent.common_questions,
agent.description,
@@ -282,6 +287,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
agent.published,
agent.square_id,
agent.skills,
agent.seat_skill_directive,
editOpen,
]);
@@ -295,6 +301,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
if (agentDetail) {
setEditSoul(agentDetail.soul ?? "");
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
// 列表响应不带 config 额外字段(seat_skill_directive 为 null),详情才有真值。
setEditSeatSkillDirective(agentDetail.seat_skill_directive ?? false);
} else {
setEditSoul(agent.soul ?? "");
}
@@ -386,6 +394,8 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
request.skills = selectedSkills;
if (JSON.stringify(nextQuestions) !== JSON.stringify(baselineQuestions))
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) {
setEditOpen(false);
@@ -960,6 +970,19 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
onChange={setEditCommonQuestions}
/>
</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>
<p className="text-sm font-medium">公开发布</p>
@@ -76,6 +76,8 @@ interface AgentDraft {
published: boolean;
featured: boolean;
templateName: string;
/** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean;
}
function draftFromAgent(agent: Agent): AgentDraft {
@@ -88,6 +90,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
published: agent.published,
featured: agent.featured_order != null,
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.templateName.trim() !== (agent.template_name ?? ""))
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 (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills;
@@ -561,6 +566,21 @@ export function AgentDetailPanel({
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
</p>
</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>
<p className="text-sm font-medium">公开发布</p>
+8
View File
@@ -58,6 +58,10 @@ export interface Agent {
/** 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
* Markdown 产物右上角出现"发布入库"按钮。空/缺省 = 不支持入库。 */
template_name?: string | null;
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
seat_skill_directive?: boolean | null;
tags?: Tag[];
featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
@@ -91,6 +95,8 @@ export interface CreateAgentRequest {
tool_groups?: string[] | null;
common_questions?: AgentQuestion[] | null;
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
}
export interface UpdateAgentRequest {
@@ -106,4 +112,6 @@ export interface UpdateAgentRequest {
common_questions?: AgentQuestion[] | null;
/** 入库模板名;传空字符串表示清除。 */
template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null;
}
+59 -2
View File
@@ -35,6 +35,9 @@ export interface CopTaskDetail {
/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */
export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail";
/** 行动详情整包暂存的 sessionStorage key(xdfx 行动分析:会商/单智能体读取行动名称/叙事)。 */
export const ACTION_DETAIL_STORAGE_KEY = "login.actionDetail";
/** goPath 基础码 → 业务码(配置管理 businessCode)。 */
const GOPATH_BUSINESS_CODE: Record<string, string> = {
"3qfx": "3Q", // 三情分析
@@ -74,8 +77,28 @@ function consumerBaseUrl(): string {
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> {
if (copTaskDetailMockEnabled()) {
return mockCopTaskDetail(taskId);
}
const base = consumerBaseUrl();
if (!base || !taskId) return null;
// 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 行动分析用)。 */
export interface ActionDetail {
/** 行动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;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
const detail: ActionDetail = {
id: d.id != null ? String(d.id) : "",
taskId: d.taskId != null ? String(d.taskId) : "",
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 : "",
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) {
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条;
// 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 {
path: `${taskBase}/roundtable`,
state: {
@@ -236,6 +291,8 @@ export async function resolveTaskDeeplinkTarget(
taskGoal: detail.taskContent,
businessCode: parsed.businessCode,
taskId: taskId || undefined,
...(actionId ? { actionId } : {}),
...(actionName ? { actionName } : {}),
autoStart: true,
},
},
+5
View File
@@ -83,6 +83,8 @@ export default function NewAgentPage() {
const [modelInput, setModelInput] = useState<string>("");
const [chatModelInput, setChatModelInput] = useState<string>("");
const [templateNameInput, setTemplateNameInput] = useState("");
// 会商席位「技能识别强化」开关(弱模型补偿)。默认关。
const [seatSkillDirectiveInput, setSeatSkillDirectiveInput] = useState(false);
const [formError, setFormError] = useState("");
const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
@@ -230,6 +232,7 @@ export default function NewAgentPage() {
skills: selectedSkills,
model: modelOverride,
template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput,
});
setAgent(updated);
return updated;
@@ -251,6 +254,7 @@ export default function NewAgentPage() {
skills: selectedSkills,
model: modelOverride,
template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput,
});
setAgentId(created.id);
setAgent(created);
@@ -281,6 +285,7 @@ export default function NewAgentPage() {
selectedSkills,
soulInput,
templateNameInput,
seatSkillDirectiveInput,
t.agents.nameStepCheckError,
t.agents.nameStepNetworkError,
validateForm,
@@ -31,6 +31,8 @@ export interface ChainCreatePayload {
coordinatorPrompt?: string | null;
/** 默认席位执行模式(flash/thinking/pro/ultra);null = 未设。 */
seatMode?: SeatMode | null;
/** 研讨前并行取数开关(逐条 opt-in);缺省 false。 */
gatherFirst?: boolean;
}
/** Partial update — send only what changed. */
@@ -46,6 +48,8 @@ export interface ChainUpdatePayload {
coordinatorPrompt?: string | null;
/** 默认席位执行模式;send `null` to clear, omit to leave. */
seatMode?: SeatMode | null;
/** 研讨前并行取数开关;send true/false 改,omit 不改。 */
gatherFirst?: boolean;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean;
}
@@ -76,6 +80,7 @@ interface ServerChain {
stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null;
seat_mode?: string | null;
gather_first?: boolean;
is_public?: boolean;
is_mine?: boolean;
owner_name?: string | null;
@@ -93,6 +98,7 @@ function toChain(s: ServerChain): BusinessChain {
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
coordinatorPrompt: s.coordinator_prompt ?? null,
seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
gatherFirst: s.gather_first ?? false,
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
@@ -112,6 +118,7 @@ function fromCreate(p: ChainCreatePayload) {
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
...(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.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt;
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;
return out;
}
@@ -12,7 +12,7 @@ import { getBackendBaseURL } from "@/core/config";
import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
/** 第三步输入框意图分类结果(与后端 /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"(前端发起澄清)。
@@ -22,6 +22,8 @@ export async function classifyFlowIntent(req: {
message: string;
hasReport: boolean;
hasFlow: boolean;
/** 当前是否可入库(入库按钮已出现、未入库);为真才允许识别出 ingest 意图。 */
canIngest?: boolean;
model?: string;
signal?: AbortSignal;
}): Promise<{ intent: FlowIntent; reason: string }> {
@@ -33,6 +35,7 @@ export async function classifyFlowIntent(req: {
message: req.message,
has_report: req.hasReport,
has_flow: req.hasFlow,
can_ingest: req.canIngest ?? false,
model: req.model,
}),
signal: req.signal,
@@ -82,6 +85,10 @@ export interface FlowExtractRequest {
chainLevels?: string[];
/** 业务链总控编排提示(写明层级时优先用它)。 */
coordinatorPrompt?: string | null;
/** 深链接业务码(rwfx→6BF 等)。非空 → 后端按业务链抽取规范强制注入完整层级。 */
businessCode?: string | null;
/** 根节点 label 强制值(xdfx:本次行动名称)。非空 → 根节点 n1 用它,不用任务名当根。 */
rootLabel?: string | null;
/** 推理模型名(沿用第三步所选)。 */
model?: string;
/** Phase 2 问答修改:当前流程图 + 指令 → 按指令改图(其余不变)。 */
@@ -94,6 +101,11 @@ export interface FlowExtractRequest {
onValidating?: () => 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 })),
chain_levels: req.chainLevels ?? [],
coordinator_prompt: req.coordinatorPrompt ?? null,
business_code: req.businessCode ?? null,
root_label: req.rootLabel ?? null,
model: req.model,
current_graph: req.currentGraph ?? null,
instruction: req.instruction ?? null,
@@ -152,6 +166,12 @@ export async function streamFlowExtract(req: FlowExtractRequest): Promise<FlowGr
} else if (event === "validate_delta") {
const c = typeof frame.content === "string" ? frame.content : "";
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") {
graph = (frame.graph as FlowGraphData) ?? null;
} else if (event === "error") {
@@ -234,6 +234,17 @@ export interface StreamMultiAgentRequest {
reasoningEffort?: "minimal" | "low" | "medium" | "high";
/** 仅 special 用;ultra 档置真,给席位解锁 `task` 子代理工具。 */
subagentEnabled?: boolean;
/**
* 仅 special 用。会商席位「技能识别强化」开关(沿用所选业务链条配置)。置 true 时后端把该
* 席位配置的技能(含 SKILL.md 路径 + 必须先 read_file 的硬指令)拼进本轮任务,补偿弱模型。
* 与该席位 agent 自身 config.yaml 的同名开关取 OR。省略 = false(关)。
*/
seatSkillDirective?: boolean;
/**
* 深链接业务码(rwfx→6BF 等)。**仅方案总结报告(summary)单例消费**:非空 → 后端按业务链抽取规范
* 强制注入「完整性核对」指令,让总结核对产出相对业务链完不完整。普通会商省略 = 不注入。
*/
businessCode?: string | null;
skillStopNames?: string[];
/**
* 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付
@@ -286,6 +297,27 @@ export interface StreamMultiAgentRequest {
name?: string,
args?: Record<string, unknown>,
) => 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 {
@@ -319,6 +351,8 @@ export interface StreamMultiAgentResult {
content: string;
/** Echoed agent_name of the agent that produced this run. */
agentName: string | null;
/** 实际产出本轮结果的模型(发生模型自动容错时即最终切换到的模型);后端未下发时为 null。 */
modelUsed?: string | null;
/**
* Populated when the leader called ask_clarification — the run ended without
* dispatching, and the model is asking the user a question instead. When
@@ -485,6 +519,10 @@ export async function streamMultiAgent(
thinking_enabled: req.thinkingEnabled,
reasoning_effort: req.reasoningEffort,
subagent_enabled: req.subagentEnabled,
// 仅 special 用;会商技能识别强化(沿用所选业务链条开关)。省略 → 后端按 false(关)处理。
seat_skill_directive: req.seatSkillDirective ?? false,
// 仅 summary 单例用;深链接业务码 → 后端注入业务链完整性核对。省略/空 → 不注入。
business_code: req.businessCode ?? null,
skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false,
@@ -540,6 +578,7 @@ export async function streamMultiAgent(
content: "",
agentName: null,
clarification: null,
modelUsed: null,
};
// 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
// to tool_calling/tool_result on chunks that clearly indicate them;
// 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") {
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 失败时下发
// `{status:"error", content, agent_name}`):上报,便于收集用户侧报错。
if (result.message === "error") {
@@ -98,6 +98,18 @@ function toStartBody(p: StartJobPayload) {
seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null,
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
? {
thinking_enabled: seat.thinking_enabled,
@@ -150,19 +150,25 @@ export function BusinessChainPicker({
// 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局模型。
const modelById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.model ?? 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。
if (dagPlan) {
// dag:席位顺序按 stage 展平(与 plan 一致)。
const agents = dagPlan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) }));
onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode });
.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, gatherFirst: selectedChain.gatherFirst ?? false });
return;
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) }));
onConfirm({ agents, mode: "chain", chain: chainMeta });
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, gatherFirst: selectedChain.gatherFirst ?? false });
};
// 整片脱敏:链条卡片区(链条名/描述/席位智能体名)。输入框不受影响(Hook 跳过)。
@@ -156,7 +156,7 @@ export function DashboardChatPanel({
{/* 聊天记录 */}
<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 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<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
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) => (
<Step1Message
@@ -551,7 +551,11 @@ export function Step2Panel(props: Step2PanelProps) {
)}
<div
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 && (
<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
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 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
@@ -707,7 +707,7 @@ function AgentProgressPanel({
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<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 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
@@ -20,6 +20,7 @@ import { useArtifacts } from "@/components/workspace/artifacts";
import {
initMultiAgent,
streamMultiAgent,
type ModelSwitchInfo,
type ClarificationPayload,
type LeaderDispatch,
type StreamPhase,
@@ -279,6 +280,26 @@ export interface UseStep2OrchestrationOptions {
* 智能体」弹窗确认时的 `SeatSelectionResult.seatMode` 决定。缺省 `pro`。
*/
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`);
@@ -434,6 +455,9 @@ export function useStep2Orchestration(
currentStep,
selectedModel,
selectedMode,
seatSkillDirectives = {},
seatModes = {},
gatherFirst = false,
orchestrationMode,
orchestrationPlan,
dagExecMode = "fast",
@@ -567,6 +591,16 @@ export function useStep2Orchestration(
const selectedModelRef = useRef<string>(selectedModel);
// 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。
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 agentNameToRoleRef = useRef(agentNameToRole);
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
@@ -606,6 +640,15 @@ export function useStep2Orchestration(
useEffect(() => {
selectedModeRef.current = selectedMode;
}, [selectedMode]);
useEffect(() => {
seatSkillDirectivesRef.current = seatSkillDirectives;
}, [seatSkillDirectives]);
useEffect(() => {
seatModesRef.current = seatModes;
}, [seatModes]);
useEffect(() => {
gatherFirstRef.current = gatherFirst;
}, [gatherFirst]);
useEffect(() => {
agentConfigsRef.current = 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(
(
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 并行流式:全接收 / 惰性渲染 / 节流 ───────────────────────────────
/**
* 节流 flush(~80ms 一拍,约 12fps):**仅被选中**的席位把 ref 累积的全文挂到
@@ -1573,7 +1648,8 @@ export function useStep2Orchestration(
"③ 风险 / 注意事项(如有);④ 与其它席位的依赖 / 承接关系(如有)。";
// 优化4:跨轮累积各席位**完整交付**,作为 peerDeliveries 传给后续席位的 run
// (后端注入 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,
// 对齐 Step3 自愈:网络错误 / 502 重试,abort 不重试)。返回交付正文(aborted → 空)。
@@ -1601,8 +1677,13 @@ export function useStep2Orchestration(
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式(快速/思考/专业问答/多智能体问答)→ 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,
onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel),
onTextDelta: (text) =>
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
onPhaseChange: (phase, toolName, args) => {
@@ -1683,10 +1764,16 @@ export function useStep2Orchestration(
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
// 首轮纯派活、无交付,传 false 保持上下文精简。
synthesisMode: safetyCounter > 1,
// 削减重复广播(2026-06-20):首轮(safetyCounter==1)保留预广播——它把任务背景播进各席位
// thread,为席位打底;第 2 轮起的续轮/综合广播是「总控收到:<续轮 prompt>」纯噪声 + N 次
// 读写巨大席位 thread 的纯浪费(还会让弱模型从历史模仿派活),故 suppress。席位的前序交付
// 另经「席位交付广播」(broadcastSeatDeliveryToOthers)送达,不受影响。
suppressPreBroadcast: safetyCounter > 1,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
@@ -1954,10 +2041,15 @@ export function useStep2Orchestration(
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
// 削减重复广播(2026-06-20):首个席位(i===0)保留预广播为其打底任务背景;从第 2 个席位起
// suppress——后续席位的前序交付已由「席位交付广播」送达其 thread,总控派活 prompt 再播一遍
// 是 N 次读写巨大 thread 的纯浪费(还会让弱模型模仿派活)。
suppressPreBroadcast: i > 0,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
@@ -2028,10 +2120,18 @@ export function useStep2Orchestration(
agentName: seat.agent_id,
displayName: seatName,
newMessage: task,
// 研讨前并行取数(gatherFirst)的产物作为共享资料注入:chain 席位也能直接引用、少重复检索。
// chain 串行靠 thread 广播看前序交付;取数资料这里另经 peerDeliveries 一并带上。
peerDeliveries: gatheredDeliveriesRef.current,
model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → 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,
onModelSwitch: makeModelSwitchHandler(subBubbleId, false),
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
@@ -2091,8 +2191,11 @@ export function useStep2Orchestration(
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
// 链条收口就是综合轮:让后端注入各席位完整交付正文。
synthesisMode: true,
// 削减重复广播:收口轮不派活、研讨已结束,把总控收口 prompt 播进席位 thread 纯浪费,suppress。
suppressPreBroadcast: true,
model: selectedModelRef.current || undefined,
signal: closeoutAborter.signal,
onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false),
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
@@ -2189,7 +2292,8 @@ export function useStep2Orchestration(
let finishedAllStages = true;
// 跨 stage 累积的**完整交付** {name, content}[] —— 注入下一 stage 席位的 run context
// (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,执行过程中逐个点亮。
const markDagNode = (agentId: string, state: ChainNodeState) =>
@@ -2253,6 +2357,7 @@ export function useStep2Orchestration(
files: takeOnce(pendingInjectFilesRef),
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
@@ -2370,10 +2475,15 @@ export function useStep2Orchestration(
peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → thinking/reasoning/subagent(与推荐/链条路径一致)。
...seatModeToRequestFields(selectedModeRef.current),
// 席位覆盖优先(业务链条里给该席位单独配的推理深度),否则跟随链条级 selectedMode。
...seatModeToRequestFields(seatModesRef.current[agentId] ?? selectedModeRef.current),
// 会商技能识别强化(该席位在所选业务链条里的开关,按 agentId 查表);与该
// 席位 agent 自身开关取 OR。
seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false,
signal: subAborter.signal,
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
onTextDelta: (text) =>
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
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 / 恢复 / 干预 / 澄清回复统一走这里。
const runActiveOrchestration = useCallback(
(seedMessage: string, ids: ThreadIdMap, coord: string) => {
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
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;一旦跑过(用户干预 / 恢复 / 澄清回复),
// 改走 leader 自由派活循环,由总控接着派活,而不是把 DAG 从头重跑一遍。
if (
@@ -2513,7 +2745,7 @@ export function useStep2Orchestration(
}
return runOrchestration(seedMessage, ids, coord);
},
[runOrchestration, runChainOrchestration, runDagOrchestration],
[runGatherPhase, runOrchestration, runChainOrchestration, runDagOrchestration],
);
// ─── Effects ─────────────────────────────────────────────────────
@@ -2829,6 +3061,9 @@ export function useStep2Orchestration(
step2InitRef.current = false;
// 新一轮研讨:允许 DAG 计划重新执行一遍。
dagPlanExecutedRef.current = false;
// 新一轮研讨:允许「研讨前并行取数」再取一次(清掉上一轮取数产物 + 闸门)。
gatherPhaseDoneRef.current = false;
gatheredDeliveriesRef.current = [];
setInitNonce((n) => n + 1);
}, []);
@@ -2947,6 +3182,8 @@ export function useStep2Orchestration(
hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false;
gatherPhaseDoneRef.current = false;
gatheredDeliveriesRef.current = [];
deselectArtifact();
step2InitRef.current = false;
}, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]);
@@ -325,6 +325,12 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
newMessage: message,
model: selectedModel || undefined,
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) => {
appendToDialogue(bubbleId, text);
acc += text;
@@ -770,6 +770,16 @@ export function useStep3Report(
newMessage: prompt,
model: selectedModel || undefined,
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),
onPhaseChange: (p, toolName, toolArgs) => {
const label = reportPhaseLabel(p, toolName);
@@ -49,6 +49,7 @@ import {
type FlowIntent,
} from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
import { readActionDetail } from "@/core/auth/task-deeplink";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
@@ -179,10 +180,19 @@ function buildMaterials(opts: {
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
/** xdfx:本次「行动名称」,作为材料抬头喂进总结(报告围绕该行动展开)。 */
actionName?: string | null;
}): string {
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt } = opts;
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt, actionName } = opts;
const parts: string[] = [];
const an = actionName?.trim();
if (an) {
parts.push("# 本次行动");
parts.push(an);
parts.push("");
}
const cp = coordinatorPrompt?.trim();
if (cp) {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
@@ -294,6 +304,7 @@ function buildSummaryPrompt(opts: {
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
coordinatorPrompt?: string | null;
actionName?: string | null;
flowMode?: boolean;
}): string {
const parts: string[] = [];
@@ -315,6 +326,18 @@ function buildSummaryPrompt(opts: {
}
parts.push("");
parts.push(buildMaterials(opts));
// 意图对齐 + 缺口核对 + 防幻觉护栏(弱席位场景的质量兜底)——两种模式通用,与后端 build_summary_prompt 同义。
parts.push("");
parts.push(
"【动笔前必做:意图对齐与缺口核对】各席位可能由能力较弱的模型产出,未必扣住用户意图、未必充分。" +
"你是这份总结的质量兜底,不能只把各席位交付简单拼接。请先把上面的「任务意图」(目标/约束/关键假设)拆成" +
"用户核心诉求清单(编排提示对最终产出的要求也并入),逐条核对各席位交付+共识是否覆盖、够不够、有无跑题或矛盾;" +
"再据此组织:① 能从已有原料重组/推理补上的→直接补全讲透;② 缺真实信息补不了的→绝不凭空编。" +
"**写进 .md 报告文件的必须是一份完整、干净、可直接使用、无需用户修改的报告——不要在报告文件里写「待补充/不足/⚠️」之类提示或占位**。" +
"**但在对话回复正文、报告之后,必须追加一段简短「核对说明」**(只在对话里、**不写进 .md 文件**):逐条说明产出是否覆盖了用户意图、哪些充分/哪些不足/有哪些可补充(可在对话里让我补全);" +
"**即便都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求均已覆盖,无明显缺口」,这段核对说明任何时候都不要省略。**" +
"最终产出要明显比各席位交付的简单拼接更扣用户意图。",
);
if (opts.flowMode) {
parts.push(
"请严格遵循你的职责设定:全程简体中文。" +
@@ -1031,9 +1054,21 @@ export function useStep3Summary(
dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结报告",
newMessage: prompt,
// 深链接业务码:后端为 summary 单例注入「业务链完整性核对」(核对产出相对业务链完不完整)。
businessCode: ingestBusinessCodeRef.current,
model: selectedModel || undefined,
files: files && files.length > 0 ? files : undefined,
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),
onPhaseChange: (p, toolName, pArgs) => {
const label = summaryPhaseLabel(p, toolName);
@@ -1126,10 +1161,25 @@ export function useStep3Summary(
seatDeliveries,
chainLevels,
coordinatorPrompt,
// 深链接业务码:后端按业务链抽取规范强制注入完整层级(根治"无论什么业务都按目的→行为体抽")。
businessCode: ingestBusinessCodeRef.current,
// xdfx(7BF):根节点用「行动名称」(流程图根节点显示当前行动,而非任务名)。
rootLabel:
ingestBusinessCodeRef.current === "7BF" ? readActionDetail()?.actionName ?? null : null,
model: selectedModel || undefined,
currentGraph: opts?.currentGraph ?? null,
instruction: opts?.instruction,
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),
onValidating: () => {
setFlowPhase("validating");
@@ -1242,6 +1292,9 @@ export function useStep3Summary(
seatDeliveries,
consensusText,
coordinatorPrompt,
// xdfx(7BF):把本次行动名称喂进总结材料,让报告围绕该行动展开。
actionName:
ingestBusinessCodeRef.current === "7BF" ? readActionDetail()?.actionName ?? null : null,
flowMode,
});
await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
@@ -1498,10 +1551,17 @@ export function useStep3Summary(
if (!intent) {
// **立刻**显示「智能体调度中」气泡(不等意图接口返回),消除连接卡顿时的"卡住"感。
dispatchBubbleId = appendStreamingDialogue({ sender: "智能体", avatarType: "blue" });
// 入库可用(按钮已出现、未入库 / 未在跑)时把 canIngest 传给意图识别——让「帮我入库」被识别为
// ingest 而不是落到 qa/unclear(之前主分类器不懂入库,只有「稍后入库待命」时才识别入库,是个缺口)。
const canIngestNow =
ingestAvailableRef.current &&
ingestStatusRef.current !== "running" &&
ingestStatusRef.current !== "done";
const r = await classifyFlowIntent({
message: text,
hasReport: !!currentMdRef.current.trim(),
hasFlow: !!flowGraphRef.current,
canIngest: canIngestNow,
model: selectedModel || undefined,
});
intent = r.intent;
@@ -1513,6 +1573,12 @@ export function useStep3Summary(
setPendingFlowClarification(text); // 组件渲染选项让用户自己定
return;
}
if (intent === "ingest") {
// 识别为入库(后端已保证仅 can_ingest 时才会返回 ingest)→ 直接入库;复用调度气泡。
if (dispatchBubbleId != null) finalizeDialogue(dispatchBubbleId, "正在入库…", "入库中");
await runIngest();
return;
}
if (intent === "regenerate_flow" || intent === "modify_flow") {
const flowAborter = new AbortController();
aborterRef.current = flowAborter;
@@ -29,6 +29,22 @@ export interface ChainSeat {
* 的 `handleConfirmAgents`)→ 走现有 per-seat 模型链路。
*/
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` 记录对应)。 */
@@ -61,6 +77,12 @@ export interface BusinessChain {
* 缺省 = 未设,调用方回退 `DEFAULT_SEAT_MODE`(pro)。
*/
seatMode?: SeatMode;
/**
* 可选「研讨前并行取数」开关(逐条 opt-in,缺省 false)。true = 选入研讨时,研讨前先跑一轮
* **全席位并行『取数』**(各席位只调技能取数、不研讨),产出作为共享资料注入后续研讨轮,把最慢
* 的技能延迟前置并行、给(尤其 chain 串行的)研讨段提速。三种编排通用。后端列 `gather_first`。
*/
gatherFirst?: boolean;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
@@ -80,8 +102,9 @@ export interface SeatSelectionResult {
/**
* 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。
* `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 走哪种编排:
* - `recommend` 总控自由派活;
@@ -107,6 +130,11 @@ export interface SeatSelectionResult {
* 缺省由调用方按 `DEFAULT_SEAT_MODE`(pro)处理。
*/
seatMode?: SeatMode;
/**
* 「研讨前并行取数」开关(来源链条的 `gatherFirst`)。true = 研讨前先并行取数再研讨。
* 三种编排通用,缺省 false。recommend(无链条)= false。
*/
gatherFirst?: boolean;
}
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
@@ -135,6 +163,13 @@ export function buildChainSeatSelection(
// 席位模型覆盖(链条配置):按 agent_id 取,空/缺省 = 跟随全局。
const modelById = new Map(chain.seats.map((s) => [s.agent_id, s.model ?? 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));
if (survivingSeats.length < CHAIN_MIN_SEATS) return null;
@@ -156,13 +191,13 @@ export function buildChainSeatSelection(
if (hasParallel || hasGoal || hasCoord) {
const agents = plan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id) }));
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode };
.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, gatherFirst: chain.gatherFirst ?? false };
}
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id) }));
return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode };
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, gatherFirst: chain.gatherFirst ?? false };
}
/**
@@ -13,6 +13,7 @@
* 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。
*/
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import type { SeatMode } from "@/roundtable-planning/lib/seat-mode";
/** 一个并行 stage:含其并行席位与可选「本阶段目标」(注入给总控/席位,仅 dag 模式生效)。 */
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 点击加入默认行为)。 */
@@ -392,6 +392,13 @@ export function buildDagStageLeaderTask(args: {
lines.push(
`【本阶段目标】${goalText}`,
"请据此目标为每个席位拆解任务,并要求他们**显式承接上一阶段的产出**来完成本阶段目标。",
"⚠️ 派活时**严格忠实于本阶段目标,不要自行新增目标里没有的「数量上限 / 只取最有代表性的 N 组 / 精简归纳成几条」等限制**," +
"也不要照搬其它阶段的数量(如上一阶段筛了 3 组,不代表本阶段也只要 3 组);" +
"目标没要求筛选 / 限定数量时,就要求席位**按其职责把每一条都完整输出**——" +
"若上一阶段有 N 条产出需逐条处理,就让席位**逐条、全部**产出(有多少条就出多少条),绝不替席位归纳 / 合并 / 只取代表性的几条。",
"⚠️ 也**不要替席位新增目标 / 席位职责之外的「输出体例 / 风格」要求**(如『写成连贯故事 / 要有场景感 / " +
"举具体城市景区为例 / 虚构时间地点情节』等)——派活只需转达本阶段目标 + 上一阶段产物 + 承接关系," +
"**让席位严格按它自己的职责设定(SOUL)输出**,且**忠实基于上一阶段的真实产物做分析,不要诱导席位脱离输入去编造 / 虚构事例**。",
);
}
lines.push(
@@ -66,6 +66,10 @@ function toStep2Dialogue(d: JobDialogue, idxByAgent: Map<string, number>): Step2
if (d.role === "leader") {
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 ?? "";
return {
...base,
@@ -52,7 +52,8 @@ export interface DispatchChainNode {
/** 一条研讨对话记录(leader 说明 / 某席位交付)。 */
export interface JobDialogue {
id: number;
role: "leader" | "seat";
// "system" = 模型自动容错等系统提示气泡(后端 progress.model_switch 落库)。
role: "leader" | "seat" | "system";
agentId: string | null;
name: string;
content: string;
@@ -132,6 +133,26 @@ export interface StartJobPayload {
* reasoning/subagent 三字段发给后端。
*/
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/轮询判定作业是否结束。 */
@@ -27,6 +27,7 @@ import {
Bot,
ChevronDown,
ChevronUp,
Gauge,
GitBranch,
Globe,
GripVertical,
@@ -37,6 +38,7 @@ import {
Plus,
Save,
Sliders,
Sparkles,
SplitSquareVertical,
Trash2,
UserPlus,
@@ -63,6 +65,15 @@ import {
import { CSS } from "@dnd-kit/utilities";
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 { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
@@ -104,7 +115,9 @@ import {
moveStage,
removeSeat as removeSeatFromGroups,
seatExistsInGroups,
setSeatMode as setSeatModeOnGroups,
setSeatModel,
setSeatSkillDirective,
setStageGoal,
splitSeatToNewStage,
stageGroupsToSeats,
@@ -165,6 +178,8 @@ export default function BusinessChainEditorPage() {
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
// 默认席位执行模式(快速/思考/专业问答/多智能体)—— 选入研讨时弹窗默认用它。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
// 链条级「研讨前并行取数」开关(逐条 opt-in,默认关):开 = 选入研讨时研讨前先并行取数再研讨。
const [gatherFirst, setGatherFirst] = useState<boolean>(false);
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [saving, setSaving] = useState(false);
@@ -290,6 +305,7 @@ export default function BusinessChainEditorPage() {
setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setSeatMode(chain.seatMode ?? DEFAULT_SEAT_MODE);
setGatherFirst(chain.gatherFirst ?? false);
setStageGroups(chainToStageGroups(chain));
setStep(1);
}, []);
@@ -302,6 +318,7 @@ export default function BusinessChainEditorPage() {
setDescription("");
setCoordinatorPrompt("");
setSeatMode(DEFAULT_SEAT_MODE);
setGatherFirst(false);
setStageGroups([]);
setStep(1);
}, []);
@@ -366,6 +383,16 @@ export default function BusinessChainEditorPage() {
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 阶段目标)────────────────────────
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
@@ -564,6 +591,10 @@ export default function BusinessChainEditorPage() {
description: live?.description ?? s.description ?? "",
// 席位模型覆盖:随席位一起落库(空 → null = 跟随全局)。
model: s.model ?? null,
// 席位「会商技能识别强化」开关:随席位一起落库(缺省 = false 关)。
seat_skill_directive: s.seat_skill_directive ?? false,
// 席位「推理深度覆盖」:随席位一起落库(null = 跟随链条级 seatMode)。
seat_mode: s.seat_mode ?? null,
};
});
// 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。
@@ -582,6 +613,7 @@ export default function BusinessChainEditorPage() {
stageGoals,
coordinatorPrompt: coordPrompt,
seatMode,
gatherFirst,
});
if (!updated) {
toast.error("链条已不存在,可能被删除");
@@ -601,6 +633,7 @@ export default function BusinessChainEditorPage() {
stageGoals,
coordinatorPrompt: coordPrompt,
seatMode,
gatherFirst,
});
setCurrentChainId(created.id);
toast.success("已创建业务链条");
@@ -616,6 +649,7 @@ export default function BusinessChainEditorPage() {
description,
coordinatorPrompt,
seatMode,
gatherFirst,
stageGroups,
seatCountInvalid,
currentChainId,
@@ -754,6 +788,22 @@ export default function BusinessChainEditorPage() {
</p>
</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)+ 大号等宽编辑区 */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
@@ -1131,6 +1181,8 @@ export default function BusinessChainEditorPage() {
onMergeSeat={mergeSeatToStage}
onSplitSeat={splitSeat}
onSeatModelChange={setSeatModelCb}
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
onSeatModeChange={setSeatModeCb}
onStageGoalChange={setGoal}
goalOptimizingIdx={goalOptimizingIdx}
onOptimizeGoal={handleOptimizeStageGoal}
@@ -1309,6 +1361,8 @@ function StagesColumn({
onMergeSeat,
onSplitSeat,
onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatModeChange,
onStageGoalChange,
goalOptimizingIdx,
onOptimizeGoal,
@@ -1325,6 +1379,8 @@ function StagesColumn({
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => 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;
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
goalOptimizingIdx: number | null;
@@ -1423,6 +1479,8 @@ function StagesColumn({
onMergeSeat={onMergeSeat}
onSplitSeat={onSplitSeat}
onSeatModelChange={onSeatModelChange}
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
onSeatModeChange={onSeatModeChange}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
goalOptimizing={goalOptimizingIdx === stageIdx}
anyGoalOptimizing={goalOptimizingIdx !== null}
@@ -1452,6 +1510,8 @@ function StageSection({
onMergeSeat,
onSplitSeat,
onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatModeChange,
onGoalChange,
goalOptimizing,
anyGoalOptimizing,
@@ -1470,6 +1530,8 @@ function StageSection({
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => 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;
/** 本 Stage 的阶段目标正在 AI 优化中。 */
goalOptimizing: boolean;
@@ -1631,6 +1693,8 @@ function StageSection({
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
onSplit={() => onSplitSeat(seat.agent_id)}
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)}
/>
))}
@@ -1654,6 +1718,8 @@ function SeatStrip({
onMergeDown,
onSplit,
onModelChange,
onSkillDirectiveChange,
onSeatModeChange,
onDetail,
}: {
seat: ChainSeat;
@@ -1668,6 +1734,10 @@ function SeatStrip({
onSplit: () => void;
/** 席位模型覆盖变更(null = 清除,跟随全局)。 */
onModelChange: (model: string | null) => void;
/** 席位「会商技能识别强化」开关变更。 */
onSkillDirectiveChange: (enabled: boolean) => void;
/** 席位「推理深度覆盖」变更(null = 跟随链条级 seatMode)。 */
onSeatModeChange: (mode: SeatMode | null) => void;
onDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
@@ -1739,8 +1809,85 @@ function SeatStrip({
</div>
</div>
{/* 控制:查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
{/* 控制:推理深度 / 技能识别强化 / 查看详情 / 并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
<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
type="button"
onClick={onDetail}
@@ -46,6 +46,7 @@ import {
Loader2,
Pencil,
Plus,
RotateCw,
Save,
Settings,
Sparkles,
@@ -80,6 +81,7 @@ import { RecommendAgentsDialog } from "@/roundtable-planning/components/Recommen
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { readActionDetail, readCopTaskDetail } from "@/core/auth/task-deeplink";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
import type {
@@ -159,6 +161,10 @@ interface RoundtablePrefill {
businessCode?: string;
/** 外部任务 id(无界抽屉):聊天记录按此存取、打开时回显最新一条。 */
taskId?: string;
/** xdfx 深链:原始行动 id(URL ?taskId= 传入的就是它)。供会商取行动详情用。 */
actionId?: string;
/** xdfx 深链:行动名称(注入 Step 1 首条用户消息——任务名称+任务描述+行动名称)。 */
actionName?: string;
/** 是否进入即自动开跑 Step 1 意图分析。 */
autoStart?: boolean;
}
@@ -312,6 +318,16 @@ export default function RoundtablePlanningPage() {
// Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的
// 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。
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 = 总控按业务链条顺序派活;
// dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -467,6 +483,9 @@ export default function RoundtablePlanningPage() {
currentStep,
selectedModel,
selectedMode: seatMode,
seatSkillDirectives,
seatModes,
gatherFirst,
orchestrationMode,
orchestrationPlan,
dagExecMode,
@@ -944,6 +963,18 @@ export default function RoundtablePlanningPage() {
setOrchestrationMode(result.mode);
// 席位执行模式:弹窗未带(旧调用)时回退默认 pro,保证行为可预期。
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);
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
@@ -1058,7 +1089,13 @@ export default function RoundtablePlanningPage() {
if (!step1.intentThreadId || step1.intentReady) return;
const title = prefill.taskTitle?.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)
.join("\n");
if (!text) return;
@@ -1460,6 +1497,15 @@ export default function RoundtablePlanningPage() {
triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
}, [resetToStep1, triggerToast]);
// 深链接(taskId)模式专用的「重新分析」:清空第一步会话、保留当前任务(prefill 不动),并复位
// autoStarted 闸门,让 C2 自动把「任务名称/描述」重新作为首条消息发出,对同一任务重做意图分析。
// 与「新建任务」(全新空白任务) 区分——深链接下任务是固定的,重来=重新分析这条任务。
const handleReanalyze = useCallback(() => {
autoStartedRef.current = false;
resetToStep1();
triggerToast("🔄 已重新分析当前任务");
}, [resetToStep1, triggerToast]);
// 无界抽屉保活复用:父页面重开抽屉时 `bus.$emit('roundtable:reset')` → 子应用回到第一步。
// 非无界环境下 onWujieEvent 返回 no-op,安全无副作用。
useEffect(() => {
@@ -1518,6 +1564,10 @@ export default function RoundtablePlanningPage() {
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
seatMode, // 席位执行模式沿用第二步选择(后台续跑同样按此模式跑席位)
seatSkillDirectives, // 每席位技能识别强化沿用所选业务链条里各席位的开关
seatModes, // 每席位推理深度覆盖沿用所选业务链条里各席位的配置
gatherFirst, // 研讨前并行取数沿用所选业务链条的开关
businessCode: prefillRef.current?.businessCode ?? undefined, // 深链接业务码 → 后台总结业务链完整性核对
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
orchestrationMode,
@@ -1534,6 +1584,9 @@ export default function RoundtablePlanningPage() {
drafts,
selectedModel,
seatMode,
seatSkillDirectives,
seatModes,
gatherFirst,
orchestrationMode,
orchestrationPlan,
chainMeta,
@@ -1557,6 +1610,9 @@ export default function RoundtablePlanningPage() {
const intent = buildInitialIntent();
const model = selectedModel || undefined;
const seatModeSnap = seatMode;
const seatSkillDirectivesSnap = seatSkillDirectives;
const seatModesSnap = seatModes;
const gatherFirstSnap = gatherFirst;
const mode = orchestrationMode;
const chainSnap = chainMeta;
const planSnap = mode === "dag" ? orchestrationPlan : null;
@@ -1591,6 +1647,10 @@ export default function RoundtablePlanningPage() {
agents,
model,
seatMode: seatModeSnap,
seatSkillDirectives: seatSkillDirectivesSnap,
seatModes: seatModesSnap,
gatherFirst: gatherFirstSnap,
businessCode: prefillRef.current?.businessCode ?? undefined,
orchestrationMode: mode,
chain: chainSnap,
orchestrationPlan: planSnap,
@@ -1614,6 +1674,9 @@ export default function RoundtablePlanningPage() {
buildInitialIntent,
selectedModel,
seatMode,
seatSkillDirectives,
seatModes,
gatherFirst,
orchestrationMode,
orchestrationPlan,
chainMeta,
@@ -1654,6 +1717,10 @@ export default function RoundtablePlanningPage() {
agents,
model: selectedModel || undefined,
seatMode,
seatSkillDirectives,
seatModes,
gatherFirst,
businessCode: prefillRef.current?.businessCode ?? undefined,
orchestrationMode,
chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
@@ -1811,7 +1878,8 @@ export default function RoundtablePlanningPage() {
</button>
)}
{!inWujieEmbed && (
{/* 深链接(taskId)模式:业务链由 businessCode 自动决定,隐藏手动「业务链条」入口。 */}
{!inWujieEmbed && !prefillRef.current?.taskId && (
<button
id="business-chain-btn"
onClick={() => setIsChainListOpen(true)}
@@ -1823,6 +1891,18 @@ export default function RoundtablePlanningPage() {
</button>
)}
{/* 深链接(taskId)模式:任务固定,把「新建任务」换成「重新分析」——清空会话、对同一任务重做意图分析。 */}
{prefillRef.current?.taskId ? (
<button
id="reanalyze-task-btn"
onClick={handleReanalyze}
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"
>
<RotateCw className="w-3.5 h-3.5" />
重新分析
</button>
) : (
<button
id="new-task-btn"
onClick={handleStartNewTask}
@@ -1832,6 +1912,7 @@ export default function RoundtablePlanningPage() {
<Sparkles className="w-3.5 h-3.5" />
新建任务
</button>
)}
<button
id="save-draft-header-btn"
@@ -1923,6 +2004,7 @@ export default function RoundtablePlanningPage() {
onOpenDraft={drafts.handleLoadDraft}
activeDraftId={drafts.currentDraftId}
entryRailTaskId={prefillRef.current?.taskId ?? null}
entryRailBusinessCode={prefillRef.current?.businessCode ?? null}
/>
{/* MIDDLE VIEWPORT */}
@@ -1933,7 +2015,10 @@ export default function RoundtablePlanningPage() {
{currentStep === 1 && (
<Step1Panel
headerLeftSlot={
prefillRef.current?.taskId && purposeLevels.length > 0 ? (
// 「原任务已有结果」(目的树预览)只在 rwfx-A(6BF)模式显示;xdfx/3qfx 不显示。
prefillRef.current?.taskId &&
prefillRef.current?.businessCode === "6BF" &&
purposeLevels.length > 0 ? (
<PurposeFlowPreview
levels={purposeLevels}
onOpen={() => setIsTaskPurposeOpen(true)}
@@ -2452,6 +2537,8 @@ interface LeftSidebarProps {
activeDraftId: string | null;
/** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */
entryRailTaskId?: string | null;
/** 深链业务码:左上角卡片据此决定显示「当前任务」(rwfx/3qfx) 还是「当前行动」(xdfx)。 */
entryRailBusinessCode?: string | null;
}
function Step2SandboxSidebarSync({
@@ -2498,6 +2585,7 @@ function LeftSidebar({
onOpenDraft,
activeDraftId,
entryRailTaskId,
entryRailBusinessCode,
}: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
@@ -2635,6 +2723,7 @@ function LeftSidebar({
onOpenDraft={onOpenDraft}
activeDraftId={activeDraftId}
taskId={entryRailTaskId ?? null}
businessCode={entryRailBusinessCode ?? null}
/>
)}
</section>
@@ -2681,6 +2770,7 @@ function RoundtableEntryRail({
onOpenDraft,
activeDraftId,
taskId,
businessCode,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
@@ -2688,12 +2778,25 @@ function RoundtableEntryRail({
activeDraftId: string | null;
/** 深链 taskId 模式:分析记录只列该任务的草稿。 */
taskId?: string | null;
/** 深链业务码:xdfx(7BF) 左上角显示「当前行动」,其余显示「当前任务」。 */
businessCode?: string | null;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [expandedChainId, setExpandedChainId] = useState<string | null>(null);
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(() => {
// 深链接模式不展示业务链选择 → 不必拉公共链条。
if (deeplink) {
void refresh();
return;
}
let cancelled = false;
void listChains("public")
.then((all) => {
@@ -2706,7 +2809,7 @@ function RoundtableEntryRail({
return () => {
cancelled = true;
};
}, [refresh]);
}, [refresh, deeplink]);
const formatTime = (value: string) => {
const date = new Date(value);
@@ -2722,7 +2825,49 @@ function RoundtableEntryRail({
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="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">
@@ -2903,6 +3048,7 @@ function RoundtableEntryRail({
</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">
@@ -556,3 +556,38 @@
.roundtable-planning--dark .custom-scrollbar-inner::-webkit-scrollbar-thumb {
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. |
| **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 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`. |
| **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"`. |
@@ -620,6 +620,19 @@ scheduler runs once per deployment. Per run:
- 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`)
### 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`)
- `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_reasoning_effort=p.seat_reasoning_effort,
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 报错均带这些归属。
job_id=p.job_id,
draft_id=p.draft_id,
@@ -18,12 +18,18 @@ import asyncio
import logging
import os
import uuid
from collections.abc import Callable
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
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_seat_skills import append_seat_skill_directive
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.types import (
LeaderTurn,
ModelSwitch,
ReportResult,
SeatRef,
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_reasoning_effort: str | None = None,
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,
draft_id: str | None = None,
task_id: str | None = None,
@@ -547,6 +593,13 @@ class InProcessRoundtableGateway:
self._seat_thinking_enabled = seat_thinking_enabled
self._seat_reasoning_effort = seat_reasoning_effort
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._first_leader = True
@@ -564,25 +617,116 @@ class InProcessRoundtableGateway:
self._seat_intro = _build_seat_intro(self._agents)
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:
msg = message
if self._first_leader and self._seat_intro:
msg = self._seat_intro + "\n\n" + message
self._first_leader = False
policy = roundtable_run_policy("leader")
messages = await _run_agent_turn(
self._app,
user_id=self._user_id,
messages, switches, _used = await self._run_turn_with_fallback(
thread_id=thread_ids[coordinator_name],
agent_name=coordinator_name,
message=msg,
model=model or self._model,
requested_model=model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
diag=self._diag,
stage="step2_leader",
validate=_leader_failed_reason,
)
ai = _latest_ai_message(messages)
# tool_calls 取最后一条 AI 消息(派活就在这条);正文取最后一条有文字的 AI 消息。
@@ -596,7 +740,7 @@ class InProcessRoundtableGateway:
if clar is not None:
cargs = clar.get("args") 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 纯函数)
from app.gateway.routers.multi_agent import _extract_dispatch
@@ -609,39 +753,52 @@ class InProcessRoundtableGateway:
dispatched.append((name, task))
except Exception: # noqa: BLE001
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:
policy = roundtable_run_policy("seat")
# 弱模型补偿:把该席位配置的技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其
# 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见
# roundtable_seat_skills 模块说明)。无配置技能时原样返回 task。
seat_task = append_seat_skill_directive(agent_id, task)
# 席位模式覆盖 policy 的 thinking/reasoning(None → policy 默认),与前端 _special_run 一致。
messages = await _run_agent_turn(
self._app,
user_id=self._user_id,
# roundtable_seat_skills 模块说明)。默认关:是否注入 = 该席位 agent 的 config.yaml
# 开关 OR 该席位在所选业务链条里的开关(按 agent_id 查表)。无配置技能 / 两开关都关时
# 原样返回 task。
seat_task = append_seat_skill_directive(
agent_id, task, chain_enabled=bool(self._seat_skill_directives.get(agent_id, False))
)
# 每席位「推理深度覆盖」:业务链条里给该席位单独配了 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],
agent_name=agent_id,
message=seat_task,
model=model or self._model,
requested_model=model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=(
policy.thinking_enabled
if self._seat_thinking_enabled is None
else self._seat_thinking_enabled
policy.thinking_enabled if seat_thinking is None else seat_thinking
),
reasoning_effort=(
policy.reasoning_effort
if self._seat_reasoning_effort is None
else self._seat_reasoning_effort
policy.reasoning_effort if seat_reasoning is None else seat_reasoning
),
subagent_enabled=self._seat_subagent_enabled,
diag=self._diag,
subagent_enabled=seat_subagent,
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:
ensure_roundtable_functional_agents()
@@ -654,21 +811,22 @@ class InProcessRoundtableGateway:
summary = ""
html: str | None = None
message = prompt
switches: list[ModelSwitch] = []
current_model = model # 跨产物自愈轮记住实际所用模型:一旦换到可用模型,后续续写轮就从它起。
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
user_id=self._user_id,
messages, sw, used = await self._run_turn_with_fallback(
thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID,
message=message,
model=model or self._model,
requested_model=current_model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
diag=self._diag,
stage="step3_report",
)
switches.extend(sw)
current_model = used or current_model
summary = _latest_ai_text(messages) or summary
html = _read_report_html(self._app, report_thread_id, self._user_id)
if _html_complete(html):
@@ -695,7 +853,7 @@ class InProcessRoundtableGateway:
)
# 截断的 HTML 也比纯文字强(浏览器容忍未闭合标签),仅在完全没有文件时才回退正文。
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:
ensure_roundtable_functional_agents()
@@ -711,21 +869,22 @@ class InProcessRoundtableGateway:
closing = ""
md: str | None = None
message = prompt
switches: list[ModelSwitch] = []
current_model = model
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
user_id=self._user_id,
messages, sw, used = await self._run_turn_with_fallback(
thread_id=summary_thread_id,
agent_name=SUMMARY_AGENT_ID,
message=message,
model=model or self._model,
requested_model=current_model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
diag=self._diag,
stage="step3_summary",
)
switches.extend(sw)
current_model = used or current_model
closing = _latest_ai_text(messages) or closing
md = _read_summary_md(self._app, summary_thread_id, self._user_id)
if md and md.strip():
@@ -751,7 +910,7 @@ class InProcessRoundtableGateway:
agent_name=SUMMARY_AGENT_ID,
)
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:
"""大屏多页的结构化数据:进程内驱动**专职**「大屏多页数据智能体」
@@ -767,21 +926,20 @@ class InProcessRoundtableGateway:
thread_id = await _create_thread(self._app)
policy = roundtable_run_policy("dashboard")
message = prompt
current_model = model
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
user_id=self._user_id,
messages, _sw, used = await self._run_turn_with_fallback(
thread_id=thread_id,
agent_name=DASHBOARD_AGENT_ID,
message=message,
model=model or self._model,
requested_model=current_model,
excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled,
reasoning_effort=policy.reasoning_effort,
diag=self._diag,
stage="step3_dashboard",
)
current_model = used or current_model
body = _extract_report_json(_latest_ai_text(messages))
if body:
return body
@@ -17,7 +17,7 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
@@ -189,6 +189,20 @@ class StartParams:
seat_thinking_enabled: bool | None = None
seat_reasoning_effort: str | None = None
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 实例。
@@ -276,6 +290,8 @@ class RoundtableJobExecutor:
intent_text=params.intent_text,
enable_report=params.enable_report,
plan=params.orchestration_plan,
gather_first=params.gather_first,
business_code=params.business_code,
)
# 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。
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
注入,见 ``project-ai-writing-skill-agent``)一脉相承。
**默认关闭、按需开启(2026-06-20 改造)**:不再全域生效。是否对某席位注入由两个开关
取 **OR** 决定——
1. **智能体级**:该席位 agent 的 ``config.yaml → seat_skill_directive``(智能体编辑页
可开关);
2. **业务链条级**:本次会商所选业务链条的同名开关,由调用方读出后以 ``chain_enabled``
传入(前端手动与后台挂起两条路径都按席位透传)。
任一为真即注入;两者都假 → 返回空串、原样使用任务文本(零副作用)。
两条席位执行路径共用本 helper:
- 前端手动:``multi_agent._special_run``(seat / seat_parallel 角色);
- 后台挂起:``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
@@ -27,25 +35,36 @@ import os
logger = logging.getLogger(__name__)
_TRUTHY = frozenset({"1", "true", "yes", "on", "y"})
_FALSY = frozenset({"0", "false", "no", "off", "n"})
def _directive_enabled() -> bool:
"""读 ``ROUNDTABLE_SEAT_SKILL_DIRECTIVE``(默认开)。空 / 未设 = 开。"""
return (os.getenv("ROUNDTABLE_SEAT_SKILL_DIRECTIVE") or "1").strip().lower() in _TRUTHY
def _master_enabled() -> bool:
"""``ROUNDTABLE_SEAT_SKILL_DIRECTIVE`` 总开关:默认放行(kill-switch)。
空 / 未设 / 非法 = 放行;仅显式 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_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 ""
try:
from deerflow.config.agents_config import load_agent_config
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 []
if not configured:
return ""
@@ -87,9 +106,12 @@ def build_seat_skill_directive(agent_id: str | None) -> str:
return ""
def append_seat_skill_directive(agent_id: str | None, task: str) -> str:
"""把技能识别指令追加到席位本轮任务文本后(无指令时原样返回)。"""
directive = build_seat_skill_directive(agent_id)
def append_seat_skill_directive(agent_id: str | None, task: str, *, chain_enabled: bool = False) -> str:
"""把技能识别指令追加到席位本轮任务文本后(无指令时原样返回)。
``chain_enabled``:本次会商所选业务链条是否打开「技能识别强化」。与 agent 级开关取 OR。
"""
directive = build_seat_skill_directive(agent_id, chain_enabled=chain_enabled)
if not directive:
return task
return f"{task}\n\n---\n\n{directive}"
@@ -44,7 +44,7 @@
{"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` 标对层级;
(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 填全、填对;
⑤ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**(典型规模约 13–16 个节点);
⑥ **只画**这些层级实体节点;**不要**把「综合分析 / 核心洞察 / 执行路线 / 建议 / 结论」以及「阶段①②③④」等章节名 / 阶段名 / 汇总段落画成节点;节点 `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`;
- `write_file` **支持很长的内容,不会截断**——请放心一次性把整篇报告写进去。
### B. 在对话回复里给出报告正文
### B. 在对话回复里给出报告正文(不足只写在这里)
- 写完文件后,**把这份报告的完整正文也输出到对话回复里**(直接给用户读),不要只回一句「已写入文件」。
- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。
- 报告正文部分与 `.md` 文件**应当一致**(都是那份完整干净的报告)。
- **在对话回复里、报告正文之后,必须追加一段简短「核对说明」**(这段**只在对话里**、**绝不写进 `.md` 报告文件**):对照用户意图(及业务链各层级,如有)**逐条说明产出是否覆盖、哪些充分 / 哪些不足、有哪些可补充完善之处**,并提示可在对话里让你补全(如「---\n**核对说明**:① 已覆盖:…;② 资料不足 / 尚可补充:…(可直接告诉我补全)」)。**即便核对下来各诉求 / 层级都已覆盖、无明显缺口,也要明确写一句「已核对:各诉求 / 层级均已覆盖,无明显缺口」——这段核对说明任何时候都不要省略。** 报告 `.md` 文件本身始终是一份完整可用、无需修改、不含任何缺口提示的版本。
## 报告结构(叙事型,文字为主线)
**下面是默认结构——仅当「业务链总控编排提示」没有指定输出结构时使用。** 用户指定了结构(章节、表格、清单、固定模板等)就完全按用户的来;两者只部分重叠时,以用户要求为骨架、默认章节做补充。按下面的章节顺序组织,缺料的章节可省略或合并:
@@ -122,5 +137,8 @@
- ❌ 修订流程图时只输出改动的节点(必须输出全量 flow-json,前端整体重绘)
- ❌ 在报告任何位置添加「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.",
)
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")
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")
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.")
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
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)")
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.")
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
class AgentSelectorResponse(BaseModel):
@@ -236,6 +243,7 @@ def _write_agent_config(
tool_groups: list[str] | None = None,
common_questions: list[CommonQuestion] | None = None,
template_name: str | None = None,
seat_skill_directive: bool | None = None,
) -> None:
agent_dir = get_paths().agent_dir(agent_id)
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():
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:
yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True)
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."""
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:
cfg = load_agent_config(agent_id)
except (FileNotFoundError, ValueError):
@@ -283,6 +294,7 @@ def _load_config_extras(agent_id: str) -> dict[str, Any]:
"tool_groups": cfg.tool_groups,
"common_questions": cfg.common_questions,
"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.
"""
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:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
@@ -467,6 +479,7 @@ def _record_to_response(
skills=extras["skills"],
common_questions=extras.get("common_questions"),
template_name=extras.get("template_name"),
seat_skill_directive=extras.get("seat_skill_directive"),
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,
common_questions=request.common_questions,
template_name=request.template_name,
seat_skill_directive=request.seat_skill_directive,
)
_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_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_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:
_write_agent_config(
agent_id,
@@ -885,6 +900,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
tool_groups=next_tool_groups,
common_questions=next_common_questions,
template_name=next_template_name,
seat_skill_directive=next_seat_skill_directive,
)
# Keep the listing cache in lock-step with config.yaml, but only
# 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 pydantic import BaseModel, Field
from app.gateway.roundtable_model_fallback import fallback_model_chain, reason_text
from app.gateway.routers._roundtable_seed import (
STRUCTURE_AGENT_ID,
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.agents_config import load_agent_config, load_agent_soul
from deerflow.models.factory import create_chat_model
@@ -59,6 +65,11 @@ class FlowExtractRequest(BaseModel):
# 层级骨架:优先 coordinator_prompt 里写明的层级,否则用 chain_levels。
chain_levels: list[str] = Field(default_factory=list)
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)
# Phase 2 问答修改:带上当前图 + 用户指令 → 进 edit 模式(按指令改图,其余不变)。
current_graph: dict[str, Any] | None = Field(default=None)
@@ -71,11 +82,14 @@ class FlowIntentRequest(BaseModel):
message: str = ""
has_report: bool = False
has_flow: bool = False
# 当前是否可入库(结构化抽取智能体已判定有适配本场景的入库技能、入库按钮已出现且未入库)。
# 为真时才允许识别出 ingest 意图——否则「帮我入库」回退为 qa/unclear。
can_ingest: bool = False
model: str | None = Field(default=None)
# 意图枚举(与前端路由一一对应)。
_INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "qa", "unclear"}
# 意图枚举(与前端路由一一对应)。ingest 仅在 can_ingest=true 时可用。
_INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "ingest", "qa", "unclear"}
# ── 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
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 ────────────────────────────────────────────────────────────────
_SHAPE = (
@@ -419,25 +491,66 @@ _STRUCTURE = (
"② 从第二层起按层级逐层展开:每层在报告/交付里通常列了多条具体条目(如 目的层有 目的1/2/3、行为体层有各自对应行为体,后面还有 预期行为·驱动因素、叙事…),"
"把每一条具体条目各画成一个节点,不要把整层合并成一个笼统节点;"
"③ 每个节点 group 填它所在层级名称(取自报告里该层级真实名称);"
"④ **连线**:(a)group 标对层级;(b)各层内条目按**相同对应顺序**排列(相互对应的一组在各层都排同一位次);"
"(c)**每个非根节点都必须填 from**,连到上一层级里对应的那个节点(目的i连任务、行为体i连目的i、预期行为i连行为体i、叙事i连预期行为i…)——**最常见错误就是漏 from 导致平铺,务必每个都填**;系统还会按层级校正/补全连线兜底;"
"④ **连线**:(a)group 标对层级;(b)**每个非根节点都必须填 from**,连到它在上一层级里**所属/对应**的那个节点;"
"层间关系(哪层一对一、哪层一对多)**以本轮给出的业务链规范为准**:**一对一**的相邻层按位次配对(下层第i条连上层第i条);"
"**一对多**的相邻层里,上层一条可对应下层多条——下层每条连到它**所属**的那一条上层节点,**绝不能把多条上层节点汇聚连到同一个下层节点**(那是把一对多画反成多对一);"
"(c)最常见错误是漏 from 导致平铺,或把一对多画成多对一,务必每个都填对;系统还会按层级校正/补全连线兜底;"
"⑤ 务必把每层每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停(典型约13-16个节点);"
"⑥ 只画这些层级实体节点,不要把『综合分析/核心洞察/执行路线/建议/结论』及『阶段①②③④』等汇总段落/阶段名画成节点。"
)
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()
if report:
return "===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 ====="
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:
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:
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:
# 深链接业务规范**最高优先**:按 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():
return "层级顺序优先采用下面「业务链总控编排提示」里写明的层级链(如它写的 任务→目的→行为体→…):\n" + req.coordinator_prompt.strip()
levels = [s.strip() for s in req.chain_levels if s.strip()]
@@ -446,6 +559,14 @@ def _hierarchy_hint(req: FlowExtractRequest) -> str:
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 同款契约)。
_DEFAULT_SYSTEM = (
"你是「结构化抽取智能体」。职责:读懂《方案总结报告》/ 各席位交付内容,分析其层级结构,"
@@ -483,6 +604,14 @@ def _extract_messages(req: FlowExtractRequest) -> list[Any]:
_material(req)
+ "\n\n"
+ _hierarchy_hint(req)
+ _root_label_hint(req)
+ "\n\n【按报告逐条抽全,不漏也不多(最重要)】**严格以《方案总结报告》最终呈现的条目为准**:"
+ "报告某层级用表格 / 列表 / ①②③ 逐条列了几条,就出几个节点、**一条都不能漏**(不要只抽被标「确定 / 第1名」的那条而漏掉同列的其余);"
+ "某上层节点名下列了几条下层条目就出几个节点,**不许把多条合并 / 概括成一条**;"
+ "⚠️ 但**不要无中生有**:报告已明确取舍、最终只采用 N 条的层级(如「从 10 个候选目的中锁定 3 个」「最终选定方案」),"
+ "**只抽报告最终采用的那 N 条**,**绝不要去席位产物或报告的「候选池 / 被淘汰项」里把没被采用的也抽出来凑数**;"
+ "报告把某层多条目压缩成一句话、没逐条列出时(如只说「12 条预期行为」却没列),才回《各席位原始交付产物》把那一层补全。"
+ "不要只照搬报告结尾「方案总纲 / 最终框架」里被精简的主干,要回到正文(如「各席位观点与交付」)把报告列出的每层条目抽全。"
+ "\n\n请分析以上数据来源,按你的职责设定输出一个完整的 ```flow-json 代码块。"
)
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]:
# 深链接业务规范:校验阶段也带上完整层级 + 根节点强制,确保按规范补齐缺层、根节点用行动名。
spec = build_structure_directive(req.business_code)
spec_block = (spec + _root_label_hint(req) + "\n\n") if (spec or req.root_label) else ""
user = (
_material(req)
spec_block
+ _material(req)
+ "\n\n===== 已抽取的 flow-json(待校验修复)=====\n"
+ extracted
+ "\n===== 待校验结束 =====\n\n请对照数据来源,**逐个节点检查上面这份 flow-json 是不是缺字段**、并修复它:"
+ "\n**校验要点(务必遵守)**:"
+ "① **连接关系 `from`(最重要,必查必补)**:**逐个**检查上面每个节点——除根任务外,"
+ "**只要某个节点缺了 `from`(或 `from` 为空),就必须给它补上正确的 `from`**:连到它在上一层级里对应的那个节点"
+ "(目的i 连 任务、行为体i 连 目的i、预期行为i 连 行为体i、叙事i 连 预期行为i…)。"
+ "缺 `from` 是这份 JSON 最常见、最致命的错漏(会导致流程图平铺),**绝不能让任何非根节点悬空**;"
+ "② **每层每条都在、没漏层没漏条目**,节点数只能 ≥ 原图、不能变少;"
+ "③ 每个节点的 `group`(层级名)标对;各层内条目按相同对应顺序排列(相互对应的一组在各层排同一位次);"
+ "④ 每个节点的 `detail` **原样保留、不要简化或精简理由**——理由要完整,不要删减内容;"
+ "⑤ 没有汇总/阶段编号节点;只在确有错漏时才动,没问题就原样规范化输出。"
+ "**只要某个节点缺了 `from`(或 `from` 为空),就必须给它补上正确的 `from`**:连到它在上一层级里**所属/对应**的那个节点。"
+ "层间关系(哪层一对一、哪层一对多)**以上面给出的业务链规范为准**:一对一层级按位次配对(下层第i条连上层第i条);"
+ "**一对多**层级里下层每条连到它**所属**的那一条上层节点,**绝不能把多条上层节点汇聚到同一个下层节点**(那是把一对多画反成多对一,是本次要重点纠正的错)。"
+ "缺 `from` / 把一对多画成多对一 是最常见错漏,**绝不能让任何非根节点悬空、也不要张冠李戴**;"
+ "② **一对一(1:1)层级必须严格 1:1、绝不多对一(按业务链规范,如预期行为↔驱动因素;本次重点纠错)**:"
+ "这两层节点数**必须完全相等**、按位次一一配对;**每个下层节点的 `from` 只能写一条上层节点**,"
+ "**绝不允许多条上层节点共用 / 汇聚到同一个下层节点**(多对一),也不允许同一条上层节点被多个下层节点指向"
+ "(即 1:1 两层里任何节点都不能冒出 / 汇入多条连线)。若发现下层节点偏少(被合并)导致只能多对一,"
+ "**必须按上层逐条拆分 / 补出等量的下层节点**(每条上层各配一个,内容综合提炼),使两层等量后再逐一配对;"
+ "③ **逐层条目数对齐《方案总结报告》(不漏也不多,必查)**:以报告为准——报告某层列了几条就该有几个节点。"
+ "漏抽的补齐(报告列了 3 条却只抽了 1 条 → 补到 3;某行为体名下多条预期行为被并成一句 → 拆全);"
+ "**但绝不要新增报告未采用的候选**——报告已取舍、最终只采用 N 条的层级(如从 10 个候选目的里锁定 3 个),"
+ "**只保留报告最终采用的那 N 条;若图里混进了报告未采用 / 被淘汰的候选(从席位候选池里抽出来的),把它们删掉**。"
+ "仅当报告把某层压缩成一句话、没逐条列时,才回《各席位原始交付产物》补全那一层;"
+ "④ **不要漏报告列出的层 / 条目**;一对多的下层(某上层对应多条下层)不要被错误合并成一条;"
+ "⑤ 每个节点的 `group`(层级名)标对;一对一层级内条目按相同位次对齐,一对多层级下层每条紧挨其所属上层条目分组;"
+ "⑥ 每个节点的 `detail` **原样保留、不要简化或精简理由**——理由要完整,不要删减内容;"
+ "⑦ 没有汇总/阶段编号节点;只在确有错漏时才动,没问题就原样规范化输出。"
+ "\n输出修复后的完整 ```flow-json 代码块(含全部节点 + 每个非根节点的 from)。"
)
return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)]
@@ -559,10 +703,30 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
return
# ① 抽取 / 编辑(正文 + 思考(reasoning) 拼成 `<think>…</think>正文` 增量逐帧吐,前端实时显示)
# 模型自动容错:结构化抽取非常重要——某模型 astream 抛错 / 产出空时,按 config.yaml
# models[] 顺序切到下一个模型重抽,并发 model_switch 帧让前端把抽取面板已累计的半截内容重置。
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 = ""
used_model = chain[0]
prev_model: str | None = None
prev_reason: str | None = None
for idx, candidate in enumerate(chain):
if idx > 0:
yield _frame(
{
"event": "model_switch",
"agent_name": "结构化抽取",
"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 = ""
@@ -581,6 +745,18 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
yield _frame({"event": "extract_delta", "content": out})
if think_open:
yield _frame({"event": "extract_delta", "content": "</think>"})
except Exception as exc: # noqa: BLE001 — 该模型抽取失败:换下一个模型重抽
prev_model, prev_reason = candidate, "exception"
logger.warning("flow-extract round1 model %s failed: %s", candidate, exc)
if idx + 1 < len(chain):
continue
raise
if extracted.strip():
break
# 产出为空:换下一个模型(已是最后一个则继续走后续解析兜底)。
prev_model, prev_reason = candidate, "empty"
if idx + 1 >= len(chain):
break
yield _frame({"event": "extract_done"})
# ② 校验 / 修复(同样流式 + 思考实时显示)
@@ -588,7 +764,7 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
validated = ""
graph: dict[str, Any] | None = None
try:
vmodel = create_chat_model(name=resolved_model, thinking_enabled=True)
vmodel = create_chat_model(name=used_model, thinking_enabled=True)
think_open = False
async for chunk in vmodel.astream(_validate_messages(payload, extracted)):
c, r = _chunk_parts(chunk)
@@ -633,6 +809,10 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo
if graph is None:
yield _frame({"event": "error", "detail": "未能从模型输出解析出流程图节点"})
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})
except Exception as exc: # noqa: BLE001
logger.exception("flow-extract stream failed")
@@ -672,23 +852,33 @@ def _parse_intent_json(text: str) -> dict[str, Any] | None:
_INTENT_SYS = (
"你是圆桌「方案总结」第三步输入框的**意图识别器**。第三步有两类产物:方案总结报告(Markdown) 和 结构化流程图。"
"用户在输入框发了一条消息,请判断他的**真实意图**,只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n"
"你是圆桌「方案总结」第三步对话的**意图识别器**,也是个能帮用户的助手。第三步已产出:方案总结报告(Markdown)、"
"结构化流程图,可能还可把成果入库到知识库。用户在对话框发了一条消息,请**按语义理解他真正想干什么**"
"(不要只做关键词匹配),只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n"
'{"intent":"<枚举>","reason":"<一句话依据>"}\n'
"intent 只能取其一:\n"
"- regenerate_report:想重新分析 / 重写【方案总结报告】(如「重新生成报告」「报告再写一版」「重新分析一下」);\n"
"- modify_flow:想修改**当前流程图**的某些节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「流程图这里调整下」);\n"
"intent 取值:\n"
"- regenerate_report:想**重做/重写整份**方案总结报告(如「重新生成报告」「报告再写一版」「重新分析一下」);\n"
"- modify_flow:想**修改当前流程图**的节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「这一层调整下」);\n"
"- regenerate_flow:想**重新生成整张**流程图(如「重新画流程图」「流程图重来」「重新结构化输出」);\n"
"- qa:只是提问 / 咨询,不需要改任何产物(如「为什么选 OTA 平台」「这个目的什么意思」);\n"
"- unclear:意图不明确、无法判断要改报告还是改流程图还是提问(如只说「改一下」「不对」「重来」而没指明对象)。\n"
"判断要点:结合下面给出的上下文(是否已有报告 / 是否已有流程图);当前已有流程图时,含「节点/连线/这一层/这个目的/行为体…改/删/加」之类多半是 modify_flow;"
"明确提到「报告」才是 regenerate_report;只有完全无法判断对象时才用 unclear。\n"
"- ingest:想把本次成果**入库/沉淀/存进知识库**(如「帮我入库」「入库吧」「把方案存进知识库」「沉淀一下」);\n"
"- qa:**提问 / 要求解释、介绍、概述、答疑**,只需用文字回答、不改任何产物(如「介绍一下这报告的内容」"
"「这方案主要讲了啥」「为什么选 OTA 平台」「风险最高的是哪条」「帮我看看有没有遗漏」「总结成几句话」);\n"
"- unclear:**实在**判断不出他想做哪件事(如只说「改一下」「不对」「重来」「再说」且无任何指向)。\n"
"判断要点:\n"
"1. **优先识别出明确意图,不要动不动就 unclear**——只要能合理判断就给具体意图;尤其『介绍/讲讲/解释/说明/"
"为什么/是什么/有没有/帮我看/概述/总结一下』这类是**对内容的提问** → 一律 qa(用文字答),不要误判成重做报告。\n"
"2. 含『节点/连线/这一层/这个目的/行为体/流程图…的 改/删/加/换』 → modify_flow(前提是已有流程图)。\n"
"3. 明确说『报告』要重写/重做才是 regenerate_report;只是问报告内容是 qa。\n"
"4. **入库类意图只有在下文上下文标注「可入库=是」时才允许返回 ingest**;若标注不可入库,用户提入库就归 qa"
"(用文字说明当前不可入库/无适配技能)。\n"
"示例:\n"
'「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重新生成报告"}\n'
'「把目的1的名称改短点」→ {"intent":"modify_flow","reason":"要求修改流程图某节点"}\n'
'「流程图重新画一张」→ {"intent":"regenerate_flow","reason":"要求重画整张流程图"}\n'
'「OTA 平台为什么是直接行为体」→ {"intent":"qa","reason":"纯提问不改产物"}\n'
'「改一下」→ {"intent":"unclear","reason":"没说改报告还是流程图"}'
'「帮我介绍一下这报告中的内容」→ {"intent":"qa","reason":"要求介绍报告内容,属提问答疑"}\n'
'「这方案为什么这么定」→ {"intent":"qa","reason":"询问方案理由"}\n'
'「帮我入库」(可入库=是) → {"intent":"ingest","reason":"明确要求入库且当前可入库"}\n'
'「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重做报告"}\n'
'「把目的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()
if not msg:
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:
model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False)
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()
if intent not in _INTENTS:
intent = "unclear"
# 不可入库时模型若误判 ingest → 降级为 qa(用文字说明当前不可入库),不要触发入库。
if intent == "ingest" and not payload.can_ingest:
intent = "qa"
return {"intent": intent, "reason": _as_str(obj.get("reason"))}
except Exception: # noqa: BLE001 — 分类失败不致命,回退澄清让用户自己定
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 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 deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
@@ -1545,9 +1548,17 @@ async def _leader_run(
if files:
_read_tools = {"read_file", "ls", "grep", "view_image"}
leader_excluded = [t for t in leader_excluded if t not in _read_tools]
body = _run_payload(
# 模型自动容错:按 config.yaml models[] 顺序的候选链。当前模型出问题(兜底错误文案 /
# 既无正文又无派活)时切到下一个模型重跑(见循环内检测)。``_mk_body`` 用候选模型重建 body。
model_chain = fallback_model_chain(model_name) or [model_name]
model_idx = 0
current_model = model_chain[0]
def _mk_body(m: str) -> Any:
return _run_payload(
agent_name=agent_name,
model_name=model_name,
model_name=m,
thread_id=thread_id,
# 注入了席位清单的输入(leader_input);seat_roster 为空时等同 new_message。
new_message=leader_input,
@@ -1560,6 +1571,8 @@ async def _leader_run(
# 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
files=files,
)
body = _mk_body(current_model)
t_payload_built = time.perf_counter()
logger.info(
"[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", ""))
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"(非数组),
# 前端据此暂停 runOrchestration 循环,展示 Step 2 澄清条。
clarification_call = next(
@@ -1663,12 +1698,13 @@ async def _leader_run(
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
"agent_name": agent_name,
"model_used": current_model,
}
return
if not tool_calls:
# 空 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
# 派活前按真实 roster 校验:合法保留、非法丢弃(只丢非法,不殃及同轮合法席位)。
@@ -1704,7 +1740,7 @@ async def _leader_run(
# 关键路径优先:**先 yield 派活帧**,让前端立刻启动席位 special run;派活广播(告知
# 其它席位"总控给 X 派了什么活")改成 detached 后台任务,不再 await 阻塞 —— 否则广播
# 读写各席位巨大 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 派活」广播给兄弟席位 ——
# 否则弱模型从历史里看到派活语句又会模仿派活(席位任务由前端 pickDagSeatTask 干净下发,
# 跨席位承上靠各 special run 的"完成交付"后广播,不需要这条派活广播)。recommend/chain 照旧。
@@ -1737,6 +1773,8 @@ async def _special_run(
thinking_enabled: bool | None = None,
reasoning_effort: str | None = None,
subagent_enabled: bool = False,
seat_skill_directive: bool = False,
business_code: str = "",
) -> AsyncIterator[str | dict[str, Any]]:
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
@@ -1789,13 +1827,30 @@ async def _special_run(
# 弱模型补偿:仅对真正的研讨席位(seat / seat_parallel;report/summary/dashboard/ingest
# 等 Step3 单例不在此列)把其配置技能(名称 + SKILL.md 路径 + 必须先 read_file 再按其
# 流程取信息的硬指令)显式拼进本轮任务,避免弱模型无视系统提示里的技能块(见
# roundtable_seat_skills 模块说明)。无配置技能时原样返回。
# roundtable_seat_skills 模块说明)。默认关闭:是否注入 = 该席位 agent 的开关
# (config.yaml)OR 本次会商所选业务链条的开关(seat_skill_directive 透传)。无配置
# 技能 / 两开关都关时原样返回。
seat_message = new_message
if role in ("seat", "seat_parallel"):
seat_message = append_seat_skill_directive(agent_name, new_message)
body = _run_payload(
seat_message = append_seat_skill_directive(agent_name, new_message, chain_enabled=seat_skill_directive)
# 深链接业务规范(仅 summary 单例):把该业务的「业务链完整性核对」指令拼进本轮任务,强制总结报告
# 核对产出相对业务链完不完整。business_code 为空(普通会商)/ 非写实业务 → directive 为 "",原样不变。
elif role == "summary" and business_code:
directive = build_summary_directive(business_code)
if directive:
seat_message = f"{new_message}\n\n---\n\n{directive}"
# 模型自动容错:按 config.yaml models[] 顺序的候选链。某模型出问题(兜底错误文案;
# 真正的研讨席位还含「交付为空」)→ 发 model_switch 帧让前端重置该席位气泡,再用下一个
# 模型重跑。Step3 单例(report/summary/dashboard/ingest)的产物是落盘文件,正文可能很短,
# 故**不**把「正文为空」当失败(与后台 run_report/summary 的 validate=None 一致),只在真正
# 的模型错误时换模型。
model_chain = fallback_model_chain(model_name) or [model_name]
empty_is_failure = role in ("seat", "seat_parallel")
def _mk_body(m: str) -> Any:
return _run_payload(
agent_name=agent_name,
model_name=model_name,
model_name=m,
thread_id=thread_id,
new_message=seat_message,
excluded_tools=seat_excluded,
@@ -1816,13 +1871,19 @@ async def _special_run(
# 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
files=files,
)
t_payload_built = time.perf_counter()
logger.info(
"[special-timing] %s payload_built=%.3fs",
agent_name,
t_payload_built - t0,
)
# 优先用 display_name(中文友好,例如"情报收集")提示 leader,id 放括号备查;
# display_name 缺失时退化为仅 agent_name —— 与旧行为一致。
speaker_label = f"{display_name}({agent_name})" if display_name else agent_name
for idx, candidate in enumerate(model_chain):
body = _mk_body(candidate)
captured: list[str] = []
first_line_ts: float | None = None
line_count = 0
@@ -1852,7 +1913,7 @@ async def _special_run(
messages = _parse_last_messages(captured)
if messages is None:
# 与 _leader_run 同理:席位 run 被抢占/断开导致流截断,不抛 502,静默收尾。
# WARNING 级:正常是 rollback 抢占;否则查上游 run 的服务端报错(如 DB 连接断)。
# 这不是模型错误(多半 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)",
@@ -1866,9 +1927,25 @@ async def _special_run(
last_msg = msg
break
content = _flatten_content(last_msg.get("content", ""))
# 优先用 display_name(中文友好,例如"情报收集")提示 leader,id 放括号备查;
# display_name 缺失时退化为仅 agent_name —— 与旧行为一致。
speaker_label = f"{display_name}({agent_name})" if display_name else agent_name
# 模型失败检测:兜底错误文案;研讨席位再加「交付为空」。还有备选模型则换。
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 在
@@ -1880,8 +1957,10 @@ async def _special_run(
"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")
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)
# 业务链条级「技能识别强化」开关(仅 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:
raise HTTPException(
@@ -2007,6 +2092,8 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
thinking_enabled=seat_thinking_enabled,
reasoning_effort=seat_reasoning_effort,
subagent_enabled=seat_subagent_enabled,
seat_skill_directive=seat_skill_directive,
business_code=business_code,
)
async for chunk in gen:
if isinstance(chunk, dict):
@@ -48,6 +48,16 @@ class ChainSeatSchema(BaseModel):
# 数组以 JSON 透传持久化(见 persistence/roundtable_chains),故新增此字段无需迁移;
# 仅需在此 schema 显式声明,否则 model_dump() 会把未知字段剥掉。
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):
@@ -64,6 +74,8 @@ class ChainResponse(BaseModel):
coordinator_prompt: str | None = None
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设,选入研讨时回退默认(pro)。
seat_mode: str | None = None
# 研讨前并行取数开关(逐条 opt-in);True = 选入研讨时研讨前先并行取数。缺省 False。
gather_first: bool = False
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
@@ -91,6 +103,7 @@ class ChainCreateRequest(BaseModel):
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
gather_first: bool = False
class ChainUpdateRequest(BaseModel):
@@ -102,6 +115,8 @@ class ChainUpdateRequest(BaseModel):
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
# 研讨前并行取数开关;send 显式 true/false 改,omit 不改。
gather_first: bool | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
@@ -284,6 +299,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"stage_goals": body.stage_goals,
"coordinator_prompt": body.coordinator_prompt,
"seat_mode": body.seat_mode,
"gather_first": body.gather_first,
},
)
await _enrich_owner_names([row])
@@ -77,6 +77,18 @@ class StartJobRequest(BaseModel):
thinking_enabled: bool | None = None
reasoning_effort: str | None = None
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):
@@ -235,6 +247,10 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
seat_thinking_enabled=body.thinking_enabled,
seat_reasoning_effort=body.reasoning_effort,
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)
@@ -24,6 +24,63 @@ from deerflow.config.app_config import AppConfig
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}
_BUSY_PATTERNS = (
"server busy",
@@ -201,17 +258,9 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
return f"大模型因{reason_text}调用失败,正在第 {attempt}/{self.retry_max_attempts} 次重试,{seconds} 秒后继续…"
def _build_circuit_breaker_message(self) -> str:
return "大模型因连续多次调用失败已被熔断保护,请稍候再试。"
return _CIRCUIT_MESSAGE
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
# user-facing message. Raw `Error code: 400 - {...}` strings — especially
# 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
# JSONDecodeError loops. Keep the user message clean; full detail is
# 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:
try:
@@ -246,7 +299,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
if self._check_circuit():
return AIMessage(content=self._build_circuit_breaker_message())
return self._error_message(self._build_circuit_breaker_message(), "circuit")
attempt = 1
while True:
@@ -283,7 +336,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
)
if retriable:
self._record_failure()
return AIMessage(content=self._build_user_message(reason))
return self._error_message(self._build_user_message(reason), reason)
@override
async def awrap_model_call(
@@ -292,7 +345,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
if self._check_circuit():
return AIMessage(content=self._build_circuit_breaker_message())
return self._error_message(self._build_circuit_breaker_message(), "circuit")
attempt = 1
while True:
@@ -329,7 +382,7 @@ class LLMErrorHandlingMiddleware(AgentMiddleware[AgentState]):
)
if retriable:
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:
@@ -23,6 +23,7 @@ import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from .extraction_spec import build_summary_directive
from .progress import JobProgressWriter
from .types import (
CHAIN_CLOSEOUT_PROMPT,
@@ -80,6 +81,8 @@ async def run_orchestration(
intent_text: str = "",
enable_report: bool = True,
plan: dict | None = None,
gather_first: bool = False,
business_code: str | None = None,
) -> OrchestrationResult:
"""运行一次完整编排(循环 → 共识 → 报告绘制)。
@@ -97,23 +100,32 @@ async def run_orchestration(
可解析出非空 stages 时走 ``_run_dag``;否则(dag 但 plan 缺失/为空)回退 recommend。
enable_report: 是否在收口后执行 Step3(方案总结报告 + 结果绘制)。False 时只跑完
Step2 研讨即收尾(step3=None)。两段产物各自故障隔离,详见 ``_run_step3``。
gather_first: 是否在研讨前先跑一轮**全席位并行『取数』**(见 ``_run_gather_phase``),结果作为
共享资料注入各研讨轮席位任务。业务链条逐条 opt-in(默认 False)。三种模式通用——尤其让
chain(串行)的技能延迟前置并行、研讨段提速。
"""
intent = (intent_text or seed_message or "").strip()
try:
await progress.begin()
await progress.diag(
stage="step2_orchestration", level="info", event="begin",
message=f"会商编排启动(mode={mode},{len(agents)} 个席位)",
detail={"mode": mode, "agents": [a.agent_id for a in agents]},
message=f"会商编排启动(mode={mode},{len(agents)} 个席位{',研讨前并行取数' if gather_first else ''})",
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
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":
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:
# 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":
question = outcome.question or "总控需要补充信息,请补充后继续。"
@@ -152,6 +164,7 @@ async def run_orchestration(
consensus_text=outcome.last_leader_content,
deliveries=outcome.deliveries,
coordinator_prompt=(dag_plan.coordinator_prompt if dag_plan is not None else None),
business_code=business_code,
)
await progress.done()
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:
parts: list[str] = []
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)
def _seat_message(intent_text: str, deliveries: dict[str, str], name_by_id: dict[str, str], task: str) -> str:
"""把任务背景 + 前序交付 + 防提问约束拼进席位任务(替代 thread 广播)。"""
def _seat_message(
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] = []
if 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)
if 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)
# ── 研讨前「并行取数」阶段(可选,业务链条逐条 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(
gateway: RoundtableGateway,
progress: JobProgressWriter,
@@ -221,6 +340,8 @@ async def _run_recommend(
model: str | None,
seed_message: str,
intent_text: str,
*,
gathered: dict[str, str] | None = None,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
@@ -235,6 +356,7 @@ async def _run_recommend(
message=message,
model=model,
)
await _emit_switches(progress, turn.switches, agent_name="总控", cycle=cycle)
if turn.final_text:
last_leader = turn.final_text
await progress.add_leader_dialogue(
@@ -246,24 +368,49 @@ async def _run_recommend(
if turn.is_consensus:
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)
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)
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)
try:
seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=seat_msg, model=model)
deliveries[agent_id] = seat.final_text
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():
await progress.diag(
stage="step2_seat", level="warning", event="seat_empty_delivery",
message=f"席位「{name_by_id.get(agent_id, agent_id)}」本轮交付为空(可能模型未产出或被截断)",
agent_id=agent_id, agent_name=name_by_id.get(agent_id, agent_id), cycle=cycle,
message=f"席位「{name}」本轮交付为空(可能模型未产出或被截断)",
agent_id=agent_id, agent_name=name, cycle=cyc,
)
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),
)
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()
# 续轮:把各席位本轮交付喂给总控,让它据实判断(总控 SOUL 依赖看到交付)
@@ -287,6 +434,8 @@ async def _run_chain(
model: str | None,
seed_message: str,
intent_text: str,
*,
gathered: dict[str, str] | None = None,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
@@ -313,6 +462,7 @@ async def _run_chain(
message=leader_task,
model=model,
)
await _emit_switches(progress, turn.switches, agent_name="总控", cycle=i + 1)
if turn.final_text:
last_leader = turn.final_text
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)
# (b) 当前席位交付(顺序,不并行);带上「背景 + 前序交付 + 别提问」上下文
# (b) 当前席位交付(顺序,不并行);带上「背景 +(可选)研讨前并行取数资料 + 前序交付 + 别提问」上下文。
# chain 严格串行(seat B 看 seat A 交付),但有 gathered 时各席位已带共享资料,不必各自从零检索 → 提速。
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)
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
if not (seat_turn.final_text or "").strip():
await progress.diag(
@@ -354,6 +520,7 @@ async def _run_chain(
message=closeout_msg,
model=model,
)
await _emit_switches(progress, closeout.switches, agent_name="总控", cycle=total + 1)
if closeout.final_text:
last_leader = closeout.final_text
await progress.add_leader_dialogue(
@@ -376,6 +543,8 @@ async def _run_dag(
seed_message: str,
intent_text: str,
plan: DagPlan,
*,
gathered: dict[str, str] | None = None,
) -> _LoopOutcome:
"""分层 DAG 编排:每个 stage 一轮总控派活(钉死本批席位)→ 批内席位**并行**执行
(``asyncio.gather``)→ 下一 stage 总控再派;全部 stage 完成后由总控收口综合。
@@ -416,6 +585,7 @@ async def _run_dag(
message=leader_task,
model=model,
)
await _emit_switches(progress, turn.switches, agent_name="总控", cycle=i + 1)
if turn.final_text:
last_leader = turn.final_text
await progress.add_leader_dialogue(
@@ -429,22 +599,35 @@ async def _run_dag(
for seat in stage_seats:
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)
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task)
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task, gathered=_gathered)
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
if not (seat_turn.final_text or "").strip():
await progress.diag(
stage="step2_seat", level="warning", event="seat_empty_delivery",
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(
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),
)
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))
@@ -462,6 +645,7 @@ async def _run_dag(
message=closeout_msg,
model=model,
)
await _emit_switches(progress, closeout.switches, agent_name="总控", cycle=total + 1)
if closeout.final_text:
last_leader = closeout.final_text
await progress.add_leader_dialogue(
@@ -484,6 +668,7 @@ async def _run_step3(
consensus_text: str,
deliveries: dict[str, str],
coordinator_prompt: str | None = None,
business_code: str | None = None,
) -> dict | None:
"""Step3 双产物:「方案总结报告」(roundtable-summary 写 md)与「结果绘制」
(roundtable-report 画 HTML)**并行执行**(两段无依赖,2026-06-13 由串行改并行,
@@ -504,11 +689,13 @@ async def _run_step3(
await progress.summary_writing()
summary_thread_id = await gateway.create_summary_thread()
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_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():
step3["summaryReport"] = {
"md": summary.md or "",
@@ -592,6 +779,7 @@ async def _run_report(
report_thread_id = await gateway.create_report_thread()
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)
await _emit_switches(progress, getattr(report, "switches", None), agent_name="结果绘制")
return {
"html": report.html,
"generatedAt": report.generated_at or datetime.now(UTC).isoformat(),
@@ -659,6 +847,7 @@ def build_summary_prompt(
consensus_text: str,
*,
coordinator_prompt: str | None = None,
business_code: str | None = None,
) -> str:
"""拼装 Step3「方案总结报告」prompt —— 对齐前端 ``useStep3Summary.buildSummaryPrompt``
(非流程图分支)+ ``buildMaterials`` 的原料结构,使后台产出与手动调用一致。"""
@@ -693,6 +882,22 @@ def build_summary_prompt(
lines.append("# 三、总控最终共识结论")
lines.append((consensus_text or "").strip() or "(未提供)")
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(
"请严格遵循你的职责设定:纯 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)
await self._persist()
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
@dataclass
class ModelSwitch:
"""一次「模型自动容错」记录:当前模型出问题、已切到下一个模型继续。
由网关(单轮执行器)在换模型时产出,挂在各 Turn/Result 上带回引擎;引擎据此调
``JobProgressWriter.model_switch(...)`` 往研讨时间线插系统提示并落诊断。``reason_label``
已由 app 层翻译成简短中文(harness 不依赖 app 文案表)。
"""
failed_model: str
next_model: str
reason_label: str
@dataclass
class LeaderTurn:
"""总控一轮执行的归一化结果(由 gateway.run_leader 产出)。"""
@@ -55,6 +69,8 @@ class LeaderTurn:
# 本轮总控调用的工具(按出现顺序,每项 {name, args, id})——通常是 agent_orchestration
# 派活 / ask_clarification。用于在草稿 run 里重建「工具步骤卡」,与前端手动跑一致。
tool_calls: list[dict] = field(default_factory=list)
# 本轮发生的模型自动容错记录(按发生顺序);引擎据此向用户提示「已切换模型」。
switches: list[ModelSwitch] = field(default_factory=list)
@property
def is_clarification(self) -> bool:
@@ -73,6 +89,8 @@ class SeatTurn:
# 本轮席位调用的工具(按出现顺序,每项 {name, args, id})——用于在草稿 run 里
# 重建「工具步骤卡」+ write_file stub(点开产物),与前端手动跑一致。
tool_calls: list[dict] = field(default_factory=list)
# 本轮发生的模型自动容错记录(按发生顺序)。
switches: list[ModelSwitch] = field(default_factory=list)
@dataclass
@@ -83,6 +101,7 @@ class ReportResult:
summary: str | None = None
model: str | None = None
generated_at: str | None = None
switches: list[ModelSwitch] = field(default_factory=list)
@dataclass
@@ -99,6 +118,7 @@ class SummaryResult:
model: str | None = None
thread_id: 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 += [
f"【本阶段目标】{goal_text}",
"请据此目标为每个席位拆解任务,并要求他们显式承接上一阶段的产出来完成本阶段目标。",
"⚠️ 派活时**严格忠实于本阶段目标,不要自行新增目标里没有的「数量上限 / 只取最有代表性的 N 组 / 精简归纳成几条」等限制**,"
"也不要照搬其它阶段的数量(如上一阶段筛了 3 组,不代表本阶段也只要 3 组);"
"目标没要求筛选 / 限定数量时,就要求席位**按其职责把每一条都完整输出**——"
"若上一阶段有 N 条产出需逐条处理,就让席位**逐条、全部**产出(有多少条就出多少条),绝不替席位归纳 / 合并 / 只取代表性的几条。",
"⚠️ 也**不要替席位新增目标 / 席位职责之外的「输出体例 / 风格」要求**(如『写成连贯故事 / 要有场景感 / "
"举具体城市景区为例 / 虚构时间地点情节』等)——派活只需转达本阶段目标 + 上一阶段产物 + 承接关系,"
"**让席位严格按它自己的职责设定(SOUL)输出**,且**忠实基于上一阶段的真实产物做分析,不要诱导席位脱离输入去编造/ 虚构事例**。",
]
lines += [
f"请你作为总控,通过 agent_orchestration 技能**分别**向上述{'这些席位' if is_parallel else '席位'}派活,"
@@ -103,6 +103,12 @@ class AgentConfig(BaseModel):
# 入库模板名(外部知识入库接口的 templateName)。配置后该智能体会话中的
# Markdown 产物可一键发布到外部入库接口;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:
@@ -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)。选入研讨时
# 弹窗默认用它(仍可临时改)。NULL = 未设,调用方回退默认(pro)。短串,VARCHAR(16)。
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 = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column(
@@ -58,6 +58,8 @@ class RoundtableChainRepository:
"coordinator_prompt": getattr(row, "coordinator_prompt", None),
# 默认席位执行模式(flash/thinking/pro/ultra);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_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")),
coordinator_prompt=(data.get("coordinator_prompt") 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),
created_at=now,
updated_at=now,
@@ -135,6 +138,7 @@ class RoundtableChainRepository:
stage_goals=_UNSET,
coordinator_prompt=_UNSET,
seat_mode=_UNSET,
gather_first=_UNSET,
is_public=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -155,6 +159,8 @@ class RoundtableChainRepository:
row.coordinator_prompt = coordinator_prompt or None
if seat_mode is not _UNSET:
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:
row.is_public = bool(is_public)
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。
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 / 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)
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:
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:
@@ -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