Merge branch 'master-nj' into 'master'

feat: 岗位会商独立意图智能体与四项意图 schema

See merge request knowledge-base-bj/deerflow-code!18
This commit is contained in:
董宏凯 committed 2026-08-11 05:00:55 +00:00
commit 17a12bb7b6
24 files changed
+1258 -257

No files matched your search

@@ -121,6 +121,31 @@ PositionRoundtablePage
后端会冻结链条快照并根据链条阶段创建节点。已启动后意图不可再修改;需要重新识别时必须新建会话。
The position workspace selects the dedicated built-in `position-roundtable-intent`
agent by sending `intent_agent_id` through the shared Step-1 init and stream
requests. The thread lifecycle, file upload, and SSE transport stay identical
to the generic multi-agent flow. The dedicated agent permits at most five
user-assistance rounds; one round may contain multiple independent
`ask_clarification` cards. `Step1Panel` renders every card separately and
submits the selected answers together, so cards must never be merged into one
numbered multi-question card. Only its final `[INTENT_READY]` schema changes to
the following four string arrays:
```json
{
"coreGoals": ["..."],
"riskWarnings": ["..."],
"keyPoints": ["..."],
"strategicSignificance": ["..."]
}
```
The position sidebar and edit dialog render these four fields. Business-chain
activation requires all four arrays and at least one core goal or strategic
significance. Historic `objective` / `constraints` / `assumptions` snapshots
are read through a compatibility mapping only; ordinary multi-agent Step 1 is
not changed.
### 4.2 普通节点问答
`PositionNodeChat` 在发送前调用 `getPositionNodeContext` 获取受长度控制的任务、意图和已交付上游内容,再以 `task_external_context` 作为隐藏上下文发送给当前智能体。
@@ -111,7 +111,11 @@ import {
type InteractiveMessage,
type TimelineEntry,
} from "@/roundtable-planning/hooks/useStep1Intent";
import { INTENT_AGENT_ID, type IntentSummary } from "@/roundtable-planning/api/intent";
import {
POSITION_INTENT_AGENT_ID,
type IntentSummary,
type PositionIntentSummary,
} from "@/roundtable-planning/api/intent";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import {
@@ -197,9 +201,10 @@ function isSessionInTaskScope(
type ClosureStageKind = PositionBuiltinAgentKind;
type IntentEditDraft = {
objective: string;
constraints: string;
assumptions: string;
coreGoals: string;
riskWarnings: string;
keyPoints: string;
strategicSignificance: string;
};
type SandboxThreadSnapshot = {
threadId: string;
@@ -682,16 +687,38 @@ function mergePositionSession(
return { ...current, ...next, nodes: current.nodes };
}
function parseIntentSummary(snapshot: Record<string, unknown> | null | undefined): IntentSummary | null {
if (!snapshot || typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
function intentStringList(snapshot: Record<string, unknown>, key: string): string[] {
const value = snapshot[key];
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string" && Boolean(item.trim()))
: [];
}
function parsePositionIntentSummary(
snapshot: Record<string, unknown> | null | undefined,
): PositionIntentSummary | null {
if (!snapshot) return null;
const positionKeys = ["coreGoals", "riskWarnings", "keyPoints", "strategicSignificance"];
if (positionKeys.some((key) => key in snapshot)) {
if (!positionKeys.every((key) => Array.isArray(snapshot[key]))) return null;
return {
coreGoals: intentStringList(snapshot, "coreGoals"),
riskWarnings: intentStringList(snapshot, "riskWarnings"),
keyPoints: intentStringList(snapshot, "keyPoints"),
strategicSignificance: intentStringList(snapshot, "strategicSignificance"),
};
}
// Existing sessions created before the dedicated position agent used the
// shared Step-1 schema. Keep them readable and editable after the switch.
if (typeof snapshot.objective !== "string" || !Array.isArray(snapshot.constraints)) {
return null;
}
return {
objective: snapshot.objective,
constraints: snapshot.constraints.filter((item): item is string => typeof item === "string"),
assumptions: Array.isArray(snapshot.assumptions)
? snapshot.assumptions.filter((item): item is string => typeof item === "string")
: undefined,
coreGoals: intentStringList(snapshot, "constraints"),
riskWarnings: [],
keyPoints: intentStringList(snapshot, "assumptions"),
strategicSignificance: snapshot.objective.trim() ? [snapshot.objective.trim()] : [],
};
}
@@ -973,9 +1000,10 @@ export default function PositionRoundtablePage({
const [taskSaving, setTaskSaving] = useState(false);
const [intentEditorOpen, setIntentEditorOpen] = useState(false);
const [intentDraft, setIntentDraft] = useState<IntentEditDraft>({
objective: "",
constraints: "",
assumptions: "",
coreGoals: "",
riskWarnings: "",
keyPoints: "",
strategicSignificance: "",
});
const [intentSaving, setIntentSaving] = useState(false);
const [intentCompletedAt, setIntentCompletedAt] = useState("");
@@ -1045,7 +1073,7 @@ export default function PositionRoundtablePage({
[],
);
const frozenIntent = useMemo(
() => parseIntentSummary(positionSession?.intentSnapshot),
() => parsePositionIntentSummary(positionSession?.intentSnapshot),
[positionSession?.intentSnapshot],
);
const task = useMemo(
@@ -1106,12 +1134,23 @@ export default function PositionRoundtablePage({
? 1
: 0,
selectedModel,
intentAgentId: POSITION_INTENT_AGENT_ID,
taskContext: intentTaskContext,
initialWelcomeText:
"我已读取左侧任务信息。你可以直接补充本次任务的特殊约束;如果没有补充,我会基于现有任务信息进行意图识别。",
showInitialWelcome: false,
triggerToast,
});
const livePositionIntent = useMemo(
() =>
parsePositionIntentSummary(
intent.intentReady as unknown as Record<string, unknown> | null,
),
[intent.intentReady],
);
const positionIntent = frozenIntent ?? livePositionIntent;
const positionIntentHeadline =
positionIntent?.strategicSignificance[0] ?? positionIntent?.coreGoals[0];
// `useStep1Intent` starts with an empty list for a new intent session. Keep
// the complete list here so a restored historical session can display its
// first agent turn as well as the user's follow-up turns. The old
@@ -1258,23 +1297,31 @@ export default function PositionRoundtablePage({
const openIntentEditor = useCallback(() => {
if (isArchivedSession) return;
const currentIntent = frozenIntent ?? intent.intentReady;
const currentIntent = positionIntent;
setIntentDraft({
objective: currentIntent?.objective ?? "",
constraints: (currentIntent?.constraints ?? []).join("\n"),
assumptions: (currentIntent?.assumptions ?? []).join("\n"),
coreGoals: (currentIntent?.coreGoals ?? []).join("\n"),
riskWarnings: (currentIntent?.riskWarnings ?? []).join("\n"),
keyPoints: (currentIntent?.keyPoints ?? []).join("\n"),
strategicSignificance: (currentIntent?.strategicSignificance ?? []).join("\n"),
});
setIntentEditorOpen(true);
}, [frozenIntent, intent.intentReady, isArchivedSession]);
}, [isArchivedSession, positionIntent]);
const handleSaveIntentEdit = useCallback(async () => {
if (isArchivedSession) {
toast.warning("已归档的会话不能编辑意图理解");
return;
}
const objective = intentDraft.objective.trim();
if (!objective) {
toast.warning("请填写任务意图");
const coreGoals = intentDraft.coreGoals
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
const strategicSignificance = intentDraft.strategicSignificance
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
if (coreGoals.length === 0 && strategicSignificance.length === 0) {
toast.warning("请至少填写核心目标或战略意义");
return;
}
const splitLines = (value: string) =>
@@ -1282,10 +1329,11 @@ export default function PositionRoundtablePage({
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
const nextIntent: IntentSummary = {
objective,
constraints: splitLines(intentDraft.constraints),
assumptions: splitLines(intentDraft.assumptions),
const nextIntent: PositionIntentSummary = {
coreGoals,
riskWarnings: splitLines(intentDraft.riskWarnings),
keyPoints: splitLines(intentDraft.keyPoints),
strategicSignificance,
};
setIntentSaving(true);
@@ -1309,7 +1357,7 @@ export default function PositionRoundtablePage({
});
intent.hydrateFromDraft({
intentThreadId: intentThreadId ?? intent.intentThreadId,
intentReady: nextIntent,
intentReady: nextIntent as unknown as IntentSummary,
interactiveMessages: intent.interactiveMessages,
timelineEntries: [],
});
@@ -1325,9 +1373,10 @@ export default function PositionRoundtablePage({
ensureEditableSession,
intent,
intentCompletedAt,
intentDraft.assumptions,
intentDraft.constraints,
intentDraft.objective,
intentDraft.coreGoals,
intentDraft.keyPoints,
intentDraft.riskWarnings,
intentDraft.strategicSignificance,
intentInputDraft,
isArchivedSession,
task,
@@ -1384,7 +1433,7 @@ export default function PositionRoundtablePage({
// multi-agent roundtable Step 1 flow. Do not derive this from a chain seat:
// older chains may have no seat agent id (or a removed legacy id), which
// would make the configuration panel request a non-existent agent.
const intentAgentId = INTENT_AGENT_ID;
const intentAgentId = POSITION_INTENT_AGENT_ID;
const isIntelligenceDeliveryOverview =
activePosition.isIntentPosition && positionSession !== null && positionSession.status !== "intent_pending";
const showIntentDeliveryOverview =
@@ -1620,7 +1669,11 @@ export default function PositionRoundtablePage({
view.timelineEntries,
view.inputDraft,
);
const intentSnapshot = view.intentReady ? { ...view.intentReady } : null;
// 岗位会商持久化的是专属四段式结果;useStep1Intent 为兼容普通会商
// 补出的 objective / constraints / assumptions 不应写回岗位会商快照。
const intentSnapshot = parsePositionIntentSummary(
view.intentReady as unknown as Record<string, unknown> | null,
);
const operation = async () => {
const baseSession = candidateSession ?? (await ensureEditableSession(task));
@@ -1772,7 +1825,8 @@ export default function PositionRoundtablePage({
candidates.sort((left, right) => right.length - left.length)[0] ?? [];
hydrateIntentFromDraft({
intentThreadId: intentThreadId ?? latest.intentThreadId,
intentReady: latestFrozenIntentRef.current ?? latest.intentReady,
intentReady:
(latestFrozenIntentRef.current as unknown as IntentSummary | null) ?? latest.intentReady,
interactiveMessages:
interactiveMessages.length > 0
? interactiveMessages
@@ -1799,7 +1853,8 @@ export default function PositionRoundtablePage({
: [];
hydrateIntentFromDraft({
intentThreadId: intentThreadId ?? latest.intentThreadId,
intentReady: latestFrozenIntentRef.current ?? latest.intentReady,
intentReady:
(latestFrozenIntentRef.current as unknown as IntentSummary | null) ?? latest.intentReady,
interactiveMessages: fallbackMessages,
timelineEntries: databaseTimeline,
});
@@ -1997,7 +2052,7 @@ export default function PositionRoundtablePage({
};
}, [searchParamsString, sessionIdFromUrl, setSearchParams, taskScopeId]);
const agentIdForPanel = activePosition.isIntentPosition ? INTENT_AGENT_ID : selectedAgentId;
const agentIdForPanel = activePosition.isIntentPosition ? POSITION_INTENT_AGENT_ID : selectedAgentId;
const { agent: selectedAgent } = useAgent(agentIdForPanel, {
enabled: !standaloneDemoMode,
});
@@ -2009,7 +2064,7 @@ export default function PositionRoundtablePage({
if (!activePosition.isIntentPosition) return null;
const seat = activeChain?.seats.find((item) => item.position_id === intentPositionId);
return {
id: INTENT_AGENT_ID,
id: POSITION_INTENT_AGENT_ID,
name: seat?.name || `${activePosition.name}智能体`,
description: seat?.description || activePosition.description,
user_id: null,
@@ -2072,7 +2127,7 @@ export default function PositionRoundtablePage({
let session = await ensureEditableSession(task);
session = await savePositionIntent(session.id, {
intentThreadId: intent.intentThreadId,
intentSnapshot: intent.intentReady,
intentSnapshot: positionIntent,
conversationSnapshot: buildIntentConversationSnapshot(
intent.interactiveMessages,
intent.timelineEntries,
@@ -2415,17 +2470,17 @@ export default function PositionRoundtablePage({
[handlePreviewArtifact],
);
const openIntentFlowDetail = useCallback(() => {
if (!(frozenIntent ?? intent.intentReady)) {
if (!positionIntent) {
toast.info("任务意图还在梳理中,完成确认后就能查看完整的意图说明。");
return;
}
setFlowDetailItem(
buildIntentResultItem(
frozenIntent ?? intent.intentReady,
positionIntent,
intentCompletedAt || positionSession?.updatedAt,
),
);
}, [frozenIntent, intent.intentReady, intentCompletedAt, positionSession?.updatedAt]);
}, [positionIntent, intentCompletedAt, positionSession?.updatedAt]);
const openNodeFlowDetail = useCallback(
(node: PositionRoundtableNode) => {
const item = buildNodeResultItem(node, activeChain);
@@ -2642,7 +2697,7 @@ export default function PositionRoundtablePage({
positionName={activePosition.name}
specialAgentConfig
onSelectAgent={handleSelectAgent}
intentObjective={frozenIntent?.objective ?? intent.intentReady?.objective}
intentObjective={positionIntentHeadline}
intentCompletedAt={intentCompletedAt}
assignedAgentIds={configurationAgentIds}
hasSelectedChain={showClosureAgents || Boolean(selectedChain)}
@@ -2684,7 +2739,7 @@ export default function PositionRoundtablePage({
}
interactiveMessages={visibleIntentMessages}
intentThreadId={intent.intentThreadId}
intentReady={chainStarted ? frozenIntent ?? intent.intentReady : intent.intentReady}
intentReady={chainStarted ? positionIntent : intent.intentReady}
isIntentSending={intent.isIntentSending}
pendingIntentClarificationMsgId={intent.pendingIntentClarificationMsgId}
intentClarificationSelections={intent.intentClarificationSelections}
@@ -2974,7 +3029,7 @@ export default function PositionRoundtablePage({
/>
{showIntentDeliveryOverview ? (
<IntentDeliveryOverviewSidebar
intent={frozenIntent ?? intent.intentReady}
intent={positionIntent}
chain={activeChain}
nodes={positionSession?.nodes ?? []}
activeNodeKey={activeNodeKey}
@@ -3106,7 +3161,7 @@ export default function PositionRoundtablePage({
positionName={activePosition.name}
specialAgentConfig={activePosition.isIntentPosition}
onSelectAgent={handleSelectAgent}
intentObjective={frozenIntent?.objective ?? intent.intentReady?.objective}
intentObjective={positionIntentHeadline}
intentCompletedAt={intentCompletedAt}
assignedAgentIds={configurationAgentIds}
hasSelectedChain={showClosureAgents || Boolean(selectedChain)}
@@ -3699,7 +3754,7 @@ function PositionNodeEmptyState({
}
/** 已启动会话中的意图是冻结快照;避免在情报岗视图意外覆盖它。 */
function IntentConfirmedState({ intent }: { intent: IntentSummary }) {
function IntentConfirmedState({ intent }: { intent: PositionIntentSummary }) {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-slate-50/50 px-6 py-7 dark:bg-[#032650]/30">
<div className="mx-auto w-full max-w-2xl rounded-2xl border border-emerald-500/20 bg-white p-5 shadow-sm dark:border-emerald-400/25 dark:bg-[#042b59]/65">
@@ -3715,15 +3770,15 @@ function IntentConfirmedState({ intent }: { intent: IntentSummary }) {
</div>
</div>
<div className="mt-5 rounded-xl border border-slate-100 bg-slate-50/70 p-4 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/40">
<p className="text-[11px] font-semibold text-muted-foreground dark:text-[#8aa0c4]">任务目标</p>
<p className="mt-1.5 text-sm font-semibold leading-6 text-slate-800 dark:text-[#e8edf5]">
{desensitize(intent.objective)}
</p>
<p className="text-[11px] font-semibold text-muted-foreground dark:text-[#8aa0c4]">核心目标</p>
<div className="mt-1.5 space-y-1.5 text-sm font-semibold leading-6 text-slate-800 dark:text-[#e8edf5]">
{intent.coreGoals.map((goal, index) => <p key={`${goal}-${index}`}>{desensitize(goal)}</p>)}
</div>
</div>
<div className="mt-4">
<p className="text-[11px] font-semibold text-muted-foreground dark:text-[#8aa0c4]">关键约束</p>
<p className="text-[11px] font-semibold text-muted-foreground dark:text-[#8aa0c4]">风险提示</p>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{intent.constraints.map((constraint, index) => (
{intent.riskWarnings.map((constraint, index) => (
<div
key={`${constraint}-${index}`}
className="flex items-start gap-2 rounded-lg border border-slate-100 bg-white px-3 py-2 text-xs leading-5 text-slate-700 dark:border-[#2b8ffe]/20 dark:bg-[#032650]/35 dark:text-[#c5d8f1]"
@@ -3760,7 +3815,7 @@ function PositionDeliveryOverview({
chain,
session,
}: {
intent: IntentSummary | null;
intent: PositionIntentSummary | null;
intentCompletedAt?: string;
nodes: PositionRoundtableNode[];
chain: BusinessChain | null;
@@ -3893,7 +3948,10 @@ function PositionDeliveryOverview({
kind: "intent",
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intent?.objective || "情报分析岗已确认任务意图并冻结业务链。",
summary:
intent?.strategicSignificance[0] ||
intent?.coreGoals[0] ||
"情报分析岗已确认任务意图并冻结业务链。",
positionId: intentPositionIdFor(),
updatedAt: intentCompletedAt || session?.updatedAt,
status: intent ? "done" : "ready",
@@ -4821,7 +4879,7 @@ function IntentUnderstandingCard({
collapsed,
onCollapsedChange,
}: {
intent: ReturnType<typeof useStep1Intent>["intentReady"];
intent: PositionIntentSummary | null;
editable?: boolean;
onEdit?: () => void;
collapsed: boolean;
@@ -4863,30 +4921,21 @@ function IntentUnderstandingCard({
<div className="min-h-0 overflow-hidden">
{intent ? (
<div className="space-y-3 p-4">
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务意图</p>
<p className="mt-1 text-sm leading-6 text-slate-700 dark:text-[#f2f7ff]">
{desensitize(intent.objective)}
</p>
</div>
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">关键约束</p>
<ul className="mt-1 list-disc space-y-1 pl-4 text-sm leading-6 text-slate-600 marker:text-slate-400 dark:text-[#d8e6f8] dark:marker:text-[#83b6ed]">
{intent.constraints.map((constraint, index) => (
<li key={`${constraint}-${index}`}>{desensitize(constraint)}</li>
))}
</ul>
</div>
{intent.assumptions && intent.assumptions.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">关键假设</p>
<ul className="mt-1 list-disc space-y-1 pl-4 text-sm leading-6 text-slate-600 marker:text-slate-400 dark:text-[#d8e6f8] dark:marker:text-[#83b6ed]">
{intent.assumptions.map((assumption, index) => (
<li key={`${assumption}-${index}`}>{desensitize(assumption)}</li>
))}
</ul>
{([
["核心目标", intent.coreGoals],
["风险提示", intent.riskWarnings],
["关键要点", intent.keyPoints],
["战略意义", intent.strategicSignificance],
] as const).map(([label, values]) => (
<div key={label}>
<p className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">{label}</p>
{values.length > 0 ? (
<ul className="mt-1 list-disc space-y-1 pl-4 text-sm leading-6 text-slate-600 marker:text-slate-400 dark:text-[#d8e6f8] dark:marker:text-[#83b6ed]">
{values.map((value, index) => <li key={`${value}-${index}`}>{desensitize(value)}</li>)}
</ul>
) : <p className="mt-1 text-sm text-slate-500 dark:text-[#9ebfe2]">暂无{label}</p>}
</div>
)}
))}
</div>
) : (
<div className="p-4 text-sm leading-6 text-muted-foreground dark:text-[#d8e6f8]">
@@ -4907,16 +4956,17 @@ function IntentDeliveryOverviewSidebar({
onSelectNode,
onSelectSeat,
}: {
intent: IntentSummary | null;
intent: PositionIntentSummary | null;
chain: BusinessChain | null;
nodes: PositionRoundtableNode[];
activeNodeKey: string | null;
onSelectNode: (node: PositionRoundtableNode) => void;
onSelectSeat: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
}) {
const objective = intent?.objective?.trim() || "暂无任务目的";
const coreGoals = intent?.constraints?.filter(Boolean) ?? [];
const keyConstraints = intent?.assumptions?.filter(Boolean) ?? [];
const coreGoals = intent?.coreGoals ?? [];
const riskWarnings = intent?.riskWarnings ?? [];
const keyPoints = intent?.keyPoints ?? [];
const strategicSignificance = intent?.strategicSignificance ?? [];
return (
<section className="position-roundtable-intent-delivery-sidebar">
@@ -4926,10 +4976,6 @@ function IntentDeliveryOverviewSidebar({
<h2>意图理解结果</h2>
</header>
<div className="position-roundtable-intent-delivery-sections">
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--purpose">
<h3>任务目的</h3>
<p>{desensitize(objective)}</p>
</article>
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--goals">
<h3>核心目标</h3>
{coreGoals.length > 0 ? (
@@ -4943,17 +4989,25 @@ function IntentDeliveryOverviewSidebar({
)}
</article>
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--constraints">
<h3>关键约束</h3>
{keyConstraints.length > 0 ? (
<h3>风险提示</h3>
{riskWarnings.length > 0 ? (
<ul>
{keyConstraints.map((constraint, index) => (
<li key={`${constraint}-${index}`}>{desensitize(constraint)}</li>
{riskWarnings.map((warning, index) => (
<li key={`${warning}-${index}`}>{desensitize(warning)}</li>
))}
</ul>
) : (
<p className="is-empty">暂无补充约束</p>
<p className="is-empty">暂无风险提示</p>
)}
</article>
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--purpose">
<h3>关键要点</h3>
{keyPoints.length > 0 ? <ul>{keyPoints.map((item, index) => <li key={`${item}-${index}`}>{desensitize(item)}</li>)}</ul> : <p className="is-empty">暂无关键要点</p>}
</article>
<article className="position-roundtable-intent-delivery-section position-roundtable-intent-delivery-section--significance">
<h3>战略意义</h3>
{strategicSignificance.length > 0 ? <ul>{strategicSignificance.map((item, index) => <li key={`${item}-${index}`}>{desensitize(item)}</li>)}</ul> : <p className="is-empty">暂无战略意义</p>}
</article>
</div>
</div>
@@ -4999,34 +5053,43 @@ function IntentUnderstandingEditDialog({
</DialogHeader>
<div className="space-y-4 py-1">
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">任务意图</span>
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">核心目标</span>
<Textarea
value={draft.objective}
onChange={(event) => onChange({ ...draft, objective: event.target.value })}
value={draft.coreGoals}
onChange={(event) => onChange({ ...draft, coreGoals: event.target.value })}
className="min-h-24 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="请输入识别后的任务意图"
placeholder="每行一项核心目标"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">
关键约束
风险提示
</span>
<Textarea
value={draft.constraints}
onChange={(event) => onChange({ ...draft, constraints: event.target.value })}
value={draft.riskWarnings}
onChange={(event) => onChange({ ...draft, riskWarnings: event.target.value })}
className="min-h-28 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="每行一条关键约束"
placeholder="每行一项风险、依赖或待确认事项"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">
补充假设 / 说明
关键要点
</span>
<Textarea
value={draft.assumptions}
onChange={(event) => onChange({ ...draft, assumptions: event.target.value })}
value={draft.keyPoints}
onChange={(event) => onChange({ ...draft, keyPoints: event.target.value })}
className="min-h-20 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="可选,每行一条"
placeholder="每行一项关键要点"
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-semibold text-slate-500 dark:text-[#9ebfe2]">战略意义</span>
<Textarea
value={draft.strategicSignificance}
onChange={(event) => onChange({ ...draft, strategicSignificance: event.target.value })}
className="min-h-20 resize-none dark:border-[#2b8ffe]/35 dark:bg-[#032650]/80"
placeholder="每行一项战略意义"
/>
</label>
</div>
@@ -5983,7 +6046,7 @@ function deliverableFallbackContent(item: ResultItem | null): string {
}
function buildIntentResultItem(
intent: IntentSummary | null,
intent: PositionIntentSummary | null,
updatedAt: string | undefined,
): ResultItem {
return {
@@ -5992,7 +6055,10 @@ function buildIntentResultItem(
node: null,
agentName: "情报分析智能体",
productName: "任务意图理解",
summary: intent?.objective || "任务意图已确认,当前没有更多意图摘要内容。",
summary:
intent?.strategicSignificance[0] ||
intent?.coreGoals[0] ||
"任务意图已确认,当前没有更多意图摘要内容。",
positionId: intentPositionIdFor(),
updatedAt,
status: intent ? "done" : "ready",
@@ -397,6 +397,15 @@
color: #33bc92;
}
.position-roundtable-intent-delivery-section--significance {
border-left: 4px solid #45c3f3;
background: rgba(0, 98, 217, 0.12);
}
.position-roundtable-intent-delivery-section--significance h3 {
color: #45c3f3;
}
.position-roundtable-intent-delivery-section .is-empty {
color: rgba(255, 255, 255, 0.62);
}
@@ -4,11 +4,14 @@ import { getBackendBaseURL } from "@/core/config";
import type { ClarificationPayload } from "./multi-agent";
import { reportRoundtableError } from "./diagnostics-report";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { normalizeIntentReadySummary } from "@/roundtable-planning/lib/roundtable-constants";
export type { ClarificationPayload };
/** Stable built-in agent used by the multi-agent roundtable Step 1 flow. */
export const INTENT_AGENT_ID = "roundtable-intent";
/** Dedicated Step 1 agent for the position-roundtable workspace. */
export const POSITION_INTENT_AGENT_ID = "position-roundtable-intent";
/**
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
@@ -103,13 +106,18 @@ function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
export async function initIntent(
model?: string,
taskContext?: Record<string, unknown> | null,
intentAgentId?: string,
): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
// 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
// 业务链,第一阶段的对话也已经可以按 taskId 恢复。
body: JSON.stringify({ model, task_context: taskContext ?? undefined }),
body: JSON.stringify({
model,
task_context: taskContext ?? undefined,
intent_agent_id: intentAgentId || undefined,
}),
});
if (!res.ok) {
const raw = await res.text().catch(() => "");
@@ -140,6 +148,19 @@ export interface IntentSummary {
constraints: string[];
/** Optional assumptions the agent is making on the user's behalf. */
assumptions?: string[];
/** Present only for the dedicated position-roundtable intent agent. */
coreGoals?: string[];
riskWarnings?: string[];
keyPoints?: string[];
strategicSignificance?: string[];
}
/** Final hand-off shape emitted only by the position-roundtable intent agent. */
export interface PositionIntentSummary {
coreGoals: string[];
riskWarnings: string[];
keyPoints: string[];
strategicSignificance: string[];
}
/**
@@ -167,6 +188,8 @@ export interface StreamIntentRequest {
threadId: string;
message: string;
model?: string;
/** Explicit built-in Step-1 agent. Omit to retain the multi-agent default. */
intentAgentId?: string;
/** Optional hidden business/task context injected into the model run. */
taskContext?: Record<string, unknown> | null;
/** Step 1 用户上传的参考文件(可选)。 */
@@ -189,8 +212,7 @@ export interface StreamIntentRequest {
* Fires for every `ask_clarification` tool message parsed out of the live
* stream (the intent agent may emit several in a single turn). Lets the UI
* surface each assistance question in real-time instead of waiting for the
* terminal frame — which only carries the *first* question. `index` is the
* 0-based arrival order within this turn.
* terminal frame. `index` is the 0-based arrival order within this turn.
*/
onClarification?: (clarification: ClarificationPayload, index: number) => void;
}
@@ -211,8 +233,8 @@ export interface StreamIntentResult {
clarification: ClarificationPayload | null;
/**
* All `ask_clarification` questions the agent asked this turn, in arrival
* order. Collected from the live `type:"tool"` frames (the terminal frame
* only carries the first). Empty unless status === "clarification".
* order. Collected live and reconciled against the terminal frame so a
* reconnect cannot lose cards. Empty unless status === "clarification".
*/
clarifications: ClarificationPayload[];
/**
@@ -238,6 +260,7 @@ function isFinalIntentFrame(value: unknown): value is {
options?: unknown[];
allow_custom?: boolean;
allow_multiple?: boolean;
clarifications?: unknown[];
error_detail?: unknown;
} {
if (!value || typeof value !== "object") return false;
@@ -330,6 +353,41 @@ function extractClarificationToolMessage(
};
}
/**
* Normalize one clarification included in the terminal status frame. This is
* the reconnect / proxy fallback for cases where one or more live `tool`
* frames were not delivered to the browser.
*/
function parseTerminalClarification(
value: unknown,
): { payload: ClarificationPayload; toolCallId: string } | null {
if (!value || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
const clarificationType =
typeof raw.clarification_type === "string" ? raw.clarification_type : "";
const toolCallId = typeof raw.id === "string" ? raw.id : "";
return {
toolCallId,
payload: {
id: toolCallId || undefined,
question: typeof raw.question === "string" ? raw.question : "",
clarificationType,
context:
typeof raw.clarification_context === "string"
? raw.clarification_context
: typeof raw.context === "string"
? raw.context
: "",
options: Array.isArray(raw.options) ? raw.options : [],
allowCustom: raw.allow_custom !== false,
allowMultiple: resolveAllowMultiple(
typeof raw.allow_multiple === "boolean" ? raw.allow_multiple : undefined,
clarificationType,
),
},
};
}
/**
* Open an SSE connection to /api/intent/stream and resolve when the upstream
* stream completes. Each call is one user turn; reuse the same threadId
@@ -343,6 +401,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
thread_id: req.threadId,
message: req.message,
model: req.model,
intent_agent_id: req.intentAgentId || undefined,
task_context: req.taskContext ?? undefined,
// 仅在有文件时带上,空数组后端也安全忽略。
files: req.files && req.files.length > 0 ? req.files : undefined,
@@ -390,8 +449,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
};
// Clarifications collected live from `type:"tool"` frames, deduped by
// tool_call_id. The terminal frame only carries the first question, so this
// is the source of truth for multi-question turns.
// tool_call_id, then reconciled against the complete terminal list.
const seenClarificationIds = new Set<string>();
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
@@ -438,7 +496,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content;
if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary;
result.summary = normalizeIntentReadySummary(parsed.summary);
}
if (parsed.status === "error") {
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
@@ -448,28 +506,26 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
detail: result.errorDetail,
});
}
if (parsed.status === "clarification" && result.clarifications.length === 0) {
// Fallback path: no live `type:"tool"` clarification frames were seen
// (older backend, or single-question turn) — synthesize one from the
// terminal frame so the UI still gets a card. When tool frames *were*
// seen, `clarifications` already holds the full (possibly multi-question)
// set and we keep it.
const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
const fallback: ClarificationPayload = {
question: typeof parsed.question === "string" ? parsed.question : "",
clarificationType,
context:
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
options: Array.isArray(parsed.options) ? parsed.options : [],
allowCustom: parsed.allow_custom !== false,
allowMultiple: resolveAllowMultiple(
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
clarificationType,
),
};
result.clarification = fallback;
result.clarifications = [fallback];
if (parsed.status === "clarification") {
// Newer backend frames contain every card. Merge them even when a
// subset already arrived live: an SSE reconnect can otherwise leave
// the user with only the first card in a multi-question assistance
// round. Older backends still fall back to the legacy top-level card.
const terminalCards = Array.isArray(parsed.clarifications)
? parsed.clarifications
.map(parseTerminalClarification)
.filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null)
: [];
if (terminalCards.length > 0) {
terminalCards.forEach((card, index) => {
collectClarification(card.payload, card.toolCallId || `terminal-${index}`);
});
} else if (result.clarifications.length === 0) {
const fallback = parseTerminalClarification(parsed);
if (fallback) {
collectClarification(fallback.payload, fallback.toolCallId || "terminal-0");
}
}
}
continue;
}
@@ -153,6 +153,10 @@ export function BusinessChainPicker({
// 席位「会商技能识别强化」开关(链条配置):按 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;
const humanValidationById = new Map(
selectedChain.seats.map((s) => [s.agent_id, s.human_validation ?? false]),
);
const humanValidationOf = (id: string) => humanValidationById.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;
@@ -162,12 +166,12 @@ export function BusinessChainPicker({
// dag:席位顺序按 stage 展平(与 plan 一致)。
const agents = dagPlan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), seatMode: seatModeOf(id) }));
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), human_validation: humanValidationOf(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), seat_skill_directive: skillOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), human_validation: humanValidationOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
onConfirm({ agents, mode: "chain", chain: chainMeta, gatherFirst: selectedChain.gatherFirst ?? false });
};
@@ -688,6 +688,15 @@ function Step1Message({
/** [INTENT_READY] 标记块解析出 IntentSummary 后的绿色摘要卡。 */
function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
const positionSections =
summary.coreGoals && summary.riskWarnings && summary.keyPoints && summary.strategicSignificance
? ([
["核心目标", summary.coreGoals],
["风险提示", summary.riskWarnings],
["关键要点", summary.keyPoints],
["战略意义", summary.strategicSignificance],
] as const)
: null;
return (
<div className="mt-1 border border-emerald-200 bg-emerald-50/50 rounded-xl p-3 space-y-2">
<div className="flex items-center gap-1.5">
@@ -696,30 +705,35 @@ function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
任务意图已明确
</span>
</div>
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
</div>
{summary.constraints?.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.constraints.map((c, i) => (
<li key={i} className="leading-snug">{c}</li>
))}
</ul>
{positionSections ? positionSections.map(([label, values]) => (
<div key={label} className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">{label}</span>
{values.length > 0 ? <ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{values.map((value, index) => <li key={index} className="leading-snug">{value}</li>)}
</ul> : <p className="text-[11px] text-slate-500">暂无{label}</p>}
</div>
)}
{summary.assumptions && summary.assumptions.length > 0 && (
)) : <>
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.assumptions.map((a, i) => (
<li key={i} className="leading-snug">{a}</li>
))}
</ul>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
</div>
)}
{summary.constraints?.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.constraints.map((c, i) => <li key={i} className="leading-snug">{c}</li>)}
</ul>
</div>
)}
{summary.assumptions && summary.assumptions.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
{summary.assumptions.map((a, i) => <li key={i} className="leading-snug">{a}</li>)}
</ul>
</div>
)}
</>}
</div>
);
}
@@ -90,6 +90,8 @@ export interface UseStep1IntentOptions {
currentStep: number;
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
selectedModel: string;
/** Optional built-in agent override for a workspace with a distinct schema. */
intentAgentId?: string;
/** Optional hidden task/business context for each intent-agent turn. */
taskContext?: Record<string, unknown> | null;
/** Optional task-aware welcome bubble text before the first user turn. */
@@ -178,6 +180,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
const {
currentStep,
selectedModel,
intentAgentId,
taskContext,
initialWelcomeText,
showInitialWelcome = true,
@@ -214,6 +217,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
const intentAgentIdRef = useRef<string | undefined>(intentAgentId);
useEffect(() => {
intentAgentIdRef.current = intentAgentId;
}, [intentAgentId]);
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
useEffect(() => {
taskContextRef.current = taskContext ?? null;
@@ -265,6 +272,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
const res = await initIntent(
selectedModelRef.current || undefined,
taskContextRef.current,
intentAgentIdRef.current,
);
if (step1InitGenRef.current !== myGen) return;
setIntentThreadId(res.thread_id);
@@ -443,6 +451,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
intentAgentId: intentAgentIdRef.current,
taskContext: taskContextRef.current,
files: fileRefs,
signal: aborter.signal,
@@ -33,6 +33,13 @@ import { useUpdateSubtask } from "@/core/tasks/context";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
type SeatDelivery = { name: string; content: string };
/** In-memory cursor for a business-chain human-validation pause. */
type HumanValidationContinuation =
| { mode: "chain"; nextSeatIndex: number }
| { mode: "dag"; nextStageIndex: number; priorDeliveries: SeatDelivery[] };
/** 读取并清空 ref(consume-once):首个 leader run 拿到文件后清空,后续轮次不再带。 */
function takeOnce<T>(ref: { current: T | null }): T | undefined {
const v = ref.current ?? undefined;
@@ -286,6 +293,8 @@ export interface UseStep2OrchestrationOptions {
* OR。recommend / 未选链条 → 空 {}(各席位仅靠其自身开关)。
*/
seatSkillDirectives?: Record<string, boolean>;
/** Per-seat human-validation checkpoints inherited from the selected chain. */
humanValidations?: Record<string, boolean>;
/**
* 每席位「推理深度覆盖」映射 {agentId: SeatMode | null}(沿用所选业务链条里每个席位的配置)。
* 派活该 special 席位时按 agentId 查表,**席位覆盖优先、否则跟随链条级 `selectedMode`**:
@@ -456,6 +465,7 @@ export function useStep2Orchestration(
selectedModel,
selectedMode,
seatSkillDirectives = {},
humanValidations = {},
seatModes = {},
gatherFirst = false,
orchestrationMode,
@@ -593,6 +603,7 @@ export function useStep2Orchestration(
const selectedModeRef = useRef<SeatMode>(selectedMode);
// 每席位技能识别强化映射(所选业务链条各席位开关)同样打到 ref,席位调用按 agentId 读最新值。
const seatSkillDirectivesRef = useRef<Record<string, boolean>>(seatSkillDirectives);
const humanValidationsRef = useRef<Record<string, boolean>>(humanValidations);
// 每席位推理深度覆盖映射(所选业务链条各席位 seatMode)打到 ref,席位调用按 agentId 读最新值;
// 覆盖优先、否则跟随 selectedModeRef。
const seatModesRef = useRef<Record<string, SeatMode | null>>(seatModes);
@@ -624,6 +635,7 @@ export function useStep2Orchestration(
// 澄清回复都改走 **leader 自由派活循环**(runOrchestration),由总控接着派活,而**不是**
// 重新把整张 DAG 从头跑一遍(致命 bug 修复)。新任务 / 重新进入时由 resetInitGate 复位。
const dagPlanExecutedRef = useRef<boolean>(false);
const humanValidationContinuationRef = useRef<HumanValidationContinuation | null>(null);
const selectedAgentsRef = useRef<SelectedAgent[]>(selectedAgents);
useEffect(() => {
orchestrationModeRef.current = orchestrationMode;
@@ -643,6 +655,9 @@ export function useStep2Orchestration(
useEffect(() => {
seatSkillDirectivesRef.current = seatSkillDirectives;
}, [seatSkillDirectives]);
useEffect(() => {
humanValidationsRef.current = humanValidations;
}, [humanValidations]);
useEffect(() => {
seatModesRef.current = seatModes;
}, [seatModes]);
@@ -1213,6 +1228,21 @@ export function useStep2Orchestration(
[],
);
const pauseForHumanValidation = useCallback(
(continuation: HumanValidationContinuation, seatNames: string[]) => {
humanValidationContinuationRef.current = continuation;
appendDialogue({
sender: "系统",
avatarType: "blue",
content: `已完成 ${seatNames.join("、")} 的输出,按业务链条配置暂停,等待人工校验。可直接点击“恢复会商”继续,或在下方输入修改意见后提交。`,
confidence: "等待人工校验",
});
setIsRoundTableRunning(false);
triggerToast("已暂停等待人工校验:可恢复会商,或输入修改意见后继续");
},
[appendDialogue, triggerToast],
);
const phaseLabel = useCallback(
(
phase: StreamPhase,
@@ -2012,11 +2042,17 @@ export function useStep2Orchestration(
const chainSeats = selectedAgentsRef.current;
const total = chainSeats.length;
const continuation = humanValidationContinuationRef.current;
const startSeatIndex =
continuation?.mode === "chain" ? continuation.nextSeatIndex : 0;
if (continuation?.mode === "chain") {
humanValidationContinuationRef.current = null;
}
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllSeats = true;
try {
for (let i = 0; i < total; i += 1) {
for (let i = startSeatIndex; i < total; i += 1) {
if (isStale()) {
finishedAllSeats = false;
break;
@@ -2179,6 +2215,14 @@ export function useStep2Orchestration(
}
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
if (humanValidationsRef.current[seat.agent_id]) {
pauseForHumanValidation(
{ mode: "chain", nextSeatIndex: i + 1 },
[seatName],
);
finishedAllSeats = false;
break;
}
}
// ── (c) 总控收口:综合全链交付,产出最终结论 ──
@@ -2257,6 +2301,7 @@ export function useStep2Orchestration(
handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId,
pauseForHumanValidation,
],
);
@@ -2300,23 +2345,37 @@ export function useStep2Orchestration(
dagPlanExecutedRef.current = true;
const leaderThreadId = resolveSeatThreadId(ids, coord);
const continuation = humanValidationContinuationRef.current;
const startStageIndex =
continuation?.mode === "dag" ? continuation.nextStageIndex : 0;
if (continuation?.mode === "dag") {
humanValidationContinuationRef.current = null;
}
let finishedAllStages = true;
// 跨 stage 累积的**完整交付** {name, content}[] —— 注入下一 stage 席位的 run context
// (peerDeliveries),供 read_peer_delivery 工具按需取全文(§12.9 传摘要按需传全文)。
// 研讨前并行取数(gatherFirst)的产物作为初始注入:首个 stage 席位开局即带共享资料。
const priorDeliveries: Array<{ name: string; content: string }> = [...gatheredDeliveriesRef.current];
const priorDeliveries: SeatDelivery[] =
continuation?.mode === "dag"
? [...continuation.priorDeliveries]
: [...gatheredDeliveriesRef.current];
// Phase 7:初始化 DAG 流程图节点状态 —— 计划内所有席位置 pending,执行过程中逐个点亮。
const markDagNode = (agentId: string, state: ChainNodeState) =>
setDagNodeStates((prev) => ({ ...prev, [agentId.toLowerCase()]: state }));
setDagNodeStates(
Object.fromEntries(
plan.stages.flatMap((st) => st.agentIds.map((id) => [id.toLowerCase(), "pending" as ChainNodeState])),
plan.stages.flatMap((st, stageIndex) =>
st.agentIds.map((id) => [
id.toLowerCase(),
(stageIndex < startStageIndex ? "done" : "pending") as ChainNodeState,
]),
),
),
);
try {
for (let s = 0; s < plan.stages.length; s += 1) {
for (let s = startStageIndex; s < plan.stages.length; s += 1) {
if (isStale()) {
finishedAllStages = false;
break;
@@ -2558,6 +2617,21 @@ export function useStep2Orchestration(
setConsensusPercentage(
Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)),
);
const validationSeatNames = stageSeats
.filter(({ agentId }) => humanValidationsRef.current[agentId])
.map(({ name }) => name);
if (validationSeatNames.length > 0) {
pauseForHumanValidation(
{
mode: "dag",
nextStageIndex: s + 1,
priorDeliveries: [...priorDeliveries],
},
validationSeatNames,
);
finishedAllStages = false;
break;
}
}
// ── 计划阶段全部完成 → **交给总控(leader 自由派活循环)继续** ──
@@ -2613,6 +2687,7 @@ export function useStep2Orchestration(
ingestParallelDelta,
resetParallelStreams,
runOrchestration,
pauseForHumanValidation,
],
);
@@ -2746,8 +2821,9 @@ export function useStep2Orchestration(
// 改走 leader 自由派活循环,由总控接着派活,而不是把 DAG 从头重跑一遍。
if (
mode === "dag" &&
!dagPlanExecutedRef.current &&
(orchestrationPlanRef.current?.stages.length ?? 0) > 0
(humanValidationContinuationRef.current?.mode === "dag" ||
(!dagPlanExecutedRef.current &&
(orchestrationPlanRef.current?.stages.length ?? 0) > 0))
) {
return runDagOrchestration(seedMessage, ids, coord);
}
@@ -3070,6 +3146,7 @@ export function useStep2Orchestration(
const resetInitGate = useCallback(() => {
step2InitRef.current = false;
humanValidationContinuationRef.current = null;
// 新一轮研讨:允许 DAG 计划重新执行一遍。
dagPlanExecutedRef.current = false;
// 新一轮研讨:允许「研讨前并行取数」再取一次(清掉上一轮取数产物 + 闸门)。
@@ -3086,6 +3163,7 @@ export function useStep2Orchestration(
// 这里只重复设 cancel ref 与 UI 标记,确保从 Step 2 在编排中切到草稿时
// UI 立刻翻成静止态。
orchestrationCancelRef.current = true;
humanValidationContinuationRef.current = null;
setIsOrchestrating(false);
setIsInitializing(false);
setPendingClarification(null);
@@ -3166,6 +3244,7 @@ export function useStep2Orchestration(
return false;
}
dagPlanExecutedRef.current = true;
humanValidationContinuationRef.current = null;
orchestrationCancelRef.current = true;
abortAllInFlight();
isOrchestratingRef.current = false;
@@ -3208,6 +3287,7 @@ export function useStep2Orchestration(
hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false;
humanValidationContinuationRef.current = null;
gatherPhaseDoneRef.current = false;
gatheredDeliveriesRef.current = [];
deselectArtifact();
@@ -36,6 +36,11 @@ export interface ChainSeat {
* (无需 DB 列),在链条编辑器里逐席位开关。
*/
seat_skill_directive?: boolean;
/**
* Human-validation checkpoint. When enabled, Step 2 pauses after this seat
* (or its DAG stage) completes and reuses the existing resume/intervention UI.
*/
human_validation?: boolean;
/**
* 可选「席位推理深度覆盖」:覆盖链条级 `seatMode`,只对该席位生效。值是同一套
* `SeatMode`(flash/thinking/pro/ultra);null / 缺省 = 跟随链条级默认。用于「卡顿提速」:
@@ -109,7 +114,7 @@ export interface SeatSelectionResult {
* `model` 为该席位在来源链条里配置的模型覆盖(空 / 缺省 = 跟随全局模型)。
* `seat_skill_directive` 为该席位在来源链条里配置的「技能识别强化」开关(缺省 = 关)。
*/
agents: Array<{ agent_id: string; name: string; model?: string | null; seat_skill_directive?: boolean; seatMode?: SeatMode | null }>;
agents: Array<{ agent_id: string; name: string; model?: string | null; seat_skill_directive?: boolean; human_validation?: boolean; seatMode?: SeatMode | null }>;
/**
* 决定 Step 2 走哪种编排:
* - `recommend` 总控自由派活;
@@ -171,6 +176,10 @@ export function buildChainSeatSelection(
// 席位「技能识别强化」开关(链条配置):按 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;
const humanValidationById = new Map(
chain.seats.map((s) => [s.agent_id, s.human_validation ?? false]),
);
const humanValidationOf = (id: string) => humanValidationById.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]));
@@ -196,12 +205,12 @@ 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), seat_skill_directive: skillOf(id), seatMode: seatModeOf(id) }));
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), human_validation: humanValidationOf(id), seatMode: seatModeOf(id) }));
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "sandbox", 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), seat_skill_directive: skillOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), human_validation: humanValidationOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode, gatherFirst: chain.gatherFirst ?? false };
}
@@ -12,6 +12,7 @@ import {
moveSeatToStage,
moveStage,
removeSeat,
setSeatHumanValidation,
setStageGoal,
splitSeatToNewStage,
stageGroupsToSeats,
@@ -122,6 +123,15 @@ test("setStageGoal is immutable + out-of-range no-op", () => {
assert.equal(setStageGoal(groups, 9, "x"), groups); // 越界返回原引用
});
test("setSeatHumanValidation updates only the selected seat and is immutable", () => {
const groups = grp([seat("a"), seat("b")], [seat("c")]);
const next = setSeatHumanValidation(groups, "b", true);
assert.equal(next[0].seats[1].human_validation, true);
assert.equal(next[0].seats[0].human_validation, undefined);
assert.equal(next[1].seats[0].human_validation, undefined);
assert.equal(groups[0].seats[1].human_validation, undefined);
});
// ── lookups ───────────────────────────────────────────────────────────────────
test("findStageIndexOfSeat / countSeats", () => {
@@ -141,6 +141,23 @@ export function setSeatSkillDirective(groups: StageGroups, agentId: string, enab
});
}
/** Update a seat's human-validation checkpoint without changing other stages. */
export function setSeatHumanValidation(
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, human_validation: enabled } : s,
),
};
});
}
/**
* 设置某个席位的「推理深度覆盖」(不可变,供编辑器席位下拉 onChange)。
* `mode` 为 null = 清除覆盖(跟随链条级 seatMode)。按 agent_id 定位,跨 stage 适用。
@@ -292,9 +292,29 @@ function toStringArray(value: unknown): string[] {
* 把解析出的对象归一成 IntentSummary。只要 `objective` 是非空字符串就认。
* constraints / assumptions 容忍模型写成字符串 / null / 缺失,统一归到 string[]。
*/
function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
export function normalizeIntentReadySummary(parsed: unknown): IntentSummary | null {
if (!parsed || typeof parsed !== "object") return null;
const obj = parsed as Record<string, unknown>;
const positionKeys = ["coreGoals", "riskWarnings", "keyPoints", "strategicSignificance"];
if (positionKeys.some((key) => key in obj)) {
if (!positionKeys.every((key) => Array.isArray(obj[key]))) return null;
const coreGoals = toStringArray(obj.coreGoals);
const riskWarnings = toStringArray(obj.riskWarnings);
const keyPoints = toStringArray(obj.keyPoints);
const strategicSignificance = toStringArray(obj.strategicSignificance);
// Keep the long-standing Step-1 state machine compatible while retaining
// every position-specific field verbatim for its dedicated UI and storage.
return {
objective:
strategicSignificance[0] || coreGoals[0] || keyPoints[0] || "岗位任务意图已识别",
constraints: riskWarnings,
assumptions: keyPoints,
coreGoals,
riskWarnings,
keyPoints,
strategicSignificance,
};
}
const objective =
typeof obj.objective === "string" ? obj.objective.trim() : "";
if (!objective) return null;
@@ -310,14 +330,14 @@ function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
* 1) 原文直接 parse
* 2) 转义字符串内的裸控制字符(换行 / 制表符)后再 parse
* 3) 再去掉尾随逗号后 parse
* 三次都失败才放弃。任一成功都过 `normalizeIntentSummary` 兜字段类型。
* 三次都失败才放弃。任一成功都过 `normalizeIntentReadySummary` 兜字段类型。
*/
function parseIntentSummary(raw: string): IntentSummary | null {
const sanitized = sanitizeJsonControlChars(raw);
const candidates = [raw, sanitized, stripTrailingCommas(sanitized)];
for (const candidate of candidates) {
try {
const summary = normalizeIntentSummary(JSON.parse(candidate));
const summary = normalizeIntentReadySummary(JSON.parse(candidate));
if (summary) return summary;
} catch {
// 下一个候选
@@ -41,6 +41,7 @@ import {
Sparkles,
SplitSquareVertical,
Trash2,
UserCheck,
UserPlus,
Wand2,
X,
@@ -127,6 +128,7 @@ import {
seatExistsInGroups,
setSeatMode as setSeatModeOnGroups,
setSeatModel,
setSeatHumanValidation,
setSeatSkillDirective,
setStageGoal,
splitSeatToNewStage,
@@ -453,6 +455,10 @@ export default function BusinessChainEditorPage() {
setStageGroups((prev) => setSeatSkillDirective(prev, agentId, enabled));
}, []);
const setSeatHumanValidationCb = useCallback((agentId: string, enabled: boolean) => {
setStageGroups((prev) => setSeatHumanValidation(prev, agentId, enabled));
}, []);
// 席位「推理深度覆盖」:编辑器席位小图标下拉 onChange(null = 清除,跟随链条级 seatMode)。
const setSeatModeCb = useCallback((agentId: string, mode: SeatMode | null) => {
setStageGroups((prev) => setSeatModeOnGroups(prev, agentId, mode));
@@ -670,6 +676,7 @@ export default function BusinessChainEditorPage() {
model: s.model ?? null,
// 席位「会商技能识别强化」开关:随席位一起落库(缺省 = false 关)。
seat_skill_directive: s.seat_skill_directive ?? false,
human_validation: s.human_validation ?? false,
// 席位「推理深度覆盖」:随席位一起落库(null = 跟随链条级 seatMode)。
seat_mode: s.seat_mode ?? null,
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
@@ -1272,6 +1279,7 @@ export default function BusinessChainEditorPage() {
onSplitSeat={splitSeat}
onSeatModelChange={setSeatModelCb}
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
onSeatHumanValidationChange={setSeatHumanValidationCb}
onSeatModeChange={setSeatModeCb}
positionConfigMode={positionConfigMode}
positionOptions={positionOptions}
@@ -1455,6 +1463,7 @@ function StagesColumn({
onSplitSeat,
onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatHumanValidationChange,
onSeatModeChange,
positionConfigMode,
positionOptions,
@@ -1476,6 +1485,7 @@ function StagesColumn({
onSplitSeat: (agentId: string) => void;
onSeatModelChange: (agentId: string, model: string | null) => void;
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatHumanValidationChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
positionConfigMode: boolean;
@@ -1586,6 +1596,7 @@ function StagesColumn({
onSplitSeat={onSplitSeat}
onSeatModelChange={onSeatModelChange}
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
onSeatHumanValidationChange={onSeatHumanValidationChange}
onSeatModeChange={onSeatModeChange}
positionConfigMode={positionConfigMode}
positionOptions={positionOptions}
@@ -1620,6 +1631,7 @@ function StageSection({
onSplitSeat,
onSeatModelChange,
onSeatSkillDirectiveChange,
onSeatHumanValidationChange,
onSeatModeChange,
positionConfigMode,
positionOptions,
@@ -1643,6 +1655,7 @@ function StageSection({
onSplitSeat: (agentId: string) => void;
onSeatModelChange: (agentId: string, model: string | null) => void;
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatHumanValidationChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
positionConfigMode: boolean;
positionOptions: readonly PositionOption[];
@@ -1809,6 +1822,7 @@ function StageSection({
onSplit={() => onSplitSeat(seat.agent_id)}
onModelChange={(model) => onSeatModelChange(seat.agent_id, model)}
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
onHumanValidationChange={(enabled) => onSeatHumanValidationChange(seat.agent_id, enabled)}
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
showPositionConfig={positionConfigMode}
positionOptions={positionOptions}
@@ -1837,6 +1851,7 @@ function SeatStrip({
onSplit,
onModelChange,
onSkillDirectiveChange,
onHumanValidationChange,
onSeatModeChange,
showPositionConfig,
positionOptions,
@@ -1857,6 +1872,7 @@ function SeatStrip({
onModelChange: (model: string | null) => void;
/** 席位「会商技能识别强化」开关变更。 */
onSkillDirectiveChange: (enabled: boolean) => void;
onHumanValidationChange: (enabled: boolean) => void;
/** 席位「推理深度覆盖」变更(null = 跟随链条级 seatMode)。 */
onSeatModeChange: (mode: SeatMode | null) => void;
/** 仅岗位协同入口展示的岗位配置。 */
@@ -2058,6 +2074,38 @@ function SeatStrip({
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title="人工校验:该席位完成后暂停会商,等待用户恢复或输入修改意见"
className={cn(
"p-1 transition",
R_CTRL,
seat.human_validation
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<UserCheck 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">
开启后,该席位所在的执行单元完成时会自动暂停。用户可直接点击“恢复会商”继续,或在下方输入修改意见后提交;不会新增校验卡片。
</p>
</div>
<Switch
checked={seat.human_validation ?? false}
onCheckedChange={onHumanValidationChange}
aria-label={`切换 ${seat.name || seat.agent_id} 的人工校验`}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={onDetail}
@@ -409,6 +409,10 @@ export default function RoundtablePlanningPage({
// Step 2 每席位「技能识别强化」开关映射 {agentId: bool}:由所选业务链条里**每个席位**的同名
// 配置决定(recommend 无链条 → 空 {})。透传给每个席位 run,与各席位智能体自身的开关取 OR。
const [seatSkillDirectives, setSeatSkillDirectives] = useState<Record<string, boolean>>({});
// Per-seat human-validation checkpoints from the selected business chain.
// A checkpoint pauses Step 2 after the configured seat/stage completes and
// reuses the existing resume button plus bottom intervention composer.
const [humanValidations, setHumanValidations] = useState<Record<string, boolean>>({});
// Step 2 每席位「推理深度覆盖」映射 {agentId: SeatMode|null}:由所选业务链条里**每个席位**单独配
// 的推理深度决定(null/未配 → 跟随链条级 seatMode)。透传给席位 run(覆盖优先、否则跟随 seatMode)。
// 「卡顿提速」用:把次要席位降到 flash 即可大幅缩短第二步耗时。
@@ -573,6 +577,7 @@ export default function RoundtablePlanningPage({
selectedModel,
selectedMode: seatMode,
seatSkillDirectives,
humanValidations,
seatModes,
gatherFirst,
orchestrationMode,
@@ -1058,6 +1063,9 @@ export default function RoundtablePlanningPage({
setSeatSkillDirectives(
Object.fromEntries(result.agents.map((a) => [a.agent_id, a.seat_skill_directive ?? false])),
);
setHumanValidations(
Object.fromEntries(result.agents.map((a) => [a.agent_id, a.human_validation ?? false])),
);
// 每席位推理深度覆盖:取所选业务链条里**每席位**单独配的 seatMode → {agentId: SeatMode|null}
// 映射(recommend / 未配 → null,跟随链条级 seatMode)。
setSeatModes(
@@ -123,6 +123,27 @@ cascade pragmas. The router is mounted canonically below
schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`.
Clients may retry the compatibility route only for a framework/proxy-level
`404 Not Found`; domain-level missing-session responses must remain real 404s.
Position roundtable selects the separately seeded built-in agent
`position-roundtable-intent` by passing `intent_agent_id` to the shared
`/api/intent/init` and `/api/intent/stream` endpoints. Its request lifecycle,
files, and SSE protocol match `roundtable-intent`, but its cap is five
**user-assistance rounds**. A single round may emit several independent
`ask_clarification` tool calls; those calls are separate cards, not one card
containing several numbered questions. Count that as one round by the
tool-calling AI message, never by card count. Topic-only requests must request
all material missing decisions as separate cards rather than being treated as a
resolved intent; after the fifth set is answered it must emit completed final
JSON without unresolved placeholders. The required arrays are
`coreGoals`, `riskWarnings`, `keyPoints`, and `strategicSignificance`. Do not
treat a fresh dedicated-agent `ask_clarification` as stale: it must win over
a same-run premature `[INTENT_READY]` and leave the UI waiting for the user.
Conversely, the prior turn’s historical clarification must not block the user
reply from completing. Do not
change `roundtable-intent`'s `{objective,constraints,assumptions}` contract for
this feature. The position router accepts the new schema for activation and
injects all four sections into downstream position-node and closing-agent
context; legacy stored summaries are mapped read-only for compatibility.
`/api/intent/init` must create the empty Step-1 thread through
`multi_agent._create_thread_direct`, exactly like multi-agent initialization;
do not restore the old HTTP loopback to `/api/threads`, because deployed
@@ -436,6 +457,15 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
### Position-collaboration foundation
### Roundtable business-chain human validation
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
boolean `human_validation` field on each seat (`ChainSeatSchema`). It is a
frontend Step 2 checkpoint, not a server-side approval task: after the marked
seat completes (or, in a DAG, after its stage completes), the client pauses and
uses the existing resume button or intervention composer to continue. No new
database column, migration, or validation card is required.
The position-collaboration workspace is a separate frontend route and must not
use the original roundtable coordinator or jobs. Its shared configuration
integration point is the existing `/api/roundtable-chains` store: each `seats` JSON item
+19 -1
View File
@@ -123,7 +123,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `POST /api/skills/install` | Install skill from `.skill` archive |
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace, plus `human_validation` to mark a Step 2 client-side pause checkpoint after that seat/stage completes (the original roundtable has no extra validation card) |
| `GET/PUT /api/business-mapping` | Read global business mappings. Updates to 3Q/6BF/7BF require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. |
| `GET /api/memory` | Retrieve memory data |
@@ -481,6 +481,24 @@ checkpoint or sandbox-output volume is replaced. Apply Alembic revision
Its live intent conversation still uses the shared `/api/intent/init` and
`/api/intent/stream` endpoints used by the original multi-agent roundtable.
Position roundtable identifies itself with `intent_agent_id: "position-roundtable-intent"`;
this is a separately seeded built-in agent, not an alias of `roundtable-intent`.
It preserves the same thread, SSE streaming, and file-reading lifecycle as the
generic Step-1 agent, but has its own **at-most-five user-assistance-round**
brake. A round is one AI turn plus the user reply, not one tool call: the
dedicated agent may emit several independent `ask_clarification` calls in the
same turn, which the frontend renders as several separate selection cards and
submits together. A topic-only request is not considered resolved: the agent
asks for every independent missing decision (such as scope, decision use, or
task constraints) as separate cards. For the dedicated agent, a fresh
`ask_clarification` emitted in a stream is a strict user-input pause and wins over any same-run premature
`[INTENT_READY]`; a historical clarification from the prior turn does not
block the user answer from completing. Its final
`[INTENT_READY]` JSON contains four string arrays: `coreGoals`, `riskWarnings`,
`keyPoints`, and `strategicSignificance`; it must contain completed conclusions,
not “待确认” or other unresolved placeholders. The multi-agent endpoint remains
backwards compatible when this optional field is omitted and continues to emit
`objective`, `constraints`, and `assumptions`.
`/api/intent/init` creates its empty thread directly through the in-process
thread store/checkpointer, matching multi-agent initialization and avoiding a
deployment-sensitive `127.0.0.1:<gateway-port>` loopback. If initial checkpoint
@@ -58,6 +58,10 @@ logger = logging.getLogger(__name__)
INTENT_AGENT_ID = "roundtable-intent"
# Position roundtable deliberately owns a separate intent agent. Its dialogue
# lifecycle is shared with Step 1 above, but its final structured hand-off is
# different and must not change the multi-agent roundtable contract.
POSITION_INTENT_AGENT_ID = "position-roundtable-intent"
RECOMMENDER_AGENT_ID = "roundtable-recommender"
COORDINATOR_AGENT_ID = "roundtable-coordinator"
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
@@ -92,6 +96,7 @@ _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
# roundtable-dashboard 支撑 Step 3 大屏多页结构化数据(report-json + 问答/增量改)。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
INTENT_AGENT_ID,
POSITION_INTENT_AGENT_ID,
RECOMMENDER_AGENT_ID,
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
@@ -109,14 +114,14 @@ def _ensure_one(agent_id: str) -> bool:
"""Copy the verified SOUL.md + config.yaml for one functional agent into
``.deer-flow/agents/<id>/`` if that directory is missing.
Returns True if the files were just created, False if the directory already
existed (we never overwrite — respect any local edits) or the seed assets
are missing.
"""
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False
The position-roundtable intent agent is an implementation-owned compatibility
contract: unlike user-managed agents, its schema must stay aligned with the
position workspace. Its two seed files are therefore refreshed when they
differ, so a previously seeded incorrect prompt is repaired after deployment.
Returns True if files were created or refreshed, False if no work was needed
or the seed assets are missing.
"""
src_dir = _ASSETS_DIR / agent_id
if not src_dir.is_dir():
# Assets should always ship with the code; if they don't, surface a loud
@@ -128,6 +133,25 @@ def _ensure_one(agent_id: str) -> bool:
)
return False
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
if agent_id != POSITION_INTENT_AGENT_ID:
# Other built-in agents may have been tuned on a deployed machine;
# preserve the long-standing no-overwrite behavior for them.
return False
refreshed = False
for fname in _SEED_FILES:
src = src_dir / fname
if not src.exists():
continue
target = agent_dir / fname
source_bytes = src.read_bytes()
if not target.exists() or target.read_bytes() != source_bytes:
target.write_bytes(source_bytes)
refreshed = True
return refreshed
agent_dir.mkdir(parents=True, exist_ok=True)
for fname in _SEED_FILES:
src = src_dir / fname
@@ -138,9 +162,9 @@ def _ensure_one(agent_id: str) -> bool:
def ensure_roundtable_functional_agents() -> list[str]:
"""Seed the three roundtable functional agents (``roundtable-intent`` /
``roundtable-recommender`` / ``roundtable-coordinator``) if their on-disk
directories are missing. Idempotent — fast no-op when all exist.
"""Seed the built-in roundtable functional agents when their on-disk
directories are missing, and refresh the implementation-owned position
intent schema when it has changed. Idempotent after synchronization.
Called from the startup lifespan and from the entry of intent/init,
intent/stream, recommend/stream, and multi_agent/init so the routes
@@ -0,0 +1,145 @@
# 岗位会商任务意图理解智能体
你是岗位多智能体会商 Step 1 的“意图理解专员”。你的唯一职责是把用户输入背后的任务意图结构化后交给后续岗位业务链处理。
你不写报告、不答题、不创作、不规划、不执行、不调用任何操作工具。这些都是后续岗位业务链的事。你只负责听懂、整理、澄清和交接。
---
## 0. 四条不可违背的硬规则
### 硬规则 1 · 不替代后续岗位干活
无论用户让你“写报告 / 写代码 / 写诗 / 查资料 / 做规划 / 出方案”,你都不要直接产出具体内容。
- 正确做法:把诉求结构化成任务意图,输出 [INTENT_READY],交给后续岗位业务链。
- 错误做法:用户说“帮我写一份市场分析报告”后,开始写报告正文。
### 硬规则 2 · 最多 5 轮用户协助,确认完整后才收敛
一次“同一轮中调用一个或多个 ask_clarification + 用户统一回答”算一轮用户协助。
- 首轮:若任务卡和用户输入已经足以形成可执行的四项意图,可直接输出 [INTENT_READY]。
- 第 1 至第 5 轮用户协助:若信息不足,必须发起用户协助;不能把“主题已识别”误判为“意图已明确”。
- 同一轮可发起多张用户协助卡:一个相互独立的决策点对应一次 ask_clarification 调用和一张卡。不得把多个问题塞进一张卡;用户可逐卡选择后一次性提交。
- 第 5 次协助应覆盖所有剩余关键项。收到该轮回答后,必须基于明确答案收敛为最终意图。
- 绝不进入第 6 次用户协助。系统会在达到上限后移除 ask_clarification。
### 硬规则 3 · 输出 [INTENT_READY] 后立刻闭嘴
最终 JSON 代码块闭合后,禁止再输出任何字符:不继续解释、不问“还需要补充吗”、不调用工具、不复述、不展望。
### 硬规则 4 · 只做理解和澄清,不当守门员
这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。
---
## 1. 任务意图字段定义
最终结构必须包含以下四个字段,且都必须是字符串数组:
| 字段 | 含义 | 填写原则 |
| --- | --- | --- |
| coreGoals | 用户想实现或想获得的核心结果 | 将原本的一句话任务目标拆为 1 个或多个清晰目标 |
| riskWarnings | 已知风险、依赖或执行风险 | 必须分析任务级风险;范围、地区、周期、数据来源、对象或决策用途缺失时,应先通过用户协助确认,最终结果不得保留待确认表述 |
| keyPoints | 范围、对象、优先级、时间、资源、交付形式等关键要点 | 仅汇总用户已明确的事实与结论,最终结果不得保留待确认项 |
| strategicSignificance | 任务对后续岗位协同、业务链或决策推进的意义 | 必须分析该任务将支撑哪类后续决策、业务链协同或资源投入;可以作出任务级合理推断,但不得虚构组织现状或业务数据 |
核心原则:
- 用户已给出任务目标、范围、用途和约束时,直接汇总,不要追问。
- “只给出一个主题或一句极短诉求”不等于任务意图完整。任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助。
- 同一轮应识别所有仍会影响后续业务链的关键缺口,并为每个相互独立的缺口各调用一次 ask_clarification,避免一个问题一轮地消耗用户;不要把它们合并为一张多题卡。
- 最终 [INTENT_READY] 中禁止出现“待确认”“未明确”“需补充”“暂无”“缺少”等未完成表述;信息不足时继续协助,不得提前交接。
- 空数组只适用于用户明确说明“不涉及/没有”这一项时。
---
## 2. 工作流
~~~
用户输入
↓
是否能形成可执行的四项意图?
├─ 能 → 立即输出 [INTENT_READY],结束
└─ 不能 → 同一轮可分别调多个 ask_clarification(每张卡只问一个决策点)
↓
用户回答 → 再判断
↓
最多 5 轮用户协助 → 收到明确答案后输出 [INTENT_READY]
~~~
### 提问前自检清单
任一项为“否”,就应发起用户协助;同轮把全部为“否”的独立决策点分别做成卡片:
1. 我是否不仅能写出核心目标,也能明确主要分析范围或决策用途?
2. 我是否能基于任务给出至少一项任务级风险提示和战略意义?
3. 用户是否已经在任务卡、当前消息或历史消息中给出这些关键信息?
4. 这些缺口是否已经在本轮协助中一次性列出?
5. 是否已达到第 5 次用户协助?若是,等待用户对最后一组问题的回答,不再发起第 6 次协助。
---
## 3. 唯一允许的工具:ask_clarification
除 ask_clarification 外,禁止调用任何工具、技能或操作。尤其禁止搜索、写文件、读写沙箱、展示文件、创建任务、编排智能体或生成交付物。
当任务只有主题、或缺少决定范围/用途/风险判断的关键信息时,必须调用它。每个协助问题只包含一个决策点;同一轮需要确认多个点时,连续调用多次工具,生成多张独立选择卡:
~~~
ask_clarification(
question="本次分析主要面向哪个范围?",
clarification_type="analysis_scope",
context="范围会决定后续岗位的分析边界",
options=["选项 A", "选项 B", "其他,我补充说明"],
allow_multiple=False
)
~~~
若还需要确认交付用途、时间边界、对象、风险限制等,必须在同一次回复中继续分别调用 ask_clarification,而不是将它们写进上面这张卡。不得重复询问用户已经回答过的问题。
---
## 4. 结束协议:[INTENT_READY]
可以收敛时,先用 1–2 句话、30 字以内复述理解,然后严格以 JSON 代码块结束。JSON 代码块后不得有任何内容。
输出示例的文本顺序必须是:
~~~
✅ 我理解的任务是:<一句话复述>
[INTENT_READY]
JSON 代码块(语言标识 json):
{
"coreGoals": ["..."],
"riskWarnings": [],
"keyPoints": ["..."],
"strategicSignificance": []
}
~~~
格式要求:
- [INTENT_READY] 单独成行。
- [INTENT_READY] 后紧跟语言标识为 json 的 Markdown 代码块,包裹合法 JSON 对象。
- 四个字段名称必须严格为 coreGoals / riskWarnings / keyPoints / strategicSignificance。
- 四个字段必须都是字符串数组;除用户明确说明不涉及外,不要输出空数组。
- 输出前逐项检查:四个数组中不得出现“待确认”“未明确”“需补充”“暂无”“缺少”等词语。
- JSON 必须严格合法:英文双引号、无注释、无尾随逗号;字符串中不可直接换行。
- JSON 代码块闭合后,禁止再写任何字符。
- 不得输出旧字段 objective、constraints、assumptions。
---
## 5. 禁止事项
- 禁止直接产出报告、代码、诗、方案、分析或任何交付物。
- 禁止调用任何非 ask_clarification 的工具。
- 禁止第 5 次用户协助后再发起第 6 次协助。
- 禁止重复询问用户已回答的问题。
- 禁止拒绝用户、要求用户换问题或以风险为由中断。
- 禁止编造外部事实、组织现状或业务数据;信息缺口必须在用户协助阶段确认,最终只输出明确的执行风险和战略意义。
- 禁止在 [INTENT_READY] 后再输出一个字。
@@ -0,0 +1,3 @@
id: position-roundtable-intent
name: 岗位会商任务意图理解
description: 岗位会商专用的任务意图理解智能体。通过必要的简短追问,将任务整理为核心目标、风险提示、关键要点与战略意义,供后续岗位业务链执行。
@@ -34,6 +34,7 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
``question``、``clarification_type``、``clarification_context``、
``options``、``allow_custom``、``allow_multiple``(见 ``resolve_allow_multiple``)。
多卡回合还带 ``clarifications`` 完整数组;顶层字段始终镜像第一张卡以兼容旧客户端。
终态判定优先级(实现细节,勿随意调整)
------------------------------------
@@ -65,7 +66,10 @@ from app.gateway.roundtable_diag import record_foreground_diag
# Reuse the building blocks already in multi_agent.py — keeps both routers
# identical in connection handling, payload shape, and error mapping.
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers._roundtable_seed import (
POSITION_INTENT_AGENT_ID,
ensure_roundtable_functional_agents,
)
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
@@ -87,6 +91,7 @@ router = APIRouter(prefix="/api/intent", tags=["intent"])
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
INTENT_AGENT_ID = "roundtable-intent"
_SUPPORTED_INTENT_AGENT_IDS = frozenset({INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID})
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
@@ -96,7 +101,49 @@ _INTENT_READY_PATTERN = re.compile(
)
def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, Any]:
def _requested_intent_agent_id(payload: dict[str, Any]) -> str:
"""Resolve the explicitly supported Step-1 agent without opening a route
for arbitrary custom-agent execution.
Existing multi-agent callers omit the field and therefore keep using
``roundtable-intent``. Position roundtable sends the dedicated ID so its
prompt and final JSON schema remain isolated.
"""
requested = str(payload.get("intent_agent_id") or "").strip()
if not requested:
return INTENT_AGENT_ID
if requested not in _SUPPORTED_INTENT_AGENT_IDS:
raise HTTPException(status_code=400, detail="Unsupported intent agent")
return requested
def _clarification_blocks_ready(
intent_agent_id: str,
clarification_calls: Any,
*,
is_fresh: bool,
) -> bool:
"""Position intent must wait for a fresh clarification answer.
The generic Step-1 route intentionally preserves READY-first behavior for
stale tool calls in historical snapshots. Position roundtable instead has
a four-part hand-off whose missing context is requested interactively, so
a newly dispatched clarification is a strict terminal pause. A historical
tool call from the preceding user-assistance turn must not block the next
user answer from reaching READY.
"""
return (
intent_agent_id == POSITION_INTENT_AGENT_ID
and bool(clarification_calls)
and is_fresh
)
def _intent_thread_metadata(
task_context: dict[str, Any] | None,
*,
intent_agent_id: str = INTENT_AGENT_ID,
) -> dict[str, Any]:
"""Build task-scoped metadata for a newly-created intent thread.
Normal roundtable Step 1 calls still use the original system-thread tag.
@@ -122,6 +169,8 @@ def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, An
"position_roundtable_phase": "intent",
}
)
if intent_agent_id == POSITION_INTENT_AGENT_ID:
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
return metadata
@@ -190,6 +239,32 @@ def _count_past_clarifications(messages: list[dict[str, Any]]) -> int:
return count
def _count_past_clarification_rounds(messages: list[dict[str, Any]]) -> int:
"""Count completed assistance *rounds*, rather than question cards.
The generic roundtable agent retains its historic three-tool-call brake.
Position roundtable deliberately lets one model turn emit several
independent ``ask_clarification`` cards, so using the tool-call count
there would accidentally spend all five user-assistance rounds at once.
One AI message containing one or more clarification calls is one round.
"""
if not isinstance(messages, list):
return 0
rounds = 0
for msg in messages:
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
if any(
(tc.get("name") or "").lower() == "ask_clarification"
for tc in msg.get("tool_calls") or []
if isinstance(tc, dict)
):
rounds += 1
return rounds
async def _fetch_thread_messages(
client: httpx.AsyncClient,
base: str,
@@ -225,8 +300,18 @@ async def _fetch_thread_messages(
# 硬上限:超过这个轮数,无论模型怎么想,都强制移除 ask_clarification 工具。
# 与 SOUL.md §0 「硬规则 2」对齐——"绝不存在第 4 轮"。
# 通用会商沿用 3 次工具调用的历史保护;岗位会商按"一轮可多张卡"计数,
# 允许最多 5 次用户协助。
_MAX_CLARIFICATION_ROUNDS = 3
_POSITION_MAX_CLARIFICATION_ROUNDS = 5
def _max_clarification_rounds(intent_agent_id: str) -> int:
return (
_POSITION_MAX_CLARIFICATION_ROUNDS
if intent_agent_id == POSITION_INTENT_AGENT_ID
else _MAX_CLARIFICATION_ROUNDS
)
def _is_repeated_clarification(
@@ -310,6 +395,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,首次启动时本 agent 目录可能不存在,
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
ensure_roundtable_functional_agents()
intent_agent_id = _requested_intent_agent_id(payload)
# Keep Step-1 initialization aligned with multi-agent initialization:
# create the empty thread in-process rather than looping back through
@@ -321,7 +407,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
try:
thread_id = await _create_thread_direct(
request,
metadata=_intent_thread_metadata(task_context),
metadata=_intent_thread_metadata(task_context, intent_agent_id=intent_agent_id),
)
except HTTPException:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
@@ -341,7 +427,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
raise HTTPException(status_code=500, detail=diagnostic) from exc
return {
"status": "success",
"agent_id": INTENT_AGENT_ID,
"agent_id": intent_agent_id,
"thread_id": thread_id,
}
@@ -363,6 +449,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
}
"""
thread_id = str(payload.get("thread_id") or "").strip()
intent_agent_id = _requested_intent_agent_id(payload)
message = str(payload.get("message") or "").strip()
# 不写死任何默认模型名:空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
@@ -386,21 +473,30 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
base = _loopback_base(request)
headers = _auth_headers(request)
fallback_instruction = (
"四个岗位会商字段都必须保留为字符串数组,依据已获得的用户确认形成明确结论,"
"不得写待确认、未明确、需补充、暂无或缺少"
if intent_agent_id == POSITION_INTENT_AGENT_ID
else "缺的字段用 assumptions 兜底"
)
async def stream_generator() -> AsyncIterator[str]:
# ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**,
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
async with _make_loopback_client(request) as client:
try:
# —— 3 轮硬上限兜底 ——
# 在发起 run 之前先读一次 thread state,数当前 ask_clarification 已发出次数:
# past=0/1 → 正常,不动 message,不动工具白名单
# past=2(本轮即将变成第 3 轮)→ 给消息加前缀,强提示"必须 INTENT_READY"
# past≥3 → 直接把 ask_clarification 也加进 excluded_tools,工具物理消失
# 这一步是个"刹车":SOUL 让模型自律,这里让系统兜底。
past_clarifications = _count_past_clarifications(
await _fetch_thread_messages(client, base, headers, thread_id)
# —— 澄清轮次硬上限兜底 ——
# 通用会商保持原来的 3 次 ask 工具调用上限;岗位会商允许一个
# AI turn 同时产生多张独立协助卡,所以必须按 AI 消息(一次
# "提问 + 用户回答")计轮,不能按卡片数计轮。
past_messages = await _fetch_thread_messages(client, base, headers, thread_id)
past_clarifications = _count_past_clarifications(past_messages)
past_rounds = (
_count_past_clarification_rounds(past_messages)
if intent_agent_id == POSITION_INTENT_AGENT_ID
else past_clarifications
)
max_rounds = _max_clarification_rounds(intent_agent_id)
# 1. ask_clarification 的工具白名单——超过硬上限就移除。
base_excluded_tools = [
@@ -420,13 +516,13 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"task",
"write_todos",
]
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
if past_rounds >= max_rounds:
base_excluded_tools.append("ask_clarification")
logger.info(
"intent stream: past_clarifications=%d >= %d, force-disabling "
"intent stream: past_rounds=%d >= %d, force-disabling "
"ask_clarification to break the loop",
past_clarifications,
_MAX_CLARIFICATION_ROUNDS,
past_rounds,
max_rounds,
)
# 带了上传文件时,解锁「读」类工具(read_file / ls / grep / view_image),
@@ -440,22 +536,42 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
if intent_agent_id == POSITION_INTENT_AGENT_ID and past_rounds < max_rounds:
# The position flow needs an actionable four-part hand-off,
# not merely a recognizable topic. One turn may ask several
# independent decisions, each rendered as its own selection
# card by the existing Step-1 UI.
effective_message = (
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
f"本轮请直接输出 [INTENT_READY],缺的字段用 assumptions 兜底,"
"[岗位会商收敛检查] 仅识别出主题不等于意图已明确。"
"若当前任务还无法判断分析范围、决策用途、关键风险或战略意义,"
"请在同一次回复中为每个相互独立的关键缺口分别调用一次 "
"ask_clarification;每个工具调用只能问一个决策点,不能把多项"
"问题合并成一张卡片。用户会一次性回答这些卡片。"
"如果四项意图已经足够明确,则直接输出最终结果。"
"不要以风险提示和战略意义为空的 [INTENT_READY] 结束。"
"本轮是第 %d/%d 轮用户协助;第 %d 轮若仍有缺口,必须把所有"
"剩余关键缺口分别发成卡片,收到回答后立即收敛。\n\n"
f"用户最新输入:{effective_message}"
) % (past_rounds + 1, max_rounds, max_rounds)
if past_rounds >= max_rounds:
effective_message = (
f"[系统强制] 已澄清 {past_rounds} 轮,触发硬上限。"
f"本轮请直接输出 [INTENT_READY],{fallback_instruction},"
f"绝对不要再调任何工具,也不要再询问任何问题。\n\n"
f"用户最新输入:{message}"
)
elif past_clarifications >= _MAX_CLARIFICATION_ROUNDS - 1:
elif (
intent_agent_id != POSITION_INTENT_AGENT_ID
and past_rounds >= max_rounds - 1
):
effective_message = (
f"[系统提示] 已澄清 {past_clarifications} 轮,这是最后一轮。"
f"本轮请直接输出 [INTENT_READY],未明确的字段用 assumptions 兜底。\n\n"
f"[系统提示] 已澄清 {past_rounds} 轮,这是最后一轮。"
f"本轮请直接输出 [INTENT_READY],{fallback_instruction}。\n\n"
f"用户最新输入:{message}"
)
body = _run_payload(
agent_name=INTENT_AGENT_ID,
agent_name=intent_agent_id,
model_name=model_name,
thread_id=thread_id,
new_message=effective_message,
@@ -479,6 +595,26 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
captured = done
messages = _parse_last_messages(captured)
current_rounds = (
_count_past_clarification_rounds(messages)
if intent_agent_id == POSITION_INTENT_AGENT_ID
else _count_past_clarifications(messages)
)
clarification_is_fresh = current_rounds > past_rounds
# A position-roundtable clarification is an explicit pause.
# Some models emit a premature [INTENT_READY] text after the
# tool call in the same upstream run. For this dedicated flow
# the fresh clarification must win so the user can answer it;
# generic roundtable keeps its historical READY-first order.
dispatch_msg = _find_dispatch_message(messages)
tool_calls = dispatch_msg.get("tool_calls") or []
clarification_calls = [
tc
for tc in tool_calls
if isinstance(tc, dict)
and (tc.get("name") or "").lower() == "ask_clarification"
]
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
@@ -507,7 +643,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# Step1Panel 已经把 `parts.after` 隐藏掉,体验闭环。
ready_text = msg_text[: m.end()].rstrip() + "\n"
break
if summary and ready_text:
if (
summary
and ready_text
and not _clarification_blocks_ready(
intent_agent_id,
clarification_calls,
is_fresh=clarification_is_fresh,
)
):
final_frame = {
"status": "done",
"content": ready_text,
@@ -519,42 +663,49 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# --- 终态分支 2:本轮以 ask_clarification 结束 ---
# _find_dispatch_message:从后往前找带 tool_calls 的 AIMessage
# (SkillStop/澄清 ToolMessage 之后的那条 AI 消息)。
dispatch_msg = _find_dispatch_message(messages)
content = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or []
clarification_call = next(
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
None,
)
if clarification_call is not None:
cargs = clarification_call.get("args") or {}
question = str(cargs.get("question") or "").strip()
if clarification_calls:
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
display_content = follow_up or content or question
# 兜底:模型偶尔会复述上一轮的同一澄清问题(SOUL §2.5 prompt
# 已经强约束,但小模型 / 长上下文仍可能漏跟随)。检测到完全
# 重复时,把 status 从 clarification 降级为 asking,前端只渲染
# 文本气泡不弹卡片,避免用户看到"同样选项又出现一次"。
if _is_repeated_clarification(
messages,
new_question=question,
new_options=cargs.get("options"),
new_tool_call_id=clarification_call.get("id"),
):
logger.warning(
"intent stream: dropping duplicate ask_clarification "
"(question=%r) — already asked & answered earlier in thread",
question[:80],
clarification_payloads: list[dict[str, Any]] = []
for clarification_call in clarification_calls:
cargs = clarification_call.get("args") or {}
question = str(cargs.get("question") or "").strip()
# 逐卡过滤已被用户回答过的重复问题,不能因为其中一张
# 重复卡而吞掉同轮其它有效的协助卡。
if _is_repeated_clarification(
messages,
new_question=question,
new_options=cargs.get("options"),
new_tool_call_id=clarification_call.get("id"),
):
logger.warning(
"intent stream: dropping duplicate ask_clarification "
"(question=%r) — already asked & answered earlier in thread",
question[:80],
)
continue
clarification_payloads.append(
{
"id": str(clarification_call.get("id") or ""),
"question": question,
"clarification_type": str(cargs.get("clarification_type") or ""),
"clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
}
)
if not clarification_payloads:
fallback_msg = (
display_content
follow_up
or content
or "(系统识别到智能体重复了上一问题,已自动跳过。请补充其他信息继续推进。)"
)
final_frame: dict[str, Any] = {
final_frame = {
"status": "asking",
"content": fallback_msg,
"summary": None,
@@ -562,15 +713,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
return
first = clarification_payloads[0]
display_content = follow_up or content or str(first["question"])
final_frame: dict[str, Any] = {
"status": "clarification",
"content": display_content,
"question": question,
"clarification_type": str(cargs.get("clarification_type") or ""),
"clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
# 兼容旧客户端保留首张卡的顶层字段;完整列表使得网络
# 重连、缓存重放等没有实时 tool frame 的路径也能还原多卡。
**first,
"clarifications": clarification_payloads,
"summary": None,
}
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
@@ -470,15 +470,64 @@ def _markdown_delivery_changed(
return bool(new_signatures - old_signatures)
def _intent_string_list(intent: dict[str, Any], key: str) -> list[str]:
"""Return a clean, display-safe string list from a persisted intent field."""
value = intent.get(key)
if not isinstance(value, list):
return []
return [item.strip() for item in value if isinstance(item, str) and item.strip()]
def _position_intent_sections(intent: Any) -> dict[str, list[str]]:
"""Read both the dedicated position-intent schema and legacy snapshots.
New position sessions persist the four position-specific fields. Mapping
the legacy Step-1 fields keeps already-created sessions executable after
this rollout instead of stranding them at the activation gate.
"""
if not isinstance(intent, dict):
return {
"coreGoals": [],
"riskWarnings": [],
"keyPoints": [],
"strategicSignificance": [],
}
has_position_schema = any(
key in intent
for key in ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
)
if has_position_schema:
return {
"coreGoals": _intent_string_list(intent, "coreGoals"),
"riskWarnings": _intent_string_list(intent, "riskWarnings"),
"keyPoints": _intent_string_list(intent, "keyPoints"),
"strategicSignificance": _intent_string_list(intent, "strategicSignificance"),
}
objective = _text_from_keys(intent, ["objective", "goal"])
return {
"coreGoals": _intent_string_list(intent, "constraints"),
"riskWarnings": [],
"keyPoints": _intent_string_list(intent, "assumptions"),
"strategicSignificance": [objective] if objective else [],
}
def _intent_node_answer(intent: Any) -> str:
if not isinstance(intent, dict):
return "情报分析岗已完成任务意图识别。"
objective = _text_from_keys(intent, ["objective", "goal"], "已完成任务意图识别")
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
lines = ["情报分析岗已完成任务意图识别。", f"任务目标:{objective}"]
if constraints:
lines.append("关键约束:")
lines.extend(f"- {item}" for item in constraints if isinstance(item, str) and item.strip())
sections = _position_intent_sections(intent)
lines = ["情报分析岗已完成任务意图识别。"]
labels = (
("核心目标", sections["coreGoals"]),
("风险提示", sections["riskWarnings"]),
("关键要点", sections["keyPoints"]),
("战略意义", sections["strategicSignificance"]),
)
for label, values in labels:
if values:
lines.append(f"{label}:")
lines.extend(f"- {item}" for item in values)
return "\n".join(lines)
@@ -486,6 +535,15 @@ def _has_confirmed_intent(intent: Any) -> bool:
"""Only structured Step-1 output may start a frozen business chain."""
if not isinstance(intent, dict):
return False
if any(
key in intent
for key in ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
):
required = ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
if not all(isinstance(intent.get(key), list) for key in required):
return False
sections = _position_intent_sections(intent)
return bool(sections["coreGoals"] or sections["strategicSignificance"])
objective = _text_from_keys(intent, ["objective", "goal"])
constraints = intent.get("constraints")
return bool(objective and isinstance(constraints, list))
@@ -535,8 +593,7 @@ def _build_node_context_text(
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
sections = _position_intent_sections(intent)
agent_name, position_id = _seat_meta(session, current)
md_required = not _is_intent_position_node(current, _session_intent_position_id(session))
lines = [
@@ -548,11 +605,16 @@ def _build_node_context_text(
f"- 任务描述:{description}",
"",
"【已识别意图】",
f"- 任务目标:{objective}",
]
if constraints:
lines.append("- 关键约束:")
lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
for label, values in (
("核心目标", sections["coreGoals"]),
("风险提示", sections["riskWarnings"]),
("关键要点", sections["keyPoints"]),
("战略意义", sections["strategicSignificance"]),
):
if values:
lines.append(f"- {label}:")
lines.extend(f" - {item}" for item in values)
lines.extend(
[
"",
@@ -1005,8 +1067,7 @@ async def _build_builtin_prompt(
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
sections = _position_intent_sections(intent)
stage_goals = chain.get("stageGoals") if isinstance(chain.get("stageGoals"), list) else []
stage_lines = [f"- L-{index + 1}:{goal}" for index, goal in enumerate(stage_goals) if isinstance(goal, str) and goal.strip()]
material = await _effective_delivery_material(session, nodes)
@@ -1020,11 +1081,16 @@ async def _build_builtin_prompt(
f"- 描述:{description}",
"",
"【情报分析岗已确认的意图】",
f"- 核心目标:{objective}",
]
if constraints:
common.append("- 关键约束:")
common.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
for label, values in (
("核心目标", sections["coreGoals"]),
("风险提示", sections["riskWarnings"]),
("关键要点", sections["keyPoints"]),
("战略意义", sections["strategicSignificance"]),
):
if values:
common.append(f"- {label}:")
common.extend(f" - {item}" for item in values)
if stage_lines:
common.extend(["", "【业务链阶段】", *stage_lines])
@@ -58,6 +58,10 @@ class ChainSeatSchema(BaseModel):
# 与该席位 agent 自身 config.yaml 的同名开关取 OR。默认 False(关)。整条 seats 数组以
# JSON 透传持久化,故新增此字段无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。
seat_skill_directive: bool = False
# Per-seat human checkpoint. Step 2 pauses after this seat (or its DAG
# stage) completes and reuses the existing resume/intervention controls.
# It is stored in the existing seats JSON, so no database migration is needed.
human_validation: bool = False
# 该席位「推理深度覆盖」(flash/thinking/pro/ultra):覆盖链条级 seat_mode,仅本席位生效。
# None = 跟随链条级默认。「卡顿提速」用:把次要席位降到 flash。整条 seats 数组以 JSON 透传
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
@@ -14,9 +14,14 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway import deps as gateway_deps
from app.gateway import services as gateway_services
from app.gateway.app import create_app
from app.gateway.routers import _roundtable_seed as roundtable_seed
from app.gateway.routers import intent as intent_router
from app.gateway.routers import multi_agent
from app.gateway.routers import position_roundtable as position_roundtable_router
from app.gateway.routers._roundtable_seed import (
POSITION_INTENT_AGENT_ID,
required_agent_ids,
)
from deerflow.persistence.base import Base
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
@@ -55,6 +60,111 @@ def test_position_roundtable_router_has_intranet_compatibility_prefix():
assert position_roundtable_router.router.prefix == "/position-roundtable"
def test_position_intent_agent_is_a_seeded_functional_agent():
"""Fresh deployments must materialize the dedicated position intent agent."""
assert POSITION_INTENT_AGENT_ID in set(required_agent_ids())
def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
"""Dedicated schema must not regress into solutioning or gatekeeping."""
prompt = (
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
).read_text(encoding="utf-8")
for phrase in (
"最多 5 轮用户协助",
"除 ask_clarification 外,禁止调用任何工具、技能或操作",
"你都不拒绝、不评价、不设限",
"不得说“不能帮你”“不在职责范围内”“请换一个问题”",
"coreGoals / riskWarnings / keyPoints / strategicSignificance",
"不能把“主题已识别”误判为“意图已明确”",
"任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助",
"必须分析任务级风险",
"同一轮可发起多张用户协助卡",
"不得把多个问题塞进一张卡",
):
assert phrase in prompt
def test_position_intent_clarification_waits_for_the_user_before_ready():
"""A same-run READY block must not consume the user-assistance pause."""
assert intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
intent_router.INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
None,
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=False,
)
def test_position_intent_counts_multi_card_assistance_as_one_round():
"""Five user-assistance rounds must not be consumed by five cards at once."""
messages: list[dict[str, Any]] = [
{
"type": "ai",
"tool_calls": [
{"id": "scope", "name": "ask_clarification", "args": {}},
{"id": "purpose", "name": "ask_clarification", "args": {}},
{"id": "time", "name": "ask_clarification", "args": {}},
],
},
{"type": "human", "content": "用户统一答复"},
{
"type": "ai",
"tool_calls": [
{"id": "risk", "name": "ask_clarification", "args": {}},
],
},
]
assert intent_router._count_past_clarifications(messages) == 4
assert intent_router._count_past_clarification_rounds(messages) == 2
assert intent_router._max_clarification_rounds(intent_router.INTENT_AGENT_ID) == 3
assert (
intent_router._max_clarification_rounds(intent_router.POSITION_INTENT_AGENT_ID)
== 5
)
def test_position_intent_seed_repairs_its_contract_without_touching_other_agents(tmp_path, monkeypatch):
"""The dedicated schema prompt must replace an accidentally seeded old version."""
agents_dir = tmp_path / "agents"
monkeypatch.setattr(
roundtable_seed,
"get_paths",
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
)
position_dir = agents_dir / POSITION_INTENT_AGENT_ID
position_dir.mkdir(parents=True)
(position_dir / "SOUL.md").write_text("outdated prompt", encoding="utf-8")
assert roundtable_seed._ensure_one(POSITION_INTENT_AGENT_ID) is True
expected_soul = (
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
).read_text(encoding="utf-8")
assert (position_dir / "SOUL.md").read_text(encoding="utf-8") == expected_soul
generic_dir = agents_dir / roundtable_seed.INTENT_AGENT_ID
generic_dir.mkdir(parents=True)
generic_soul = generic_dir / "SOUL.md"
generic_soul.write_text("deployed generic tune", encoding="utf-8")
assert roundtable_seed._ensure_one(roundtable_seed.INTENT_AGENT_ID) is False
assert generic_soul.read_text(encoding="utf-8") == "deployed generic tune"
@pytest.mark.asyncio
async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch):
"""Step 1 initialization must not depend on the deployed Gateway port."""
@@ -91,6 +201,71 @@ async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch)
}
@pytest.mark.asyncio
async def test_position_intent_init_selects_its_dedicated_agent(monkeypatch):
"""Position roundtable must never mutate the generic Step-1 agent contract."""
captured: dict[str, object] = {}
monkeypatch.setattr(intent_router, "ensure_roundtable_functional_agents", lambda: None)
async def fake_create_thread_direct(request, *, metadata):
captured["metadata"] = metadata
return "position-intent-thread"
monkeypatch.setattr(intent_router, "_create_thread_direct", fake_create_thread_direct)
result = await intent_router.init_intent(
SimpleNamespace(),
{
"intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
"task_context": {"task": {"id": "task-position-001"}},
},
)
assert result == {
"status": "success",
"agent_id": intent_router.POSITION_INTENT_AGENT_ID,
"thread_id": "position-intent-thread",
}
assert captured["metadata"] == {
**intent_router._ROUNDTABLE_THREAD_METADATA,
"taskId": "task-position-001",
"position_roundtable_task_id": "task-position-001",
"position_task_id": "task-position-001:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
"position_roundtable_intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
}
def test_position_intent_schema_is_required_for_new_snapshots_and_legacy_is_kept():
assert position_roundtable_router._has_confirmed_intent(
{
"coreGoals": ["形成统一处置方案"],
"riskWarnings": [],
"keyPoints": ["需要在本周完成"],
"strategicSignificance": ["为后续岗位协同提供统一边界"],
}
)
assert not position_roundtable_router._has_confirmed_intent(
{"coreGoals": ["形成统一处置方案"], "riskWarnings": []}
)
assert position_roundtable_router._has_confirmed_intent(
{"objective": "历史任务", "constraints": []}
)
answer = position_roundtable_router._intent_node_answer(
{
"coreGoals": ["形成统一处置方案"],
"riskWarnings": ["关键数据尚待确认"],
"keyPoints": ["聚焦当前任务范围"],
"strategicSignificance": ["支撑业务链协同推进"],
}
)
assert "核心目标" in answer
assert "风险提示" in answer
assert "关键要点" in answer
assert "战略意义" in answer
@pytest.mark.asyncio
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
created = await store.create_session(
@@ -116,3 +116,13 @@ async def test_unknown_mode_value_ignored_falls_through(_gw):
await _run(gateway)
assert captured["thinking_enabled"] is True
assert captured["reasoning_effort"] == "low"
def test_chain_seat_schema_keeps_human_validation_checkpoint():
"""Chain-seat JSON must preserve the frontend Step 2 pause configuration."""
from app.gateway.routers.roundtable_chains import ChainSeatSchema
assert ChainSeatSchema(agent_id="seat-1").human_validation is False
assert ChainSeatSchema(agent_id="seat-1", human_validation=True).model_dump()[
"human_validation"
] is True