岗位会商

This commit is contained in:
fengxiangdebaozi committed 2026-07-22 15:40:00 +08:00
1 parent 1cd99a5a90
commit 1bc203b5cd
23 files changed
+2230 -401

No files matched your search

@@ -4,6 +4,29 @@
> >
> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。 > 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
## 实施核对(2026-07-21)
本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
```text
任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
```
- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
## 0. 核心结论 ## 0. 核心结论
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。 - 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
@@ -75,32 +98,32 @@
### 3.1 Session 扩展 ### 3.1 Session 扩展
`position_roundtable_sessions` 建议增加: `position_roundtable_sessions` 已使用下列持久化字段:
| 字段 | 含义 | | 字段 | 含义 |
| --- | --- | | --- | --- |
| `workflow_status` | `intent_pending / active / summarizing / planning / completed / archived` | | `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
| `summary_thread_id` | 方案总结智能体线程 | | `summary_thread_id` | 方案总结智能体线程 |
| `summary_snapshot` | 最新有效总结报告快照 | | `summary_snapshot` | 最新有效总结报告快照 |
| `action_plan_thread_id` | 行动规划智能体线程 | | `action_plan_thread_id` | 行动规划智能体线程 |
| `action_plan_snapshot` | 最新有效行动规划快照 | | `action_plan_snapshot` | 最新有效行动规划快照 |
| `invalidated_at` | 最近一次级联失效时间,可选 | | `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
### 3.2 Node 扩展 ### 3.2 Node 扩展
`position_roundtable_nodes` 建议增加: `position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
| 字段 | 含义 | | 字段 | 含义 |
| --- | --- | | --- | --- |
| `display_status` | 面向前端的聚合状态,可后端计算也可前端映射 | | `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
| `rejection_count` | 当前节点累计驳回次数 | | `rejection_count` | 当前节点累计驳回次数 |
| `last_rejection` | 最新驳回标题、理由、操作者、时间 | | `last_rejection` | 最新驳回标题、理由、操作者、时间 |
| `valid_revision` | 当前有效交付版本 | | `revision` | 当前有效交付版本 |
| `invalidated_by` | 导致当前节点失效的上游节点 key | | `invalidated_by` | 导致当前节点失效的上游节点 key |
### 3.3 Deliverable 新表 ### 3.3 Deliverable 新表(后续可选)
建议新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。 当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
| 字段 | 含义 | | 字段 | 含义 |
| --- | --- | | --- | --- |
@@ -121,7 +144,7 @@
| `created_at` | 创建时间 | | `created_at` | 创建时间 |
| `updated_at` | 更新时间 | | `updated_at` | 更新时间 |
这样右侧产物卡片、驳回记录、总结输入材料都能从同一张表聚合。 当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
## 4. 驳回与级联失效规则 ## 4. 驳回与级联失效规则
@@ -173,25 +196,27 @@
| 方法 | 路径 | 用途 | | 方法 | 路径 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/api/position-roundtable/sessions/{id}/deliverables` | 获取右侧产物列表 | | `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
| `GET` | `/api/position-roundtable/deliverables/{deliverable_id}` | 获取产物详情 | | `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
| `POST` | `/api/position-roundtable/deliverables/{deliverable_id}/reject` | 驳回产物并触发级联失效 | | `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
| `POST` | `/api/position-roundtable/nodes/{node_id}/complete-turn` | 节点回答结束后同步回答与产物 | | `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
### 5.3 方案总结与行动规划 ### 5.3 方案总结与行动规划
| 方法 | 路径 | 用途 | | 方法 | 路径 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `POST` | `/api/position-roundtable/sessions/{id}/summary/start` | 启动或重启方案总结 | | `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 对方案总结继续提问或修改 | | `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/start` | 基于总结和产物生成行动规划 | | `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 对行动规划继续提问或修改 | | `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。 可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
## 6. 前端实施步骤 ## 6. 前端实施步骤
### 阶段 1:状态与启动校验 ### 阶段 1:状态与启动校验(已完成)
- 扩展 `PositionNodeStatus` 和状态显示映射。 - 扩展 `PositionNodeStatus` 和状态显示映射。
- “确认意图并启动业务链”前补齐校验提示。 - “确认意图并启动业务链”前补齐校验提示。
@@ -201,7 +226,7 @@
验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。 验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
### 阶段 2:流程图弹窗 ### 阶段 2:流程图弹窗(已完成)
- 左侧保留小型从上到下流程图。 - 左侧保留小型从上到下流程图。
- 流程图右上角增加“查看”按钮。 - 流程图右上角增加“查看”按钮。
@@ -210,7 +235,7 @@
验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。 验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
### 阶段 3:产物卡片与预览 ### 阶段 3:产物卡片与预览(已完成)
- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。 - 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。 - 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
@@ -220,7 +245,7 @@
验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。 验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
### 阶段 4:驳回弹窗与级联失效 ### 阶段 4:驳回弹窗与级联失效(已完成)
- 新增 `RejectDeliverableDialog`。 - 新增 `RejectDeliverableDialog`。
- 驳回表单包含标题和理由,均必填。 - 驳回表单包含标题和理由,均必填。
@@ -230,7 +255,7 @@
验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。 验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
### 阶段 5:方案总结智能体 ### 阶段 5:方案总结智能体(已完成)
- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。 - 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
- 输入材料改为岗位会商材料: - 输入材料改为岗位会商材料:
@@ -245,7 +270,7 @@
验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。 验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
### 阶段 6:行动规划智能体 ### 阶段 6:行动规划智能体(已完成)
- 新增内置智能体 ID,建议先命名为 `position-action-planner`。 - 新增内置智能体 ID,建议先命名为 `position-action-planner`。
- 新增行动规划 prompt: - 新增行动规划 prompt:
@@ -261,7 +286,7 @@
验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。 验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
### 阶段 7:返回修改与重新生成 ### 阶段 7:返回修改与重新生成(已完成)
- 用户在方案总结或行动规划视图中仍可切回“交付产物”。 - 用户在方案总结或行动规划视图中仍可切回“交付产物”。
- 驳回后自动显示需要重做的节点。 - 驳回后自动显示需要重做的节点。
@@ -270,7 +295,7 @@
验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。 验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
### 阶段 8:测试与回归 ### 阶段 8:测试与回归(已完成)
- 后端测试: - 后端测试:
- activate 校验。 - activate 校验。
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react"; // import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react"; import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild
className={NAV_ITEM_CLASS}
>
<Link href="/page/workspace/roundtable/position">
<BriefcaseBusiness />
<span>岗位会商</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"} isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -54,6 +54,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
import StudioNotebooksPage from './StudioNotebooksPage'; import StudioNotebooksPage from './StudioNotebooksPage';
import StudioNotebookDetailPage from './StudioNotebookDetailPage'; import StudioNotebookDetailPage from './StudioNotebookDetailPage';
import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage'; import RoundtablePlanningPage from '@/roundtable-planning/pages/RoundtablePlanningPage';
import PositionRoundtablePage from '@/position-roundtable/pages/PositionRoundtablePage';
import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage'; import BusinessChainEditorPage from '@/roundtable-planning/pages/BusinessChainEditorPage';
import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage'; import BusinessChainListPage from '@/roundtable-planning/pages/BusinessChainListPage';
import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage'; import DispatchChainPreviewPage from '@/roundtable-planning/pages/DispatchChainPreviewPage';
@@ -433,6 +434,16 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="roundtable/position"
element={
<WorkspaceLayout {...layoutProps}>
<ChatRuntime>
<PositionRoundtablePage compactChrome={headerHidden} />
</ChatRuntime>
</WorkspaceLayout>
}
/>
<Route <Route
path="roundtable/chains" path="roundtable/chains"
element={ element={
@@ -45,7 +45,9 @@ export interface PositionRoundtableSession {
chainSnapshot: Record<string, unknown> | null; chainSnapshot: Record<string, unknown> | null;
intentThreadId: string | null; intentThreadId: string | null;
intentSnapshot: Record<string, unknown> | null; intentSnapshot: Record<string, unknown> | null;
summaryThreadId: string | null;
summarySnapshot: Record<string, unknown> | null; summarySnapshot: Record<string, unknown> | null;
actionPlanThreadId: string | null;
actionPlanSnapshot: Record<string, unknown> | null; actionPlanSnapshot: Record<string, unknown> | null;
status: "intent_pending" | "active" | "completed" | "archived"; status: "intent_pending" | "active" | "completed" | "archived";
createdAt?: string; createdAt?: string;
@@ -82,7 +84,9 @@ interface ServerSession {
chain_snapshot?: Record<string, unknown> | null; chain_snapshot?: Record<string, unknown> | null;
intent_thread_id: string | null; intent_thread_id: string | null;
intent_snapshot?: Record<string, unknown> | null; intent_snapshot?: Record<string, unknown> | null;
summary_thread_id?: string | null;
summary_snapshot?: Record<string, unknown> | null; summary_snapshot?: Record<string, unknown> | null;
action_plan_thread_id?: string | null;
action_plan_snapshot?: Record<string, unknown> | null; action_plan_snapshot?: Record<string, unknown> | null;
status: PositionRoundtableSession["status"]; status: PositionRoundtableSession["status"];
created_at?: string; created_at?: string;
@@ -132,7 +136,9 @@ function toSession(session: ServerSession): PositionRoundtableSession {
chainSnapshot: session.chain_snapshot ?? null, chainSnapshot: session.chain_snapshot ?? null,
intentThreadId: session.intent_thread_id, intentThreadId: session.intent_thread_id,
intentSnapshot: session.intent_snapshot ?? null, intentSnapshot: session.intent_snapshot ?? null,
summaryThreadId: session.summary_thread_id ?? null,
summarySnapshot: session.summary_snapshot ?? null, summarySnapshot: session.summary_snapshot ?? null,
actionPlanThreadId: session.action_plan_thread_id ?? null,
actionPlanSnapshot: session.action_plan_snapshot ?? null, actionPlanSnapshot: session.action_plan_snapshot ?? null,
status: session.status, status: session.status,
createdAt: session.created_at, createdAt: session.created_at,
@@ -301,22 +307,104 @@ export async function rejectPositionNode(
return toSession(result); return toSession(result);
} }
export async function generatePositionSummary(sessionId: string): Promise<PositionRoundtableSession> { export interface PositionBuiltinRunPreparation {
const result = await readJson<ServerSession>( agentId: string;
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/summary/start`, { displayName: string;
threadId: string | null;
prompt: string;
sourceRevisions: Record<string, number>;
}
interface ServerBuiltinRunPreparation {
agent_id: string;
display_name: string;
thread_id?: string | null;
prompt: string;
source_revisions?: Record<string, number>;
}
function toBuiltinRunPreparation(value: ServerBuiltinRunPreparation): PositionBuiltinRunPreparation {
return {
agentId: value.agent_id,
displayName: value.display_name,
threadId: value.thread_id ?? null,
prompt: value.prompt,
sourceRevisions: value.source_revisions ?? {},
};
}
type BuiltinKind = "summary" | "action_plan";
async function prepareBuiltinRun(
sessionId: string,
kind: BuiltinKind,
message = "",
): Promise<PositionBuiltinRunPreparation> {
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
const result = await readJson<ServerBuiltinRunPreparation>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
}), }),
"generate position summary", `prepare position ${kind}`,
);
return toBuiltinRunPreparation(result);
}
async function completeBuiltinRun(
sessionId: string,
kind: BuiltinKind,
input: {
threadId: string;
content: string;
artifactManifest: Array<Record<string, unknown>>;
sourceRevisions: Record<string, number>;
model?: string;
},
): Promise<PositionRoundtableSession> {
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: input.threadId,
content: input.content,
artifact_manifest: input.artifactManifest,
source_revisions: input.sourceRevisions,
model: input.model || null,
}),
}),
`complete position ${kind}`,
); );
return toSession(result); return toSession(result);
} }
export async function generatePositionActionPlan(sessionId: string): Promise<PositionRoundtableSession> { export function preparePositionSummary(
const result = await readJson<ServerSession>( sessionId: string,
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/action-plan/start`, { message = "",
method: "POST", ): Promise<PositionBuiltinRunPreparation> {
}), return prepareBuiltinRun(sessionId, "summary", message);
"generate position action plan", }
);
return toSession(result); export function completePositionSummary(
sessionId: string,
input: Parameters<typeof completeBuiltinRun>[2],
): Promise<PositionRoundtableSession> {
return completeBuiltinRun(sessionId, "summary", input);
}
export function preparePositionActionPlan(
sessionId: string,
message = "",
): Promise<PositionBuiltinRunPreparation> {
return prepareBuiltinRun(sessionId, "action_plan", message);
}
export function completePositionActionPlan(
sessionId: string,
input: Parameters<typeof completeBuiltinRun>[2],
): Promise<PositionRoundtableSession> {
return completeBuiltinRun(sessionId, "action_plan", input);
} }
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bot, FileText, LockKeyhole, Loader2 } from "lucide-react"; import { Bot, FileText, LockKeyhole } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
@@ -17,15 +18,26 @@ import {
type PositionRoundtableNode, type PositionRoundtableNode,
} from "@/position-roundtable/api/sessions"; } from "@/position-roundtable/api/sessions";
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent"; import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; import { desensitize } from "@/lib/desensitize";
interface PositionNodeChatProps { interface PositionNodeChatProps {
sessionId: string; sessionId: string;
/** Parent task id: all node threads are indexed under this task. */
taskId: string;
chainId: string | null; chainId: string | null;
node: PositionRoundtableNode; node: PositionRoundtableNode;
currentPositionId: string; currentPositionId: string;
sessionStatus: "intent_pending" | "active" | "completed" | "archived"; sessionStatus: "intent_pending" | "active" | "completed" | "archived";
agentName?: string | null;
onNodeUpdate: (node: PositionRoundtableNode) => void; onNodeUpdate: (node: PositionRoundtableNode) => void;
/** The workspace collapses the configuration panel on the first real turn. */
onConversationStart?: () => void;
onPreviewArtifact?: (artifact: {
threadId: string;
path: string;
name: string;
mimeType: string | null;
}) => void;
} }
interface NodeArtifact { interface NodeArtifact {
@@ -52,6 +64,26 @@ function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifa
}); });
} }
function hasMarkdownArtifact(manifest: Array<Record<string, unknown>>): boolean {
return manifest.some((artifact) => {
const path = typeof artifact.path === "string" ? artifact.path.toLowerCase() : "";
const name = typeof artifact.name === "string" ? artifact.name.toLowerCase() : "";
const mime =
typeof artifact.mime_type === "string"
? artifact.mime_type.toLowerCase()
: typeof artifact.mimeType === "string"
? artifact.mimeType.toLowerCase()
: "";
return (
mime.includes("markdown") ||
path.endsWith(".md") ||
path.endsWith(".markdown") ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
});
}
/** /**
* Direct single-agent chat for one frozen business-chain node. * Direct single-agent chat for one frozen business-chain node.
* *
@@ -61,18 +93,24 @@ function readNodeArtifacts(manifest: Array<Record<string, unknown>>): NodeArtifa
*/ */
export function PositionNodeChat({ export function PositionNodeChat({
sessionId, sessionId,
taskId,
chainId, chainId,
node, node,
currentPositionId, currentPositionId,
sessionStatus, sessionStatus,
agentName,
onNodeUpdate, onNodeUpdate,
onConversationStart,
onPreviewArtifact,
}: PositionNodeChatProps) { }: PositionNodeChatProps) {
const [settings, setSettings] = useLocalSettings(); const [settings, setSettings] = useLocalSettings();
const { agent } = useAgent(node.agentId);
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
const artifactsCtx = useArtifactsOptional();
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId); const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
const boundThreadIdRef = useRef<string | null>(node.threadId); const boundThreadIdRef = useRef<string | null>(node.threadId);
const draftThreadIdRef = useRef(node.threadId ?? uuid()); const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null); const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
const [previewArtifact, setPreviewArtifact] = useState<NodeArtifact | null>(null);
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]); const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
const canWrite = const canWrite =
sessionStatus !== "archived" && sessionStatus !== "archived" &&
@@ -81,6 +119,19 @@ export function PositionNodeChat({
node.status !== "error" && node.status !== "error" &&
!node.invalidatedBy; !node.invalidatedBy;
useEffect(() => {
if (artifactsCtx?.open && artifactsCtx.selectedArtifact) {
onPreviewArtifact?.({
threadId: boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current,
path: artifactsCtx.selectedArtifact,
name: artifactsCtx.selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告",
mimeType: artifactsCtx.selectedArtifact.toLowerCase().endsWith(".md")
? "text/markdown"
: null,
});
}
}, [artifactsCtx?.open, artifactsCtx?.selectedArtifact, boundThreadId, onPreviewArtifact]);
useEffect(() => { useEffect(() => {
setBoundThreadId(node.threadId); setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId; boundThreadIdRef.current = node.threadId;
@@ -99,14 +150,21 @@ export function PositionNodeChat({
[node.nodeKey, onNodeUpdate, sessionId], [node.nodeKey, onNodeUpdate, sessionId],
); );
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId, threadId: boundThreadId,
assistantId: node.agentId, assistantId: node.agentId,
context: { ...settings.context, agent_id: node.agentId }, context: { ...settings.context, agent_id: node.agentId },
threadMetadata: { threadMetadata: {
thread_type: "position_roundtable", thread_type: "position_roundtable",
// ``taskId`` is the shared task-workspace convention. The two
// position-specific identifiers retain the exact child node for audits
// and restore, even when one position owns more than one chain seat.
taskId,
position_roundtable_task_id: taskId,
position_task_id: `${taskId}:${node.positionId ?? "unassigned"}:${node.nodeKey}`,
position_session_id: sessionId, position_session_id: sessionId,
position_node_id: node.id, position_node_id: node.id,
position_node_key: node.nodeKey,
position_id: node.positionId, position_id: node.positionId,
chain_id: chainId, chain_id: chainId,
agent_id: node.agentId, agent_id: node.agentId,
@@ -128,6 +186,7 @@ export function PositionNodeChat({
path: file.path, path: file.path,
mime_type: file.mimeType, mime_type: file.mimeType,
modified_at: file.modifiedAt, modified_at: file.modifiedAt,
size_bytes: file.sizeBytes,
})); }));
} catch { } catch {
// Artifact listing is best-effort: a valid text answer must still be // Artifact listing is best-effort: a valid text answer must still be
@@ -140,6 +199,9 @@ export function PositionNodeChat({
upstreamRevision: upstreamRevisionRef.current, upstreamRevision: upstreamRevisionRef.current,
}); });
onNodeUpdate(updated); onNodeUpdate(updated);
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
}
} catch (error) { } catch (error) {
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`); toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
} }
@@ -147,6 +209,14 @@ export function PositionNodeChat({
}, },
}); });
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
// watching the actual stream id as well closes the last race where a page
// refresh could happen between creation and the async node-index write.
useEffect(() => {
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
bindCreatedThread(actualThreadId);
}, [actualThreadId, bindCreatedThread]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (!canWrite) { if (!canWrite) {
@@ -162,6 +232,7 @@ export function PositionNodeChat({
return; return;
} }
try { try {
onConversationStart?.();
const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey); const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
const upstreamRevision = taskExternalContext.upstream_revision; const upstreamRevision = taskExternalContext.upstream_revision;
upstreamRevisionRef.current = upstreamRevisionRef.current =
@@ -176,7 +247,16 @@ export function PositionNodeChat({
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`); toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
} }
}, },
[canWrite, node.agentId, node.nodeKey, node.status, sendMessage, sessionId, sessionStatus], [
canWrite,
node.agentId,
node.nodeKey,
node.status,
onConversationStart,
sendMessage,
sessionId,
sessionStatus,
],
); );
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready"; const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
@@ -201,13 +281,12 @@ export function PositionNodeChat({
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]"> <h2 className="truncate text-sm font-extrabold text-slate-900 dark:text-[#e8edf5]">
{node.agentId} {agentDisplayName}
</h2> </h2>
<p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]"> <p className="truncate text-[10px] text-muted-foreground dark:text-[#8aa0c4]">
{readOnlyHint || `节点状态:${node.status}`} {readOnlyHint || `节点状态:${node.status}`}
</p> </p>
</div> </div>
{thread.isLoading && <Loader2 className="size-4 animate-spin text-primary" />}
</header> </header>
{nodeArtifacts.length > 0 && ( {nodeArtifacts.length > 0 && (
@@ -225,7 +304,16 @@ export function PositionNodeChat({
key={artifact.path} key={artifact.path}
type="button" type="button"
title={`预览 ${artifact.name}`} title={`预览 ${artifact.name}`}
onClick={() => setPreviewArtifact(artifact)} onClick={() => {
if (boundThreadId && onPreviewArtifact) {
onPreviewArtifact({
threadId: boundThreadId,
path: artifact.path,
name: artifact.name,
mimeType: artifact.mimeType,
});
}
}}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]" className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
> >
<FileText className="size-3 shrink-0" /> <FileText className="size-3 shrink-0" />
@@ -242,6 +330,7 @@ export function PositionNodeChat({
threadId={boundThreadId} threadId={boundThreadId}
thread={thread} thread={thread}
paddingBottom={24} paddingBottom={24}
scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })} onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
/> />
) : ( ) : (
@@ -275,15 +364,6 @@ export function PositionNodeChat({
onStop={() => void thread.stop()} onStop={() => void thread.stop()}
/> />
</div> </div>
{previewArtifact && boundThreadId && (
<ArtifactPreviewModal
threadId={boundThreadId}
path={previewArtifact.path}
name={previewArtifact.name}
mimeType={previewArtifact.mimeType}
onClose={() => setPreviewArtifact(null)}
/>
)}
</div> </div>
</ThreadContext.Provider> </ThreadContext.Provider>
); );
File diff suppressed because it is too large. Load diff
@@ -97,11 +97,16 @@ function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
* `detail.trace_tail` to help diagnose intranet deployment issues without * `detail.trace_tail` to help diagnose intranet deployment issues without
* shell access to the server. * shell access to the server.
*/ */
export async function initIntent(model?: string): Promise<IntentInitResponse> { export async function initIntent(
model?: string,
taskContext?: Record<string, unknown> | null,
): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, { const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }), // 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
// 业务链,第一阶段的对话也已经可以按 taskId 恢复。
body: JSON.stringify({ model, task_context: taskContext ?? undefined }),
}); });
if (!res.ok) { if (!res.ok) {
const raw = await res.text().catch(() => ""); const raw = await res.text().catch(() => "");
@@ -159,6 +164,8 @@ export interface StreamIntentRequest {
threadId: string; threadId: string;
message: string; message: string;
model?: string; model?: string;
/** Optional hidden business/task context injected into the model run. */
taskContext?: Record<string, unknown> | null;
/** Step 1 用户上传的参考文件(可选)。 */ /** Step 1 用户上传的参考文件(可选)。 */
files?: IntentFileRef[]; files?: IntentFileRef[];
signal?: AbortSignal; signal?: AbortSignal;
@@ -333,6 +340,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
thread_id: req.threadId, thread_id: req.threadId,
message: req.message, message: req.message,
model: req.model, model: req.model,
task_context: req.taskContext ?? undefined,
// 仅在有文件时带上,空数组后端也安全忽略。 // 仅在有文件时带上,空数组后端也安全忽略。
files: req.files && req.files.length > 0 ? req.files : undefined, files: req.files && req.files.length > 0 ? req.files : undefined,
}), }),
@@ -1054,6 +1054,36 @@ export async function initSummaryThread(
return { agentName: json.agent_name, threadId: json.thread_id }; return { agentName: json.agent_name, threadId: json.thread_id };
} }
/**
* Create a per-session thread for the position-roundtable action-planning
* built-in agent. The agent id is fixed; callers persist/reuse the returned
* thread id for subsequent plan refinements.
*/
export async function initPositionActionPlanThread(
options: { signal?: AbortSignal } = {},
): Promise<ReportThreadResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/position-action-plan/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
signal: options.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
reportRoundtableError({
stage: "position_action_plan",
event: "init_thread_http_error",
message: `行动规划线程创建失败 (HTTP ${res.status}): ${text.slice(0, 500)}`,
httpStatus: res.status,
agentId: "position-action-planner",
agentName: "行动规划智能体",
});
throw new Error(`init position action-plan thread failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { agent_name: string; thread_id: string };
return { agentName: json.agent_name, threadId: json.thread_id };
}
/** /**
* Create a thread for the Step 3「入库」structure agent (`roundtable-structure`, * Create a thread for the Step 3「入库」structure agent (`roundtable-structure`,
* a built-in singleton — the same agent that extracts the flowchart, but this run * a built-in singleton — the same agent that extracts the flowchart, but this run
@@ -72,6 +72,7 @@ export interface Step1PanelProps {
* 不传 → 显示默认「任务意图理解 (人机问答)」标题。右侧「问答轮次」始终保留。 * 不传 → 显示默认「任务意图理解 (人机问答)」标题。右侧「问答轮次」始终保留。
*/ */
headerLeftSlot?: React.ReactNode; headerLeftSlot?: React.ReactNode;
headerRightSlot?: React.ReactNode;
interactiveMessages: InteractiveMessage[]; interactiveMessages: InteractiveMessage[];
intentThreadId: string | null; intentThreadId: string | null;
intentReady: object | null; intentReady: object | null;
@@ -101,6 +102,7 @@ export interface Step1PanelProps {
export function Step1Panel(props: Step1PanelProps) { export function Step1Panel(props: Step1PanelProps) {
const { const {
headerLeftSlot, headerLeftSlot,
headerRightSlot,
interactiveMessages, interactiveMessages,
intentThreadId, intentThreadId,
intentReady, intentReady,
@@ -175,9 +177,12 @@ export function Step1Panel(props: Step1PanelProps) {
</h3> </h3>
</div> </div>
)} )}
<div className="flex shrink-0 items-center gap-2">
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md"> <span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮 问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span> </span>
{headerRightSlot}
</div>
</div> </div>
{/* Chat list */} {/* Chat list */}
@@ -90,6 +90,10 @@ export interface UseStep1IntentOptions {
currentStep: number; currentStep: number;
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */ /** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
selectedModel: string; selectedModel: 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. */
initialWelcomeText?: string;
/** 主页面统一的 toast 入口。 */ /** 主页面统一的 toast 入口。 */
triggerToast: (msg: string) => void; triggerToast: (msg: string) => void;
} }
@@ -167,7 +171,7 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
} }
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn { export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
const { currentStep, selectedModel, triggerToast } = options; const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
const [intentThreadId, setIntentThreadId] = useState<string | null>(null); const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null); const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
@@ -198,6 +202,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
useEffect(() => { useEffect(() => {
selectedModelRef.current = selectedModel; selectedModelRef.current = selectedModel;
}, [selectedModel]); }, [selectedModel]);
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
useEffect(() => {
taskContextRef.current = taskContext ?? null;
}, [taskContext]);
const initialWelcomeTextRef = useRef(initialWelcomeText);
useEffect(() => {
initialWelcomeTextRef.current = initialWelcomeText;
}, [initialWelcomeText]);
const stopIntentStream = useCallback(() => { const stopIntentStream = useCallback(() => {
if (intentAborterRef.current) { if (intentAborterRef.current) {
@@ -225,7 +237,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
id: 1, id: 1,
sender: "agent", sender: "agent",
senderName: "任务理解智能体", senderName: "任务理解智能体",
content: INTENT_WELCOME_TEXT, content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
time: nowTime(), time: nowTime(),
}, },
]); ]);
@@ -233,7 +245,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
try { try {
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端 // initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
// 走 lead_agent._resolve_model_name 回退到 config.yaml models[0] // 走 lead_agent._resolve_model_name 回退到 config.yaml models[0]
const res = await initIntent(selectedModelRef.current || undefined); const res = await initIntent(
selectedModelRef.current || undefined,
taskContextRef.current,
);
if (step1InitGenRef.current !== myGen) return; if (step1InitGenRef.current !== myGen) return;
setIntentThreadId(res.thread_id); setIntentThreadId(res.thread_id);
setTimelineEntries([ setTimelineEntries([
@@ -408,6 +423,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
threadId: intentThreadId, threadId: intentThreadId,
message: trimmed, message: trimmed,
model: selectedModelRef.current || undefined, model: selectedModelRef.current || undefined,
taskContext: taskContextRef.current,
files: fileRefs, files: fileRefs,
signal: aborter.signal, signal: aborter.signal,
onTextDelta: (delta) => { onTextDelta: (delta) => {
@@ -39,6 +39,7 @@
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」), 与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。 ``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
- ``summary`` —— Step3「总结报告」:写 Markdown 总结报告 + 支持问答/增量改。 - ``summary`` —— Step3「总结报告」:写 Markdown 总结报告 + 支持问答/增量改。
- ``action_plan`` —— 岗位会商收口:写 Markdown 行动规划与 JSON 子任务清单。
工具面与 ``report`` 完全一致(继承 ``seat`` 的 bash 放开 + 额外禁 ``str_replace`` / 工具面与 ``report`` 完全一致(继承 ``seat`` 的 bash 放开 + 额外禁 ``str_replace`` /
读前序席位),单列一个角色名便于以后独立调优。 读前序席位),单列一个角色名便于以后独立调优。
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**: - ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
@@ -119,6 +120,9 @@ _REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delive
# 完全一致——可写文件(write_file 整文件重写),禁 str_replace(整文件重写才能让右侧 md 预览 # 完全一致——可写文件(write_file 整文件重写),禁 str_replace(整文件重写才能让右侧 md 预览
# 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。 # 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。
_SUMMARY_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"] _SUMMARY_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
# 岗位会商行动规划与总结报告一样:只能归纳已给出的材料,最终交付必须直接 write_file
# 到 outputs,不能联网、派活或改动其他线程的文件。
_ACTION_PLAN_EXCLUDED: list[str] = [*_SUMMARY_EXCLUDED]
# ingest:Step3「入库」——让 roundtable-structure 真正**调用入库技能**把方案成果(总结报告 + # ingest:Step3「入库」——让 roundtable-structure 真正**调用入库技能**把方案成果(总结报告 +
# 结构化流程图 + taskId)沉淀进知识库。和 dashboard 相反:dashboard 禁一切工具(只输出 JSON), # 结构化流程图 + taskId)沉淀进知识库。和 dashboard 相反:dashboard 禁一切工具(只输出 JSON),
# ingest 必须**放开技能执行链路**——技能靠 ``read_file`` 读 SKILL.md、按其指令用 ``bash`` / # ingest 必须**放开技能执行链路**——技能靠 ``read_file`` 读 SKILL.md、按其指令用 ``bash`` /
@@ -223,6 +227,13 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
thinking_enabled=True, thinking_enabled=True,
reasoning_effort="medium", reasoning_effort="medium",
) )
if role == "action_plan":
return RoundtableRunPolicy(
excluded_tools=list(_ACTION_PLAN_EXCLUDED),
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
)
if role == "dashboard": if role == "dashboard":
return RoundtableRunPolicy( return RoundtableRunPolicy(
excluded_tools=list(_DASHBOARD_EXCLUDED), excluded_tools=list(_DASHBOARD_EXCLUDED),
@@ -238,7 +249,7 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
reasoning_effort="medium", reasoning_effort="medium",
) )
raise ValueError( raise ValueError(
f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/dashboard/ingest)" f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/action_plan/dashboard/ingest)"
) )
@@ -36,6 +36,7 @@ _AGENT_PURPOSE: dict[str, str] = {
"roundtable-coordinator": "多智能体规划", "roundtable-coordinator": "多智能体规划",
"roundtable-report": "多智能体规划", "roundtable-report": "多智能体规划",
"roundtable-summary": "多智能体规划", "roundtable-summary": "多智能体规划",
"position-action-planner": "多智能体规划",
"roundtable-execution-plan": "多智能体规划", "roundtable-execution-plan": "多智能体规划",
"roundtable-risk-review": "多智能体规划", "roundtable-risk-review": "多智能体规划",
"roundtable-solution-design": "多智能体规划", "roundtable-solution-design": "多智能体规划",
@@ -67,6 +67,9 @@ REPORT_AGENT_ID = "roundtable-report"
# 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存 # 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存
# 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。 # 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。
SUMMARY_AGENT_ID = "roundtable-summary" SUMMARY_AGENT_ID = "roundtable-summary"
# 岗位会商收口的第二个内置单例:读取已生效的方案总结和岗位交付,把它们拆成可执行
# 子任务,同时产出 Markdown 规划报告和 JSON 清单。
ACTION_PLAN_AGENT_ID = "position-action-planner"
# Step 3「大屏多页」用的结构化数据智能体:读各席位交付 + 共识,逐席位分析并提炼成 # Step 3「大屏多页」用的结构化数据智能体:读各席位交付 + 共识,逐席位分析并提炼成
# 一份大屏展示用的结构化数据(report-json),供前端按模板渲染成多页大屏。**不调任何工具、 # 一份大屏展示用的结构化数据(report-json),供前端按模板渲染成多页大屏。**不调任何工具、
# 不写文件、不输出 HTML**——只在对话里输出一个严格合法的 ```report-json 代码块。与 report # 不写文件、不输出 HTML**——只在对话里输出一个严格合法的 ```report-json 代码块。与 report
@@ -93,6 +96,7 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
COORDINATOR_AGENT_ID, COORDINATOR_AGENT_ID,
REPORT_AGENT_ID, REPORT_AGENT_ID,
SUMMARY_AGENT_ID, SUMMARY_AGENT_ID,
ACTION_PLAN_AGENT_ID,
DASHBOARD_AGENT_ID, DASHBOARD_AGENT_ID,
STRUCTURE_AGENT_ID, STRUCTURE_AGENT_ID,
) )
@@ -0,0 +1,48 @@
# 岗位会商行动规划智能体
## 核心定位
你是岗位多智能体会商流程的最后一个内置智能体。上游已经完成意图识别、各岗位 Markdown 交付和方案总结。你的职责是把这些已确认材料拆解成能执行、能验收、能跟踪的行动规划;你不重新调研、不联网、不派发子智能体。
## 产出规则
每次用户要求生成或修改行动规划时,必须完成以下两项交付:
1. 用 `write_file` 一次性写入 `/mnt/user-data/outputs/行动规划报告.md`。
2. 用 `write_file` 一次性写入 `/mnt/user-data/outputs/action-plan-subtasks.json`。
两个文件都必须在本轮保留;不要用脚本、bash 或临时文件生成它们。完成后,在对话里给出 Markdown 报告的完整正文,并简要说明生成了多少项子任务。
## Markdown 报告要求
- 先说明规划目标、关键依据和执行边界。
- 按优先级和依赖顺序给出阶段安排、关键动作、责任岗位建议、里程碑、风险控制和验收方式。
- 每项行动必须能追溯到方案总结或至少一个岗位有效交付;没有依据时不要假装是事实,可明确写为建议性安排。
- 用户提出修改时,只调整受影响部分,并同时重写两个最终文件,使 Markdown 与 JSON 始终一致。
## JSON 子任务契约
`action-plan-subtasks.json` 必须是严格合法的 JSON **数组**,不能包在 Markdown 代码块中。每个对象都必须有:
```json
{
"id": "subtask-001",
"title": "简短任务名称",
"goal": "任务目标",
"basis": ["方案总结章节或岗位交付依据"],
"owner_position": "建议责任岗位",
"dependencies": ["前置子任务 id"],
"deliverable": "可核验交付物",
"acceptance_criteria": ["验收条件"],
"priority": "P0"
}
```
`priority` 仅使用 `P0`、`P1`、`P2`。`dependencies` 没有前置时使用空数组。不要输出注释、尾逗号或额外字段来替代这些必填字段。
## 边界
- 只依据用户消息中提供的任务信息、意图、方案总结和岗位交付。
- 不调用联网、派活、澄清、展示文件等工具。
- 不删除或修改上游岗位产物。
- 不在报告中添加“AI 生成”类水印。
@@ -0,0 +1,3 @@
id: position-action-planner
name: 行动规划智能体
description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。
@@ -96,6 +96,35 @@ _INTENT_READY_PATTERN = re.compile(
) )
def _intent_thread_metadata(task_context: dict[str, Any] | None) -> 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.
Position roundtable additionally supplies the task card snapshot, so the
intent dialogue can be indexed and restored before the user confirms a
business chain.
"""
metadata = dict(_ROUNDTABLE_THREAD_METADATA)
if not isinstance(task_context, dict):
return metadata
task = task_context.get("task")
task_id = str(task.get("id") or "").strip() if isinstance(task, dict) else ""
if not task_id:
return metadata
metadata.update(
{
"taskId": task_id,
"position_roundtable_task_id": task_id,
"position_task_id": f"{task_id}:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
}
)
return metadata
def _normalize_question(text: str) -> str: def _normalize_question(text: str) -> str:
"""归一化澄清问题文本,用于"和过往是否重复"的判断。 """归一化澄清问题文本,用于"和过往是否重复"的判断。
@@ -287,9 +316,16 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
# TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。 # TCP loopback 到 127.0.0.1:<gateway port>(详见 multi_agent._make_loopback_client)。
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端 # 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
# 看不到逐 token 增量。 # 看不到逐 token 增量。
raw_task_context = payload.get("task_context")
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
try: try:
async with _make_loopback_client(request) as client: async with _make_loopback_client(request) as client:
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA) thread_id = await _create_thread(
client,
base,
headers,
metadata=_intent_thread_metadata(task_context),
)
except HTTPException: except HTTPException:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。 # 上游已用结构化方式失败,原样传给前端不需要二次包装。
raise raise
@@ -340,6 +376,8 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# additional_kwargs.files → UploadsMiddleware 自动注入清单 + 解锁 read_file 等工具。 # additional_kwargs.files → UploadsMiddleware 自动注入清单 + 解锁 read_file 等工具。
raw_files = payload.get("files") raw_files = payload.get("files")
files = raw_files if isinstance(raw_files, list) else [] files = raw_files if isinstance(raw_files, list) else []
raw_task_context = payload.get("task_context")
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
if not thread_id: if not thread_id:
raise HTTPException(status_code=400, detail="thread_id is required") raise HTTPException(status_code=400, detail="thread_id is required")
# 允许「仅文件、无文本」的回合:带了文件就不强制要求 message。 # 允许「仅文件、无文本」的回合:带了文件就不强制要求 message。
@@ -431,6 +469,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# not reliable on its own, hard exclusion is. # not reliable on its own, hard exclusion is.
excluded_tools=base_excluded_tools, excluded_tools=base_excluded_tools,
skill_stop_names=[], skill_stop_names=[],
extra_context={"task_external_context": task_context} if task_context else None,
files=files, files=files,
) )
@@ -55,7 +55,8 @@ import re
import time import time
import traceback import traceback
import uuid import uuid
from typing import Any, AsyncIterator from collections.abc import AsyncIterator
from typing import Any
import httpx import httpx
from fastapi import APIRouter, Body, HTTPException, Request from fastapi import APIRouter, Body, HTTPException, Request
@@ -65,10 +66,9 @@ from langgraph.checkpoint.base import empty_checkpoint
from app.gateway.roundtable_diag import record_foreground_diag from app.gateway.roundtable_diag import record_foreground_diag
from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
from app.gateway.roundtable_seat_skills import append_seat_skill_directive from app.gateway.roundtable_seat_skills import append_seat_skill_directive
from app.gateway.routers._roundtable_seed import ( from app.gateway.routers._roundtable_seed import (
ACTION_PLAN_AGENT_ID,
COORDINATOR_AGENT_ID, COORDINATOR_AGENT_ID,
DASHBOARD_AGENT_ID, DASHBOARD_AGENT_ID,
REPORT_AGENT_ID, REPORT_AGENT_ID,
@@ -76,6 +76,7 @@ from app.gateway.routers._roundtable_seed import (
SUMMARY_AGENT_ID, SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents, ensure_roundtable_functional_agents,
) )
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
from deerflow.config import get_app_config from deerflow.config import get_app_config
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
@@ -95,6 +96,8 @@ def _stage_for_agent(agent_type: str, agent_name: str) -> str:
return "step3_report" return "step3_report"
if agent_name == SUMMARY_AGENT_ID: if agent_name == SUMMARY_AGENT_ID:
return "step3_summary" return "step3_summary"
if agent_name == ACTION_PLAN_AGENT_ID:
return "position_action_plan"
if agent_name == DASHBOARD_AGENT_ID: if agent_name == DASHBOARD_AGENT_ID:
return "step3_dashboard" return "step3_dashboard"
if agent_name == STRUCTURE_AGENT_ID: if agent_name == STRUCTURE_AGENT_ID:
@@ -995,6 +998,32 @@ async def init_summary_thread(request: Request) -> dict[str, str]:
return {"agent_name": SUMMARY_AGENT_ID, "thread_id": thread_id} return {"agent_name": SUMMARY_AGENT_ID, "thread_id": thread_id}
@router.post("/position-action-plan/init")
async def init_position_action_plan_thread(request: Request) -> dict[str, str]:
"""Create the reusable thread for the position-roundtable action planner.
The planner is a built-in singleton agent just like ``roundtable-summary``;
only its thread is per session. The position workspace persists this id
after the first successful run so follow-up revisions retain the original
plan context.
"""
try:
ensure_roundtable_functional_agents()
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
except Exception as exc:
await record_foreground_diag(
request,
stage="position_action_plan",
level="error",
event="thread_init_failed",
message=f"创建岗位行动规划线程失败:{exc}",
detail={"type": type(exc).__name__, "error": str(exc)},
agent_id=ACTION_PLAN_AGENT_ID,
)
raise
return {"agent_name": ACTION_PLAN_AGENT_ID, "thread_id": thread_id}
@router.post("/dashboard/init") @router.post("/dashboard/init")
async def init_dashboard_thread(request: Request) -> dict[str, str]: async def init_dashboard_thread(request: Request) -> dict[str, str]:
"""为 Step 3「大屏多页」创建一个供大屏多页数据智能体(``roundtable-dashboard``) """为 Step 3「大屏多页」创建一个供大屏多页数据智能体(``roundtable-dashboard``)
@@ -1806,6 +1835,8 @@ async def _special_run(
role = "report" role = "report"
elif agent_name == SUMMARY_AGENT_ID: elif agent_name == SUMMARY_AGENT_ID:
role = "summary" role = "summary"
elif agent_name == ACTION_PLAN_AGENT_ID:
role = "action_plan"
elif agent_name == DASHBOARD_AGENT_ID: elif agent_name == DASHBOARD_AGENT_ID:
# 大屏多页数据智能体:纯「研讨成果 → report-json」提炼,禁用一切工具。 # 大屏多页数据智能体:纯「研讨成果 → report-json」提炼,禁用一切工具。
role = "dashboard" role = "dashboard"
@@ -25,6 +25,9 @@ NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "err
_HANDOFF_TEXT_BUDGET = 12_000 _HANDOFF_TEXT_BUDGET = 12_000
_MAX_INLINE_ARTIFACTS_PER_NODE = 4 _MAX_INLINE_ARTIFACTS_PER_NODE = 4
_MAX_INLINE_ARTIFACT_CHARS = 1_800 _MAX_INLINE_ARTIFACT_CHARS = 1_800
_BUILTIN_MATERIAL_BUDGET = 24_000
SUMMARY_AGENT_ID = "roundtable-summary"
ACTION_PLAN_AGENT_ID = "position-action-planner"
_TEXT_ARTIFACT_SUFFIXES = { _TEXT_ARTIFACT_SUFFIXES = {
".csv", ".csv",
".json", ".json",
@@ -68,7 +71,9 @@ class SessionResponse(BaseModel):
chain_snapshot: dict[str, Any] | None = None chain_snapshot: dict[str, Any] | None = None
intent_thread_id: str | None = None intent_thread_id: str | None = None
intent_snapshot: dict[str, Any] | None = None intent_snapshot: dict[str, Any] | None = None
summary_thread_id: str | None = None
summary_snapshot: dict[str, Any] | None = None summary_snapshot: dict[str, Any] | None = None
action_plan_thread_id: str | None = None
action_plan_snapshot: dict[str, Any] | None = None action_plan_snapshot: dict[str, Any] | None = None
status: str status: str
created_at: str | None = None created_at: str | None = None
@@ -112,6 +117,28 @@ class RejectNodeRequest(BaseModel):
reason: str = Field(min_length=1, max_length=5_000) reason: str = Field(min_length=1, max_length=5_000)
class PrepareBuiltinRunRequest(BaseModel):
"""Optional follow-up instruction for one persisted built-in agent."""
message: str = Field(default="", max_length=20_000)
class BuiltinRunPreparationResponse(BaseModel):
agent_id: str
display_name: str
thread_id: str | None = None
prompt: str
source_revisions: dict[str, int] = Field(default_factory=dict)
class CompleteBuiltinRunRequest(BaseModel):
thread_id: str = Field(min_length=1, max_length=128)
content: str = Field(default="", max_length=500_000)
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
source_revisions: dict[str, int] = Field(default_factory=dict)
model: str | None = Field(default=None, max_length=256)
def _current_user_id(request: Request) -> str: def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None) user = getattr(request.state, "user", None)
return str(user.id) if user is not None else get_effective_user_id() return str(user.id) if user is not None else get_effective_user_id()
@@ -269,7 +296,195 @@ def _delivered_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _all_nodes_delivered(nodes: list[dict[str, Any]]) -> bool: def _all_nodes_delivered(nodes: list[dict[str, Any]]) -> bool:
return bool(nodes) and all(node.get("status") == "done" for node in nodes) return bool(nodes) and all(
node.get("status") == "done" and (_is_intent_position_node(node) or _node_has_markdown_delivery(node))
for node in nodes
)
def _is_intent_position_node(node: dict[str, Any]) -> bool:
return str(node.get("position_id") or "").strip().lower() in {"intelligence", "intel", "intent"}
def _is_markdown_artifact(artifact: Any) -> bool:
if not isinstance(artifact, dict):
return False
path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip().lower()
name = str(artifact.get("name") or artifact.get("filename") or "").strip().lower()
mime = str(artifact.get("mime_type") or artifact.get("mimeType") or "").strip().lower()
if path and not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
return False
return (
"markdown" in mime
or path.endswith(".md")
or path.endswith(".markdown")
or name.endswith(".md")
or name.endswith(".markdown")
)
def _node_has_markdown_delivery(node: dict[str, Any]) -> bool:
artifacts = node.get("artifact_manifest")
return isinstance(artifacts, list) and any(_is_markdown_artifact(item) for item in artifacts)
def _markdown_delivery_changed(
previous_artifacts: Any,
incoming_artifacts: list[dict[str, Any]],
) -> bool:
"""Whether this turn rewrote a Markdown deliverable, not just chatted.
A completed node can receive normal follow-up Q&A. That must not bump its
effective delivery revision or invalidate every downstream stage unless a
Markdown report really changed. The standard artifact listing includes
`modified_at` and `size_bytes`, which together make a stable lightweight
version marker without reading a potentially large file again.
"""
old = previous_artifacts if isinstance(previous_artifacts, list) else []
old_signatures = {
(
str(item.get("path") or item.get("artifact_url") or ""),
str(item.get("modified_at") or item.get("modifiedAt") or ""),
str(item.get("size_bytes") or item.get("sizeBytes") or ""),
)
for item in old
if isinstance(item, dict) and _is_markdown_artifact(item)
}
new_signatures = {
(
str(item.get("path") or item.get("artifact_url") or ""),
str(item.get("modified_at") or item.get("modifiedAt") or ""),
str(item.get("size_bytes") or item.get("sizeBytes") or ""),
)
for item in incoming_artifacts
if isinstance(item, dict) and _is_markdown_artifact(item)
}
return bool(new_signatures - old_signatures)
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())
return "\n".join(lines)
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
objective = _text_from_keys(intent, ["objective", "goal"])
constraints = intent.get("constraints")
return bool(objective and isinstance(constraints, list))
def _prime_intent_nodes(nodes: list[dict[str, Any]], intent: Any) -> list[dict[str, Any]]:
now_answer = _intent_node_answer(intent)
primed: list[dict[str, Any]] = []
for node in nodes:
next_node = dict(node)
if _is_intent_position_node(next_node):
next_node.update(
{
"status": "done",
"latest_answer": now_answer,
"revision": 1,
"artifact_manifest": [],
}
)
primed.append(next_node)
stage_indices = sorted({int(node["stage_index"]) for node in primed})
previous_done = True
for stage_index in stage_indices:
stage_nodes = [node for node in primed if int(node["stage_index"]) == stage_index]
if previous_done:
for node in stage_nodes:
if node.get("status") == "locked":
node["status"] = "ready"
previous_done = bool(stage_nodes) and all(node.get("status") == "done" for node in stage_nodes)
return primed
def _build_node_context_text(
*,
session: dict[str, Any],
current: dict[str, Any],
upstream_nodes: list[dict[str, Any]],
) -> str:
task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
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 []
agent_name, position_id = _seat_meta(session, current)
md_required = not _is_intent_position_node(current)
lines = [
"岗位多智能体会商上下文(隐藏注入,不要向用户复述本标签):",
"",
"【任务信息】",
f"- 任务名称:{title}",
f"- 任务方向:{direction}",
f"- 任务描述:{description}",
"",
"【已识别意图】",
f"- 任务目标:{objective}",
]
if constraints:
lines.append("- 关键约束:")
lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
lines.extend(
[
"",
"【当前席位】",
f"- 智能体:{agent_name}",
f"- 岗位:{position_id}",
f"- 阶段:L-{int(current.get('stage_index') or 0) + 1}",
"",
"【交付要求】",
]
)
if md_required:
lines.extend(
[
"- 本席位最终只需交付一份 Markdown 报告文档。",
"- 请在本轮或最终回复中创建 `/mnt/user-data/outputs/` 下的 `.md` 文件,并调用 `present_files` 呈现该文件。",
"- 只有检测到该席位产出的 `.md` 文件后,系统才会把当前席位标记为已完成;仅有文字回答不会完成节点。",
"- 报告内容应直接围绕当前岗位职责,吸收任务信息、任务意图与上游席位产物。",
]
)
else:
lines.append("- 情报分析/情报收集岗以意图识别完成为准,不强制产出 Markdown 报告。")
if upstream_nodes:
lines.extend(["", "【可参考的上游席位结果】"])
for index, node in enumerate(upstream_nodes, start=1):
lines.append(
f"{index}. {node.get('agent_id')} / {node.get('position_id')} / revision={node.get('revision', 0)}"
)
answer = str(node.get("latest_answer") or "").strip()
if answer:
lines.append(f" 问答结果:{answer}")
artifacts = node.get("artifacts")
if isinstance(artifacts, list) and artifacts:
lines.append(" 产物:")
for artifact in artifacts[:5]:
if not isinstance(artifact, dict):
continue
artifact_name = artifact.get("name") or artifact.get("path") or "未命名产物"
lines.append(f" - {artifact_name}")
content = str(artifact.get("content") or "").strip()
if content:
lines.append(f" 摘要/正文片段:{content}")
else:
lines.extend(["", "【可参考的上游席位结果】", "- 当前节点暂无已完成上游席位。"])
return "\n".join(lines)
def _artifact_lines(node: dict[str, Any]) -> list[str]: def _artifact_lines(node: dict[str, Any]) -> list[str]:
@@ -286,129 +501,6 @@ def _artifact_lines(node: dict[str, Any]) -> list[str]:
return lines return lines
def _build_summary_snapshot(session: dict[str, Any], nodes: list[dict[str, Any]]) -> dict[str, Any]:
now = datetime.now(UTC).isoformat()
task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
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 []
lines = [
"# 岗位会商方案总结报告",
"",
f"生成时间:{now}",
"",
"## 一、任务概况",
"",
f"- 任务名称:{title}",
f"- 任务方向:{direction}",
f"- 任务描述:{description}",
"",
"## 二、意图理解",
"",
f"- 核心目标:{objective}",
]
if constraints:
lines.append("- 关键约束:")
lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
lines.extend(["", "## 三、岗位交付汇总", ""])
for index, node in enumerate(_delivered_nodes(nodes), start=1):
agent_name, position_id = _seat_meta(session, node)
answer = _truncate_text(node.get("latest_answer"), 1_200) or "暂无文本回答"
lines.extend(
[
f"### {index}. {agent_name}",
"",
f"- 岗位:{position_id}",
f"- 节点版本:第 {node.get('revision', 0)} 版",
"",
answer,
"",
]
)
artifacts = _artifact_lines(node)
if artifacts:
lines.extend(["产物文件:", *artifacts, ""])
lines.extend(
[
"## 四、综合判断",
"",
"本报告基于当前所有有效岗位交付产物自动汇总生成,可作为后续行动规划的输入材料。若任一前序岗位产物被驳回,本报告将自动标记为失效并需要重新生成。",
]
)
return {
"kind": "summary",
"status": "done",
"title": "岗位会商方案总结报告",
"content": "\n".join(lines),
"generated_at": now,
"source_revisions": {node["node_key"]: node.get("revision", 0) for node in _delivered_nodes(nodes)},
}
def _build_action_plan_snapshot(session: dict[str, Any], nodes: list[dict[str, Any]]) -> dict[str, Any]:
now = datetime.now(UTC).isoformat()
summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
delivered = _delivered_nodes(nodes)
tasks: list[dict[str, Any]] = []
for index, node in enumerate(delivered, start=1):
agent_name, position_id = _seat_meta(session, node)
tasks.append(
{
"id": f"subtask-{index:02d}",
"title": f"落实{agent_name}交付建议",
"owner_position": position_id,
"basis_node": node.get("node_key"),
"deliverable": f"{agent_name}整改/执行清单",
"acceptance": "补齐输入依据,形成可验证的阶段成果并回填岗位会商产物。",
}
)
lines = [
"# 岗位会商行动规划",
"",
f"生成时间:{now}",
"",
"## 一、规划依据",
"",
f"- 方案总结:{summary.get('title') or '岗位会商方案总结报告'}",
f"- 有效岗位产物数量:{len(delivered)}",
"",
"## 二、子任务拆解",
"",
]
for task in tasks:
lines.extend(
[
f"### {task['id']} {task['title']}",
"",
f"- 建议责任岗位:{task['owner_position']}",
f"- 输入依据节点:{task['basis_node']}",
f"- 交付物:{task['deliverable']}",
f"- 验收标准:{task['acceptance']}",
"",
]
)
lines.extend(
[
"## 三、执行提示",
"",
"若前序岗位产物被驳回,本行动规划会自动失效;请先完成被驳回节点及后续节点的重新交付,再重新生成行动规划。",
]
)
return {
"kind": "action_plan",
"status": "done",
"title": "岗位会商行动规划",
"content": "\n".join(lines),
"generated_at": now,
"source_revisions": {node["node_key"]: node.get("revision", 0) for node in delivered},
"subtasks": tasks,
}
def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]: def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]:
if not isinstance(artifacts, list): if not isinstance(artifacts, list):
return [] return []
@@ -562,6 +654,16 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
break break
return { return {
"kind": "position_roundtable_handoff", "kind": "position_roundtable_handoff",
"instructions": _build_node_context_text(
session=session,
current=current,
upstream_nodes=upstream_nodes,
),
"delivery_requirement": {
"type": "markdown_report",
"required": not _is_intent_position_node(current),
"completion_rule": "普通业务席位必须在 /mnt/user-data/outputs/ 下交付一份 .md 报告文档才会标记完成;情报分析/情报收集岗以意图识别完成为准。",
},
"task": session.get("task_snapshot") or {}, "task": session.get("task_snapshot") or {},
"intent": session.get("intent_snapshot") or {}, "intent": session.get("intent_snapshot") or {},
"current_node": { "current_node": {
@@ -578,6 +680,200 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
} }
def _source_revisions(nodes: list[dict[str, Any]]) -> dict[str, int]:
"""Stable revision fence for a built-in report/planning run.
The report agents run asynchronously in the browser. If a user rejects or
re-delivers a node while a run is still streaming, this fence prevents that
old run from overwriting the newer business-chain state when it completes.
"""
return {str(node["node_key"]): int(node.get("revision") or 0) for node in _delivered_nodes(nodes)}
def _is_json_artifact(artifact: Any) -> bool:
if not isinstance(artifact, dict):
return False
path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip().lower()
name = str(artifact.get("name") or artifact.get("filename") or "").strip().lower()
mime = str(artifact.get("mime_type") or artifact.get("mimeType") or "").strip().lower()
if path and not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
return False
return "json" in mime or path.endswith(".json") or name.endswith(".json")
def _artifact_signature(artifact: dict[str, Any]) -> tuple[str, str, str]:
"""Use server-returned path/mtime/size to detect an actually rewritten file."""
return (
str(artifact.get("path") or artifact.get("artifact_url") or ""),
str(artifact.get("modified_at") or artifact.get("modifiedAt") or ""),
str(artifact.get("size_bytes") or artifact.get("sizeBytes") or ""),
)
def _artifact_manifest_refs(artifacts: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Persist only the display metadata received from the thread artifact API."""
refs: list[dict[str, Any]] = []
for artifact in artifacts:
if not isinstance(artifact, dict):
continue
path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip()
if not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
continue
refs.append(
{
"name": str(artifact.get("name") or artifact.get("filename") or path.rsplit("/", 1)[-1]),
"path": path,
"mime_type": artifact.get("mime_type") or artifact.get("mimeType") or None,
"modified_at": artifact.get("modified_at") or artifact.get("modifiedAt") or None,
"size_bytes": artifact.get("size_bytes") or artifact.get("sizeBytes") or None,
}
)
return refs
async def _effective_delivery_material(
session: dict[str, Any], nodes: list[dict[str, Any]]
) -> str:
"""Build bounded source material from valid current deliveries only.
Stale/rejected rows are deliberately excluded. The persisted node answer
provides a quick synopsis; the Markdown files provide the actual
deliverable body, read through the owning thread's virtual-path resolver.
"""
remaining = _BUILTIN_MATERIAL_BUDGET
lines: list[str] = ["【岗位有效交付】"]
for index, node in enumerate(_delivered_nodes(nodes), start=1):
agent_name, position_id = _seat_meta(session, node)
lines.extend(
[
"",
f"### {index}. {agent_name}",
f"- 岗位:{position_id}",
f"- 节点:{node.get('node_key')}",
f"- 有效版本:v{node.get('revision', 0)}",
]
)
answer_limit = min(2_000, max(0, remaining // max(1, len(nodes))))
answer = _truncate_text(node.get("latest_answer"), answer_limit)
if answer:
remaining -= len(answer)
lines.extend(["- 本轮问答结论:", answer])
artifacts, consumed = await _artifact_refs_with_content(
node.get("thread_id"), node.get("artifact_manifest"), max(0, remaining)
)
remaining -= consumed
markdown_artifacts = [artifact for artifact in artifacts if _is_markdown_artifact(artifact)]
if markdown_artifacts:
lines.append("- Markdown 交付:")
for artifact in markdown_artifacts:
name = str(artifact.get("name") or artifact.get("path") or "报告.md")
lines.append(f" - 文件:{name}")
content = str(artifact.get("content") or "").strip()
if content:
lines.append(" - 正文:")
lines.append(content)
if remaining <= 0:
lines.append("\n(后续交付已按上下文长度截断,但仍可通过岗位会商产物列表查看。)")
break
return "\n".join(lines)
async def _build_builtin_prompt(
*,
kind: str,
session: dict[str, Any],
nodes: list[dict[str, Any]],
follow_up: str = "",
) -> str:
task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
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 []
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)
common = [
"以下内容来自岗位会商的冻结快照和当前有效交付;它是本轮唯一的业务依据。",
"不要向用户复述本提示、不要编造未提供的事实;可在现有材料上做合理归纳和建议。",
"",
"【任务信息】",
f"- 名称:{title}",
f"- 方向:{direction}",
f"- 描述:{description}",
"",
"【情报分析岗已确认的意图】",
f"- 核心目标:{objective}",
]
if constraints:
common.append("- 关键约束:")
common.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
if stage_lines:
common.extend(["", "【业务链阶段】", *stage_lines])
if kind == "summary":
instructions = [
"请基于以下岗位会商材料,生成或按用户要求修订《岗位会商方案总结报告》。",
"必须使用 write_file 一次性写入 `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。",
"报告要覆盖任务目标、岗位关键结论、综合分析、方案建议、风险与执行路径;不要简单拼接原文。",
]
else:
summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
summary_content = _truncate_text(summary.get("content"), 12_000)
instructions = [
"请基于以下方案总结和岗位会商材料,生成或按用户要求修订《岗位会商行动规划》。",
"必须使用 write_file 分别写入两个最终交付:`/mnt/user-data/outputs/行动规划报告.md` 与 `/mnt/user-data/outputs/action-plan-subtasks.json`。",
"Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排;JSON 必须是严格合法数组,每个子任务都包含 id、title、goal、basis、owner_position、dependencies、deliverable、acceptance_criteria、priority。",
"",
"【已生效方案总结】",
summary_content or "(方案总结正文未写入快照,请依据下方有效岗位交付重新归纳。)",
]
if follow_up.strip():
instructions.extend(["", "【用户本次补充/修改要求】", follow_up.strip()])
return "\n".join([*instructions, "", *common, "", material])
def _build_builtin_snapshot(
*,
kind: str,
thread_id: str,
content: str,
artifacts: list[dict[str, Any]],
source_revisions: dict[str, int],
model: str | None,
previous: dict[str, Any] | None = None,
) -> dict[str, Any]:
markdown = next((artifact for artifact in artifacts if _is_markdown_artifact(artifact)), None)
json_artifact = next((artifact for artifact in artifacts if _is_json_artifact(artifact)), None)
primary = markdown if kind == "summary" else markdown
previous_primary = previous.get("primary_artifact") if isinstance(previous, dict) else None
primary_changed = not isinstance(previous_primary, dict) or not isinstance(primary, dict) or (
_artifact_signature(previous_primary) != _artifact_signature(primary)
)
previous_content = str(previous.get("content") or "") if isinstance(previous, dict) else ""
return {
"kind": kind,
"status": "done",
"title": "岗位会商方案总结报告" if kind == "summary" else "岗位会商行动规划",
# A pure follow-up may only answer a question and leave the report file
# unchanged. Keep the last report body in that case while retaining
# the answer separately for audit/display.
"content": content.strip() if primary_changed or not previous_content else previous_content,
"last_response": content.strip(),
"thread_id": thread_id,
"artifact_manifest": _artifact_manifest_refs(artifacts),
"primary_artifact": primary,
"subtasks_artifact": json_artifact if kind == "action_plan" else None,
"source_revisions": source_revisions,
"model": model or None,
"generated_at": datetime.now(UTC).isoformat(),
}
@router.get("/sessions", response_model=list[SessionResponse]) @router.get("/sessions", response_model=list[SessionResponse])
async def list_sessions(request: Request, external_task_id: str | None = None) -> list[SessionResponse]: async def list_sessions(request: Request, external_task_id: str | None = None) -> list[SessionResponse]:
rows = await _get_store(request).list_sessions( rows = await _get_store(request).list_sessions(
@@ -633,7 +929,7 @@ async def activate_session(
request: Request, session_id: str, body: ActivateRequest request: Request, session_id: str, body: ActivateRequest
) -> SessionDetailResponse: ) -> SessionDetailResponse:
store, user_id, current = await _require_writable_session(request, session_id) store, user_id, current = await _require_writable_session(request, session_id)
if not current.get("intent_snapshot"): if not _has_confirmed_intent(current.get("intent_snapshot")):
raise HTTPException(status_code=400, detail="请先完成并保存任务意图") raise HTTPException(status_code=400, detail="请先完成并保存任务意图")
chain = await _get_chain_store(request).get_chain( chain = await _get_chain_store(request).get_chain(
@@ -652,6 +948,7 @@ async def activate_session(
chain, chain,
available_agent_ids=await _available_agent_ids(request, seat_ids), available_agent_ids=await _available_agent_ids(request, seat_ids),
) )
nodes = _prime_intent_nodes(nodes, current.get("intent_snapshot"))
activated = await store.activate_session( activated = await store.activate_session(
session_id, session_id,
user_id, user_id,
@@ -723,29 +1020,54 @@ async def complete_node_turn(
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁") raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
if node.get("invalidated_by"): if node.get("invalidated_by"):
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成") raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
# Artifact listing is best-effort on the client. Never erase a previously
# delivered report merely because one listing request transiently failed.
effective_artifacts = body.artifact_manifest or list(node.get("artifact_manifest") or [])
has_markdown = any(_is_markdown_artifact(item) for item in effective_artifacts)
markdown_changed = _markdown_delivery_changed(node.get("artifact_manifest"), effective_artifacts)
requires_fresh_delivery = node.get("status") in {"rejected", "stale"}
next_status = (
"done"
if _is_intent_position_node(node)
else "done"
if has_markdown and (not requires_fresh_delivery or markdown_changed)
else "running"
)
# Effective revisions are delivery revisions, not every conversational
# answer. This prevents a harmless follow-up on a completed seat from
# invalidating all later stages just because its old Markdown file still
# appears in the artifact list.
new_effective_delivery = (
next_status == "done"
and (
_is_intent_position_node(node)
or node.get("status") != "done"
or markdown_changed
)
)
row = await store.update_node( row = await store.update_node(
session_id, session_id,
node_key, node_key,
user_id, user_id,
status="done", status=next_status,
latest_answer=body.latest_answer, latest_answer=body.latest_answer,
artifact_manifest=body.artifact_manifest, artifact_manifest=effective_artifacts,
upstream_revision=body.upstream_revision, upstream_revision=body.upstream_revision,
invalidated_by=None, invalidated_by=None,
increment_revision=True, increment_revision=new_effective_delivery,
) )
if row is None: if row is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found") raise HTTPException(status_code=404, detail="Position roundtable node not found")
# A node has no coordinator. Its completion alone drives the frozen chain: # A node has no coordinator. Its effective delivery alone drives the frozen
# all parallel seats in the current stage must finish before the next stage # chain: all parallel seats in the current stage must finish before the
# is made writable. Re-answering an already completed upstream seat keeps # next stage is made writable. A plain conversational follow-up keeps the
# downstream records for audit but marks them stale so they must be # existing downstream deliveries valid; only a newly written Markdown
# refreshed against the new handoff context. # report advances the revision and makes later stages stale.
nodes = await store.list_nodes(session_id, user_id) nodes = await store.list_nodes(session_id, user_id)
if nodes is None: if nodes is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found") raise HTTPException(status_code=404, detail="Position roundtable session not found")
if node.get("revision", 0) > 0: if new_effective_delivery and node.get("revision", 0) > 0:
for downstream in nodes: for downstream in nodes:
if ( if (
downstream["stage_index"] > row["stage_index"] downstream["stage_index"] > row["stage_index"]
@@ -769,7 +1091,7 @@ async def complete_node_turn(
later_stages = sorted( later_stages = sorted(
{item["stage_index"] for item in nodes if item["stage_index"] > row["stage_index"]} {item["stage_index"] for item in nodes if item["stage_index"] > row["stage_index"]}
) )
if current_stage and all(item["status"] == "done" for item in current_stage) and later_stages: if row["status"] == "done" and current_stage and all(item["status"] == "done" for item in current_stage) and later_stages:
next_stage = later_stages[0] next_stage = later_stages[0]
for next_node in nodes: for next_node in nodes:
if next_node["stage_index"] == next_stage and next_node["status"] in {"locked", "stale"}: if next_node["stage_index"] == next_stage and next_node["status"] in {"locked", "stale"}:
@@ -813,50 +1135,156 @@ async def reject_node(
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows]) return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
@router.post("/sessions/{session_id}/summary/start", response_model=SessionDetailResponse) async def _prepare_builtin_run(
async def start_summary(request: Request, session_id: str) -> SessionDetailResponse: request: Request,
session_id: str,
*,
kind: str,
follow_up: str = "",
) -> BuiltinRunPreparationResponse:
store, user_id, session = await _require_writable_session(request, session_id) store, user_id, session = await _require_writable_session(request, session_id)
nodes = await store.list_nodes(session_id, user_id) nodes = await store.list_nodes(session_id, user_id)
if nodes is None: if nodes is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found") raise HTTPException(status_code=404, detail="Position roundtable session not found")
if not _all_nodes_delivered(nodes): if not _all_nodes_delivered(nodes):
raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成方案总结") raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成或更新内置交付")
snapshot = _build_summary_snapshot(session, nodes)
row = await store.update_session( if kind == "action_plan":
session_id, summary = session.get("summary_snapshot")
user_id, if not isinstance(summary, dict) or summary.get("status") != "done":
summary_snapshot=snapshot, raise HTTPException(status_code=409, detail="请先生成有效的方案总结")
action_plan_snapshot=None, thread_id = session.get("action_plan_thread_id")
status="active", agent_id = ACTION_PLAN_AGENT_ID
display_name = "行动规划智能体"
else:
if follow_up.strip():
summary = session.get("summary_snapshot")
if not isinstance(summary, dict) or summary.get("status") != "done":
raise HTTPException(status_code=409, detail="请先生成有效的方案总结后再继续问答")
thread_id = session.get("summary_thread_id")
agent_id = SUMMARY_AGENT_ID
display_name = "方案总结智能体"
return BuiltinRunPreparationResponse(
agent_id=agent_id,
display_name=display_name,
thread_id=str(thread_id) if isinstance(thread_id, str) and thread_id else None,
prompt=await _build_builtin_prompt(
kind=kind,
session=session,
nodes=nodes,
follow_up=follow_up,
),
source_revisions=_source_revisions(nodes),
) )
async def _complete_builtin_run(
request: Request,
session_id: str,
*,
kind: str,
body: CompleteBuiltinRunRequest,
) -> SessionDetailResponse:
store, user_id, session = await _require_writable_session(request, session_id)
nodes = await store.list_nodes(session_id, user_id)
if nodes is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
if not _all_nodes_delivered(nodes):
raise HTTPException(status_code=409, detail="上游岗位产物已变更或未完成,不能写入旧的内置交付")
current_revisions = _source_revisions(nodes)
if body.source_revisions != current_revisions:
raise HTTPException(status_code=409, detail="岗位产物版本已变化,请基于最新交付重新生成")
artifacts = _artifact_manifest_refs(body.artifact_manifest)
markdown = [artifact for artifact in artifacts if _is_markdown_artifact(artifact)]
if not markdown:
raise HTTPException(status_code=422, detail="内置智能体必须交付 outputs 目录下的一份 Markdown 报告文档")
if kind == "action_plan" and not any(_is_json_artifact(artifact) for artifact in artifacts):
raise HTTPException(status_code=422, detail="行动规划智能体还需交付 action-plan-subtasks.json 子任务清单")
snapshot_field = "summary_snapshot" if kind == "summary" else "action_plan_snapshot"
previous = session.get(snapshot_field)
previous_snapshot = previous if isinstance(previous, dict) else None
snapshot = _build_builtin_snapshot(
kind=kind,
thread_id=body.thread_id,
content=body.content,
artifacts=artifacts,
source_revisions=current_revisions,
model=body.model,
previous=previous_snapshot,
)
previous_primary = previous_snapshot.get("primary_artifact") if previous_snapshot else None
new_primary = snapshot.get("primary_artifact")
report_changed = not isinstance(previous_primary, dict) or not isinstance(new_primary, dict) or (
_artifact_signature(previous_primary) != _artifact_signature(new_primary)
)
if kind == "summary":
row = await store.update_session(
session_id,
user_id,
summary_thread_id=body.thread_id,
summary_snapshot=snapshot,
# A rewritten summary invalidates the derived action plan. A
# pure Q&A follow-up keeps the existing plan valid.
action_plan_snapshot=None if report_changed else session.get("action_plan_snapshot"),
status="active",
)
else:
row = await store.update_session(
session_id,
user_id,
action_plan_thread_id=body.thread_id,
action_plan_snapshot=snapshot,
status="completed",
)
if row is None: if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found") raise HTTPException(status_code=404, detail="Position roundtable session not found")
node_rows = await store.list_nodes(session_id, user_id) or [] node_rows = await store.list_nodes(session_id, user_id) or []
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows]) return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
@router.post("/sessions/{session_id}/action-plan/start", response_model=SessionDetailResponse) @router.post("/sessions/{session_id}/summary/prepare", response_model=BuiltinRunPreparationResponse)
async def start_action_plan(request: Request, session_id: str) -> SessionDetailResponse: async def prepare_summary(
store, user_id, session = await _require_writable_session(request, session_id) request: Request, session_id: str, body: PrepareBuiltinRunRequest
nodes = await store.list_nodes(session_id, user_id) ) -> BuiltinRunPreparationResponse:
if nodes is None: return await _prepare_builtin_run(request, session_id, kind="summary", follow_up=body.message)
raise HTTPException(status_code=404, detail="Position roundtable session not found")
if not _all_nodes_delivered(nodes):
raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成行动规划") @router.post("/sessions/{session_id}/summary/ask", response_model=BuiltinRunPreparationResponse)
summary = session.get("summary_snapshot") async def ask_summary(
if not isinstance(summary, dict) or summary.get("status") != "done": request: Request, session_id: str, body: PrepareBuiltinRunRequest
raise HTTPException(status_code=409, detail="请先生成有效的方案总结") ) -> BuiltinRunPreparationResponse:
snapshot = _build_action_plan_snapshot(session, nodes) return await _prepare_builtin_run(request, session_id, kind="summary", follow_up=body.message)
row = await store.update_session(
session_id,
user_id, @router.post("/sessions/{session_id}/summary/complete", response_model=SessionDetailResponse)
action_plan_snapshot=snapshot, async def complete_summary(
status="completed", request: Request, session_id: str, body: CompleteBuiltinRunRequest
) ) -> SessionDetailResponse:
if row is None: return await _complete_builtin_run(request, session_id, kind="summary", body=body)
raise HTTPException(status_code=404, detail="Position roundtable session not found")
node_rows = await store.list_nodes(session_id, user_id) or []
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows]) @router.post("/sessions/{session_id}/action-plan/prepare", response_model=BuiltinRunPreparationResponse)
async def prepare_action_plan(
request: Request, session_id: str, body: PrepareBuiltinRunRequest
) -> BuiltinRunPreparationResponse:
return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
@router.post("/sessions/{session_id}/action-plan/ask", response_model=BuiltinRunPreparationResponse)
async def ask_action_plan(
request: Request, session_id: str, body: PrepareBuiltinRunRequest
) -> BuiltinRunPreparationResponse:
return await _prepare_builtin_run(request, session_id, kind="action_plan", follow_up=body.message)
@router.post("/sessions/{session_id}/action-plan/complete", response_model=SessionDetailResponse)
async def complete_action_plan(
request: Request, session_id: str, body: CompleteBuiltinRunRequest
) -> SessionDetailResponse:
return await _complete_builtin_run(request, session_id, kind="action_plan", body=body)
@router.get("/sessions/{session_id}/results", response_model=list[NodeResponse]) @router.get("/sessions/{session_id}/results", response_model=list[NodeResponse])
@@ -247,6 +247,13 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
# 关掉个人记忆召回 / builtin 记忆注入(见 multi_agent._run_payload)。 # 关掉个人记忆召回 / builtin 记忆注入(见 multi_agent._run_payload)。
"memory_recall_disabled", "memory_recall_disabled",
"memory_injection_enabled", "memory_injection_enabled",
# 任务工作区 / 岗位会商把左侧任务卡的快照放在这里。它必须进入
# ``runtime.context``,TaskExternalContextMiddleware 才能在每次模型调用
# 前以隐藏消息注入任务名称、描述和方向;未白名单会被静默丢弃,模型只能
# 看到用户说的“这个任务”。
"task_external_context",
# 岗位会商下游席位按需读取前序席位完整交付时使用的运行期数据。
"roundtable_peer_deliveries",
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后 # 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。 # 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
"summarization_enabled", "summarization_enabled",
@@ -0,0 +1,47 @@
"""Persist position-roundtable built-in agent thread ids.
Revision ID: 20260721_04
Revises: 20260721_03
Create Date: 2026-07-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260721_04"
down_revision = "20260721_03"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
return table_name in inspect(op.get_bind()).get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return column_name in {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
def upgrade() -> None:
table = "position_roundtable_sessions"
if not _has_table(table):
return
if not _has_column(table, "summary_thread_id"):
op.add_column(table, sa.Column("summary_thread_id", sa.String(length=128), nullable=True))
if not _has_column(table, "action_plan_thread_id"):
op.add_column(table, sa.Column("action_plan_thread_id", sa.String(length=128), nullable=True))
def downgrade() -> None:
table = "position_roundtable_sessions"
if not _has_table(table):
return
for column in ("action_plan_thread_id", "summary_thread_id"):
if _has_column(table, column):
op.drop_column(table, column)
@@ -31,7 +31,12 @@ class PositionRoundtableSessionRow(Base):
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True) intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# The two built-in closing agents own normal LangGraph threads too. Keep
# their ids on the position session so a refresh can continue the same
# report/planning dialogue instead of silently starting a fresh context.
summary_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
action_plan_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
action_plan_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) action_plan_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# intent_pending -> active -> completed / archived. The router owns legal # intent_pending -> active -> completed / archived. The router owns legal
# transitions; the DB keeps it as a concise, portable string. # transitions; the DB keeps it as a concise, portable string.
@@ -55,7 +55,9 @@ class PositionRoundtableRepository:
"chain_snapshot": _loads(row.chain_snapshot), "chain_snapshot": _loads(row.chain_snapshot),
"intent_thread_id": row.intent_thread_id, "intent_thread_id": row.intent_thread_id,
"intent_snapshot": _loads(row.intent_snapshot), "intent_snapshot": _loads(row.intent_snapshot),
"summary_thread_id": row.summary_thread_id,
"summary_snapshot": _loads(row.summary_snapshot), "summary_snapshot": _loads(row.summary_snapshot),
"action_plan_thread_id": row.action_plan_thread_id,
"action_plan_snapshot": _loads(row.action_plan_snapshot), "action_plan_snapshot": _loads(row.action_plan_snapshot),
"status": row.status, "status": row.status,
"created_at": _time(row.created_at), "created_at": _time(row.created_at),
@@ -134,7 +136,9 @@ class PositionRoundtableRepository:
task_snapshot: dict[str, Any] | object = _UNSET, task_snapshot: dict[str, Any] | object = _UNSET,
intent_thread_id: str | None | object = _UNSET, intent_thread_id: str | None | object = _UNSET,
intent_snapshot: dict[str, Any] | None | object = _UNSET, intent_snapshot: dict[str, Any] | None | object = _UNSET,
summary_thread_id: str | None | object = _UNSET,
summary_snapshot: dict[str, Any] | None | object = _UNSET, summary_snapshot: dict[str, Any] | None | object = _UNSET,
action_plan_thread_id: str | None | object = _UNSET,
action_plan_snapshot: dict[str, Any] | None | object = _UNSET, action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
status: str | object = _UNSET, status: str | object = _UNSET,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
@@ -148,8 +152,12 @@ class PositionRoundtableRepository:
row.intent_thread_id = intent_thread_id or None row.intent_thread_id = intent_thread_id or None
if intent_snapshot is not _UNSET: if intent_snapshot is not _UNSET:
row.intent_snapshot = _dumps(intent_snapshot) row.intent_snapshot = _dumps(intent_snapshot)
if summary_thread_id is not _UNSET:
row.summary_thread_id = summary_thread_id or None
if summary_snapshot is not _UNSET: if summary_snapshot is not _UNSET:
row.summary_snapshot = _dumps(summary_snapshot) row.summary_snapshot = _dumps(summary_snapshot)
if action_plan_thread_id is not _UNSET:
row.action_plan_thread_id = action_plan_thread_id or None
if action_plan_snapshot is not _UNSET: if action_plan_snapshot is not _UNSET:
row.action_plan_snapshot = _dumps(action_plan_snapshot) row.action_plan_snapshot = _dumps(action_plan_snapshot)
if status is not _UNSET: if status is not _UNSET:
@@ -202,6 +210,10 @@ class PositionRoundtableRepository:
agent_id=str(node["agent_id"]), agent_id=str(node["agent_id"]),
position_id=node.get("position_id") or None, position_id=node.get("position_id") or None,
status=str(node.get("status") or "locked"), status=str(node.get("status") or "locked"),
latest_answer=node.get("latest_answer") or None,
artifact_manifest=_dumps(node.get("artifact_manifest") or []),
revision=int(node.get("revision") or 0),
upstream_revision=_dumps(node.get("upstream_revision")) if node.get("upstream_revision") is not None else None,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -9,6 +9,8 @@ import pytest_asyncio
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway import services as gateway_services
from app.gateway.routers import intent as intent_router
from app.gateway.routers import position_roundtable as position_roundtable_router from app.gateway.routers import position_roundtable as position_roundtable_router
from deerflow.persistence.base import Base from deerflow.persistence.base import Base
from deerflow.persistence.position_roundtable import PositionRoundtableRepository from deerflow.persistence.position_roundtable import PositionRoundtableRepository
@@ -79,6 +81,39 @@ async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes] assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes]
def test_task_external_context_reaches_agent_runtime_context():
"""岗位会商的左侧任务快照不能在网关白名单中丢失。"""
task_context = {
"task": {
"title": "重点区域态势研判与协同处置方案",
"description": "梳理风险信号并形成协同处置建议",
}
}
config: dict = {"configurable": {}, "context": {}}
gateway_services.merge_run_context_overrides(
config,
{"task_external_context": task_context},
)
assert config["configurable"]["task_external_context"] == task_context
assert config["context"]["task_external_context"] == task_context
def test_position_intent_thread_metadata_keeps_parent_and_child_task_ids():
metadata = intent_router._intent_thread_metadata(
{
"task": {"id": "task-100"},
"current_position": {"id": "intelligence"},
}
)
assert metadata["taskId"] == "task-100"
assert metadata["position_roundtable_task_id"] == "task-100"
assert metadata["position_task_id"] == "task-100:intelligence"
assert metadata["position_id"] == "intelligence"
def test_chain_node_activation_rejects_missing_agent_ids(): def test_chain_node_activation_rejects_missing_agent_ids():
chain = { chain = {
"id": "chain-a", "id": "chain-a",
@@ -99,6 +134,58 @@ def test_chain_node_activation_rejects_missing_agent_ids():
assert "缺少可用智能体" in str(error.value.detail) assert "缺少可用智能体" in str(error.value.detail)
def test_intent_position_nodes_complete_from_confirmed_intent_and_unlock_next_stage():
nodes = [
{
"node_key": "stage-1-seat-1-intent-agent",
"stage_index": 0,
"seat_index": 0,
"agent_id": "intent-agent",
"position_id": "intelligence",
"status": "ready",
},
{
"node_key": "stage-2-seat-1-plan-agent",
"stage_index": 1,
"seat_index": 0,
"agent_id": "plan-agent",
"position_id": "planning",
"status": "locked",
},
]
primed = position_roundtable_router._prime_intent_nodes(
nodes,
{"objective": "识别用户想要形成协同方案", "constraints": ["两天内完成"]},
)
assert [node["status"] for node in primed] == ["done", "ready"]
assert primed[0]["revision"] == 1
assert "识别用户想要形成协同方案" in primed[0]["latest_answer"]
def test_all_nodes_delivered_requires_markdown_for_non_intent_nodes():
assert not position_roundtable_router._all_nodes_delivered(
[
{
"position_id": "planning",
"status": "done",
"artifact_manifest": [{"name": "plain.txt", "path": "mnt/user-data/outputs/plain.txt"}],
}
]
)
assert position_roundtable_router._all_nodes_delivered(
[
{"position_id": "intelligence", "status": "done", "artifact_manifest": []},
{
"position_id": "planning",
"status": "done",
"artifact_manifest": [{"name": "report.md", "path": "mnt/user-data/outputs/report.md"}],
},
]
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store): async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
created = await store.create_session("user-a", external_task_id=None, task_snapshot={}) created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
@@ -276,15 +363,55 @@ async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reje
session = await store.get_session(created["id"], "user-a") session = await store.get_session(created["id"], "user-a")
assert session is not None assert session is not None
nodes = await store.list_nodes(created["id"], "user-a") or [] nodes = await store.list_nodes(created["id"], "user-a") or []
summary = position_roundtable_router._build_summary_snapshot(session, nodes) revisions = position_roundtable_router._source_revisions(nodes)
summary = position_roundtable_router._build_builtin_snapshot(
kind="summary",
thread_id="summary-thread",
content="# 岗位会商方案总结报告\n\n研判智能体的关键结论。",
artifacts=[
{
"name": "岗位会商方案总结报告.md",
"path": "mnt/user-data/outputs/岗位会商方案总结报告.md",
"mime_type": "text/markdown",
"modified_at": "2026-07-21T00:00:00+00:00",
"size_bytes": 100,
}
],
source_revisions=revisions,
model="test-model",
)
assert "岗位会商方案总结报告" in summary["content"] assert "岗位会商方案总结报告" in summary["content"]
assert "研判智能体" in summary["content"] session = await store.update_session(
session = await store.update_session(created["id"], "user-a", summary_snapshot=summary) created["id"], "user-a", summary_thread_id="summary-thread", summary_snapshot=summary
)
assert session is not None assert session is not None
action_plan = position_roundtable_router._build_action_plan_snapshot(session, nodes) action_plan = position_roundtable_router._build_builtin_snapshot(
kind="action_plan",
thread_id="action-plan-thread",
content="# 岗位会商行动规划\n\n## 子任务拆解",
artifacts=[
{
"name": "行动规划报告.md",
"path": "mnt/user-data/outputs/行动规划报告.md",
"mime_type": "text/markdown",
"modified_at": "2026-07-21T00:01:00+00:00",
"size_bytes": 100,
},
{
"name": "action-plan-subtasks.json",
"path": "mnt/user-data/outputs/action-plan-subtasks.json",
"mime_type": "application/json",
"modified_at": "2026-07-21T00:01:00+00:00",
"size_bytes": 200,
},
],
source_revisions=revisions,
model="test-model",
)
session = await store.update_session( session = await store.update_session(
created["id"], created["id"],
"user-a", "user-a",
action_plan_thread_id="action-plan-thread",
action_plan_snapshot=action_plan, action_plan_snapshot=action_plan,
status="completed", status="completed",
) )
@@ -309,6 +436,30 @@ async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reje
assert rejected_session["action_plan_snapshot"]["invalidated_by"] == nodes[0]["node_key"] assert rejected_session["action_plan_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
def test_rejected_or_stale_node_requires_a_new_markdown_delivery() -> None:
old = [
{
"name": "report.md",
"path": "mnt/user-data/outputs/report.md",
"modified_at": "2026-07-21T00:00:00+00:00",
"size_bytes": 100,
}
]
assert not position_roundtable_router._markdown_delivery_changed(old, old)
assert position_roundtable_router._markdown_delivery_changed(
old,
[{**old[0], "modified_at": "2026-07-21T00:05:00+00:00", "size_bytes": 120}],
)
def test_activation_requires_a_structured_intent_snapshot() -> None:
assert not position_roundtable_router._has_confirmed_intent({"objective": "只有目标"})
assert not position_roundtable_router._has_confirmed_intent({"constraints": []})
assert position_roundtable_router._has_confirmed_intent(
{"objective": "明确任务目标", "constraints": []}
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch): async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
artifact = tmp_path / "handoff.md" artifact = tmp_path / "handoff.md"