This commit is contained in:
董宏凯 committed 2026-07-22 15:43:13 +08:00
commit ae184f434d
37 files changed
+6908 -351

No files matched your search

@@ -375,6 +375,26 @@ Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
### Position-collaboration foundation
The position-collaboration workspace is a separate frontend route and must not
use the original roundtable coordinator or jobs. Its shared configuration
integration point is the existing `/api/roundtable-chains` store: each `seats` JSON item
may carry an optional `position_id` (validated by `ChainSeatSchema`, no schema
migration). The original roundtable only reads its established seat fields and
therefore remains unchanged. The separate `/api/position-roundtable` router
persists task/intent/frozen-chain snapshots in `position_roundtable_sessions`
and direct-agent node indexes in `position_roundtable_nodes` (migration
`20260721_01`). Message history remains in the established LangGraph thread
store; a node completion unlocks its next chain stage and a later upstream
revision marks downstream records `stale` for refresh.
For direct-agent handoff, the router resolves artifact paths only inside the
completed upstream node's own thread, then includes bounded UTF-8 excerpts for
text outputs; binary artifacts stay as metadata-only references.
`POST /sessions/{id}/archive` turns a session into a read-only historical
record: task/intent updates, node binding, hidden-context construction, and
turn completion are rejected, while read endpoints remain available.
### 岗位 (Position) Subscription System
An admin-managed aggregation layer that decides, per user, which agents / skills
@@ -122,6 +122,8 @@ FastAPI application providing REST endpoints for frontend integration:
| `POST /api/skills/install` | Install skill from `.skill` archive |
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs |
| `GET /api/memory` | Retrieve memory data |
| `POST /api/memory/reload` | Force memory reload |
| `GET /api/memory/config` | Memory configuration |
@@ -53,9 +53,10 @@ from app.gateway.routers import (
notifications,
open_3qfx,
open_chat,
parallel_agent_tasks,
parallel_agents,
positions,
parallel_agent_tasks,
parallel_agents,
position_roundtable,
positions,
public_config,
public_embed,
public_leaderboard,
@@ -1443,8 +1444,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
app.include_router(flow_extract.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# 岗位协同独立会话 / 节点状态(不使用原圆桌总控或后台作业)
app.include_router(position_roundtable.router)
# Global business ? agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
@@ -255,9 +255,15 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
from deerflow.persistence.business_mapping import make_business_mapping_store
@@ -39,6 +39,7 @@
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
- ``summary`` —— Step3「总结报告」:写 Markdown 总结报告 + 支持问答/增量改。
- ``action_plan`` —— 岗位会商收口:写 Markdown 行动规划与 JSON 子任务清单。
工具面与 ``report`` 完全一致(继承 ``seat`` 的 bash 放开 + 额外禁 ``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 预览
# 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。
_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 真正**调用入库技能**把方案成果(总结报告 +
# 结构化流程图 + taskId)沉淀进知识库。和 dashboard 相反:dashboard 禁一切工具(只输出 JSON),
# ingest 必须**放开技能执行链路**——技能靠 ``read_file`` 读 SKILL.md、按其指令用 ``bash`` /
@@ -223,6 +227,13 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
thinking_enabled=True,
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":
return RoundtableRunPolicy(
excluded_tools=list(_DASHBOARD_EXCLUDED),
@@ -238,7 +249,7 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
reasoning_effort="medium",
)
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-report": "多智能体规划",
"roundtable-summary": "多智能体规划",
"position-action-planner": "多智能体规划",
"roundtable-execution-plan": "多智能体规划",
"roundtable-risk-review": "多智能体规划",
"roundtable-solution-design": "多智能体规划",
@@ -67,6 +67,9 @@ REPORT_AGENT_ID = "roundtable-report"
# 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存
# 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。
SUMMARY_AGENT_ID = "roundtable-summary"
# 岗位会商收口的第二个内置单例:读取已生效的方案总结和岗位交付,把它们拆成可执行
# 子任务,同时产出 Markdown 规划报告和 JSON 清单。
ACTION_PLAN_AGENT_ID = "position-action-planner"
# Step 3「大屏多页」用的结构化数据智能体:读各席位交付 + 共识,逐席位分析并提炼成
# 一份大屏展示用的结构化数据(report-json),供前端按模板渲染成多页大屏。**不调任何工具、
# 不写文件、不输出 HTML**——只在对话里输出一个严格合法的 ```report-json 代码块。与 report
@@ -93,6 +96,7 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
SUMMARY_AGENT_ID,
ACTION_PLAN_AGENT_ID,
DASHBOARD_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:
"""归一化澄清问题文本,用于"和过往是否重复"的判断。
@@ -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)。
# 不要在这里替换成 httpx.ASGITransport——曾试过,会整段 buffer SSE 流导致前端
# 看不到逐 token 增量。
raw_task_context = payload.get("task_context")
task_context = raw_task_context if isinstance(raw_task_context, dict) else None
try:
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:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
raise
@@ -340,6 +376,8 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# additional_kwargs.files → UploadsMiddleware 自动注入清单 + 解锁 read_file 等工具。
raw_files = payload.get("files")
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:
raise HTTPException(status_code=400, detail="thread_id is required")
# 允许「仅文件、无文本」的回合:带了文件就不强制要求 message。
@@ -431,6 +469,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# not reliable on its own, hard exclusion is.
excluded_tools=base_excluded_tools,
skill_stop_names=[],
extra_context={"task_external_context": task_context} if task_context else None,
files=files,
)
@@ -55,7 +55,8 @@ import re
import time
import traceback
import uuid
from typing import Any, AsyncIterator
from collections.abc import AsyncIterator
from typing import Any
import httpx
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_model_fallback import fallback_model_chain, is_llm_error_message, reason_text
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
from app.gateway.roundtable_seat_skills import append_seat_skill_directive
from app.gateway.routers._roundtable_seed import (
ACTION_PLAN_AGENT_ID,
COORDINATOR_AGENT_ID,
DASHBOARD_AGENT_ID,
REPORT_AGENT_ID,
@@ -76,6 +76,7 @@ from app.gateway.routers._roundtable_seed import (
SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents,
)
from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive
from deerflow.config import get_app_config
from deerflow.runtime.user_context import get_effective_user_id
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"
if agent_name == SUMMARY_AGENT_ID:
return "step3_summary"
if agent_name == ACTION_PLAN_AGENT_ID:
return "position_action_plan"
if agent_name == DASHBOARD_AGENT_ID:
return "step3_dashboard"
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}
@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")
async def init_dashboard_thread(request: Request) -> dict[str, str]:
"""为 Step 3「大屏多页」创建一个供大屏多页数据智能体(``roundtable-dashboard``)
@@ -1806,6 +1835,8 @@ async def _special_run(
role = "report"
elif agent_name == SUMMARY_AGENT_ID:
role = "summary"
elif agent_name == ACTION_PLAN_AGENT_ID:
role = "action_plan"
elif agent_name == DASHBOARD_AGENT_ID:
# 大屏多页数据智能体:纯「研讨成果 → report-json」提炼,禁用一切工具。
role = "dashboard"
File diff suppressed because it is too large. Load diff
@@ -62,6 +62,9 @@ class ChainSeatSchema(BaseModel):
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
# 被 _SEAT_MODE_PARAMS.get() 安全忽略(回退作业级),故此处只约束长度、不强校验取值。
seat_mode: str | None = Field(default=None, max_length=16)
# 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排
# 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
position_id: str | None = Field(default=None, max_length=64)
class ChainResponse(BaseModel):
@@ -247,6 +247,13 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
# 关掉个人记忆召回 / builtin 记忆注入(见 multi_agent._run_payload)。
"memory_recall_disabled",
"memory_injection_enabled",
# 任务工作区 / 岗位会商把左侧任务卡的快照放在这里。它必须进入
# ``runtime.context``,TaskExternalContextMiddleware 才能在每次模型调用
# 前以隐藏消息注入任务名称、描述和方向;未白名单会被静默丢弃,模型只能
# 看到用户说的“这个任务”。
"task_external_context",
# 岗位会商下游席位按需读取前序席位完整交付时使用的运行期数据。
"roundtable_peer_deliveries",
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
"summarization_enabled",
@@ -0,0 +1,87 @@
"""Create standalone position-collaboration sessions and nodes.
The tables only index the task / intent / frozen business-chain workflow.
Conversation messages remain in LangGraph checkpoints and artifacts stay in the
existing thread artifact store, so this migration intentionally has no message
or file columns beyond lightweight snapshots and manifests.
Revision ID: 20260721_01
Revises: 20260701_01
Create Date: 2026-07-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260721_01"
down_revision = "20260701_01"
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 upgrade() -> None:
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
if not _has_table("position_roundtable_sessions"):
op.create_table(
"position_roundtable_sessions",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("external_task_id", sa.String(128), nullable=True),
sa.Column("task_snapshot", long_text, nullable=True),
sa.Column("chain_id", sa.String(64), nullable=True),
sa.Column("chain_snapshot", long_text, nullable=True),
sa.Column("intent_thread_id", sa.String(128), nullable=True),
sa.Column("intent_snapshot", long_text, nullable=True),
sa.Column("status", sa.String(32), nullable=False, server_default="intent_pending"),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_position_roundtable_sessions_user_id", "position_roundtable_sessions", ["user_id"])
op.create_index(
"ix_position_roundtable_sessions_external_task_id",
"position_roundtable_sessions",
["external_task_id"],
)
op.create_index("ix_position_roundtable_sessions_chain_id", "position_roundtable_sessions", ["chain_id"])
op.create_index("ix_position_roundtable_sessions_status", "position_roundtable_sessions", ["status"])
if not _has_table("position_roundtable_nodes"):
op.create_table(
"position_roundtable_nodes",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("session_id", sa.String(64), sa.ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"), nullable=False),
sa.Column("node_key", sa.String(256), nullable=False),
sa.Column("stage_index", sa.Integer(), nullable=False),
sa.Column("seat_index", sa.Integer(), nullable=False),
sa.Column("agent_id", sa.String(128), nullable=False),
sa.Column("position_id", sa.String(64), nullable=True),
sa.Column("thread_id", sa.String(128), nullable=True, unique=True),
sa.Column("status", sa.String(32), nullable=False, server_default="locked"),
sa.Column("latest_answer", long_text, nullable=True),
sa.Column("artifact_manifest", long_text, nullable=True),
sa.Column("revision", sa.Integer(), nullable=False, server_default="0"),
sa.Column("upstream_revision", long_text, nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"),
)
op.create_index("ix_position_roundtable_nodes_session_id", "position_roundtable_nodes", ["session_id"])
op.create_index("ix_position_roundtable_nodes_agent_id", "position_roundtable_nodes", ["agent_id"])
op.create_index("ix_position_roundtable_nodes_position_id", "position_roundtable_nodes", ["position_id"])
op.create_index("ix_position_roundtable_nodes_status", "position_roundtable_nodes", ["status"])
def downgrade() -> None:
if _has_table("position_roundtable_nodes"):
op.drop_table("position_roundtable_nodes")
if _has_table("position_roundtable_sessions"):
op.drop_table("position_roundtable_sessions")
@@ -0,0 +1,54 @@
"""Add rejection metadata to position roundtable nodes.
Revision ID: 20260721_02
Revises: 20260721_01
Create Date: 2026-07-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260721_02"
down_revision = "20260721_01"
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_nodes"
if not _has_table(table):
return
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
if not _has_column(table, "rejection_count"):
op.add_column(
table,
sa.Column("rejection_count", sa.Integer(), nullable=False, server_default="0"),
)
if not _has_column(table, "last_rejection"):
op.add_column(table, sa.Column("last_rejection", long_text, nullable=True))
if not _has_column(table, "invalidated_by"):
op.add_column(table, sa.Column("invalidated_by", sa.String(256), nullable=True))
def downgrade() -> None:
table = "position_roundtable_nodes"
if not _has_table(table):
return
for column in ("invalidated_by", "last_rejection", "rejection_count"):
if _has_column(table, column):
op.drop_column(table, column)
@@ -0,0 +1,49 @@
"""Add builtin summary/action-plan snapshots to position roundtable sessions.
Revision ID: 20260721_03
Revises: 20260721_02
Create Date: 2026-07-21
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260721_03"
down_revision = "20260721_02"
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
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
if not _has_column(table, "summary_snapshot"):
op.add_column(table, sa.Column("summary_snapshot", long_text, nullable=True))
if not _has_column(table, "action_plan_snapshot"):
op.add_column(table, sa.Column("action_plan_snapshot", long_text, nullable=True))
def downgrade() -> None:
table = "position_roundtable_sessions"
if not _has_table(table):
return
for column in ("action_plan_snapshot", "summary_snapshot"):
if _has_column(table, column):
op.drop_column(table, column)
@@ -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)
@@ -46,6 +46,10 @@ from deerflow.persistence.menu_overrides.model import MenuOverrideRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.position_roundtable.model import (
PositionRoundtableNodeRow,
PositionRoundtableSessionRow,
)
from deerflow.persistence.positions.model import PositionRow, PositionTagBindingRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.research_templates.model import ResearchTemplateRow
@@ -104,6 +108,8 @@ __all__ = [
"MenuOverrideRow",
"NotificationRow",
"ParallelAgentTaskRow",
"PositionRoundtableNodeRow",
"PositionRoundtableSessionRow",
"PositionRow",
"PositionTagBindingRow",
"RecommendedQuestionRow",
@@ -0,0 +1,22 @@
"""Position-collaboration session and node persistence."""
from __future__ import annotations
from deerflow.persistence.position_roundtable.model import (
PositionRoundtableNodeRow,
PositionRoundtableSessionRow,
)
from deerflow.persistence.position_roundtable.sql import PositionRoundtableRepository
__all__ = [
"PositionRoundtableNodeRow",
"PositionRoundtableRepository",
"PositionRoundtableSessionRow",
"make_position_roundtable_store",
]
def make_position_roundtable_store(session_factory):
if session_factory is None:
return None
return PositionRoundtableRepository(session_factory)
@@ -0,0 +1,93 @@
"""Persistence models for the standalone position-collaboration workspace.
This deliberately does **not** reuse ``roundtable_drafts`` or
``roundtable_jobs``. A session captures the selected business chain and the
confirmed task intent; every chain seat then has one durable node record. The
node points at an existing LangGraph thread, which continues to own the actual
message history and generated artifacts.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class PositionRoundtableSessionRow(Base):
__tablename__ = "position_roundtable_sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# Phase one uses normal account isolation. A future membership / role ACL
# can be added without changing the session or thread identifiers.
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
task_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
intent_thread_id: Mapped[str | None] = mapped_column(String(128), 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)
action_plan_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
action_plan_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# intent_pending -> active -> completed / archived. The router owns legal
# transitions; the DB keeps it as a concise, portable string.
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
class PositionRoundtableNodeRow(Base):
__tablename__ = "position_roundtable_nodes"
__table_args__ = (
UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Stable inside one frozen chain snapshot; e.g. ``stage-1-seat-2-agent``.
node_key: Mapped[str] = mapped_column(String(256), nullable=False)
stage_index: Mapped[int] = mapped_column(Integer, nullable=False)
seat_index: Mapped[int] = mapped_column(Integer, nullable=False)
agent_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# The real messages remain in the standard LangGraph/checkpoint store.
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
# locked / ready / running / done / stale / rejected / error
status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True)
latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
invalidated_by: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,396 @@
"""SQL repository for position-collaboration sessions and chain nodes."""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.position_roundtable.model import (
PositionRoundtableNodeRow,
PositionRoundtableSessionRow,
)
_UNSET = object()
def _dumps(value: Any) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False)
def _loads(value: Any) -> Any:
if not isinstance(value, str) or not value:
return None
try:
return json.loads(value)
except Exception:
return None
def _time(value: Any) -> str | Any:
return value.isoformat() if isinstance(value, datetime) else value
def _has_material(row: PositionRoundtableNodeRow) -> bool:
return row.revision > 0 or bool(row.latest_answer) or bool(_loads(row.artifact_manifest))
class PositionRoundtableRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
return {
"id": row.id,
"external_task_id": row.external_task_id,
"task_snapshot": _loads(row.task_snapshot) or {},
"chain_id": row.chain_id,
"chain_snapshot": _loads(row.chain_snapshot),
"intent_thread_id": row.intent_thread_id,
"intent_snapshot": _loads(row.intent_snapshot),
"summary_thread_id": row.summary_thread_id,
"summary_snapshot": _loads(row.summary_snapshot),
"action_plan_thread_id": row.action_plan_thread_id,
"action_plan_snapshot": _loads(row.action_plan_snapshot),
"status": row.status,
"created_at": _time(row.created_at),
"updated_at": _time(row.updated_at),
}
@staticmethod
def _node_dict(row: PositionRoundtableNodeRow) -> dict[str, Any]:
return {
"id": row.id,
"session_id": row.session_id,
"node_key": row.node_key,
"stage_index": row.stage_index,
"seat_index": row.seat_index,
"agent_id": row.agent_id,
"position_id": row.position_id,
"thread_id": row.thread_id,
"status": row.status,
"latest_answer": row.latest_answer,
"artifact_manifest": _loads(row.artifact_manifest) or [],
"revision": row.revision,
"upstream_revision": _loads(row.upstream_revision),
"rejection_count": row.rejection_count or 0,
"last_rejection": _loads(row.last_rejection),
"invalidated_by": row.invalidated_by,
"created_at": _time(row.created_at),
"updated_at": _time(row.updated_at),
}
async def create_session(
self,
user_id: str,
*,
external_task_id: str | None,
task_snapshot: dict[str, Any] | None,
) -> dict[str, Any]:
now = datetime.now(UTC)
row = PositionRoundtableSessionRow(
id=uuid4().hex,
user_id=user_id,
external_task_id=external_task_id or None,
task_snapshot=_dumps(task_snapshot or {}),
status="intent_pending",
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._session_dict(row)
async def list_sessions(
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
) -> list[dict[str, Any]]:
stmt = select(PositionRoundtableSessionRow).where(PositionRoundtableSessionRow.user_id == user_id)
if external_task_id:
stmt = stmt.where(PositionRoundtableSessionRow.external_task_id == external_task_id)
stmt = stmt.order_by(PositionRoundtableSessionRow.updated_at.desc()).limit(limit)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
return [self._session_dict(row) for row in rows]
async def get_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
if row is None or row.user_id != user_id:
return None
return self._session_dict(row)
async def update_session(
self,
session_id: str,
user_id: str,
*,
task_snapshot: dict[str, Any] | object = _UNSET,
intent_thread_id: str | 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,
action_plan_thread_id: str | None | object = _UNSET,
action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
status: str | object = _UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
if row is None or row.user_id != user_id:
return None
if task_snapshot is not _UNSET:
row.task_snapshot = _dumps(task_snapshot)
if intent_thread_id is not _UNSET:
row.intent_thread_id = intent_thread_id or None
if intent_snapshot is not _UNSET:
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:
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:
row.action_plan_snapshot = _dumps(action_plan_snapshot)
if status is not _UNSET:
row.status = str(status)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._session_dict(row)
async def activate_session(
self,
session_id: str,
user_id: str,
*,
chain_id: str,
chain_snapshot: dict[str, Any],
nodes: list[dict[str, Any]],
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
"""Freeze a chain snapshot and create its nodes once.
Repeated requests are intentionally idempotent: once nodes exist the
existing snapshot and nodes are returned unchanged.
"""
now = datetime.now(UTC)
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
if row is None or row.user_id != user_id:
return None
node_rows = (
await session.execute(
select(PositionRoundtableNodeRow)
.where(PositionRoundtableNodeRow.session_id == session_id)
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
)
).scalars().all()
if node_rows:
return self._session_dict(row), [self._node_dict(node) for node in node_rows]
row.chain_id = chain_id
row.chain_snapshot = _dumps(chain_snapshot)
row.status = "active"
row.updated_at = now
node_rows = [
PositionRoundtableNodeRow(
id=uuid4().hex,
session_id=session_id,
node_key=str(node["node_key"]),
stage_index=int(node["stage_index"]),
seat_index=int(node["seat_index"]),
agent_id=str(node["agent_id"]),
position_id=node.get("position_id") or None,
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,
updated_at=now,
)
for node in nodes
]
session.add_all(node_rows)
await session.commit()
await session.refresh(row)
for node in node_rows:
await session.refresh(node)
return self._session_dict(row), [self._node_dict(node) for node in node_rows]
async def list_nodes(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
async with self._sf() as session:
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or parent.user_id != user_id:
return None
rows = (
await session.execute(
select(PositionRoundtableNodeRow)
.where(PositionRoundtableNodeRow.session_id == session_id)
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
)
).scalars().all()
return [self._node_dict(row) for row in rows]
async def get_node(self, session_id: str, node_key: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = (
select(PositionRoundtableNodeRow)
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
.where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
PositionRoundtableSessionRow.user_id == user_id,
)
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
return self._node_dict(row) if row is not None else None
async def update_node(
self,
session_id: str,
node_key: str,
user_id: str,
*,
thread_id: str | None | object = _UNSET,
status: str | object = _UNSET,
latest_answer: str | None | object = _UNSET,
artifact_manifest: list[dict[str, Any]] | object = _UNSET,
upstream_revision: dict[str, Any] | object = _UNSET,
last_rejection: dict[str, Any] | None | object = _UNSET,
invalidated_by: str | None | object = _UNSET,
increment_revision: bool = False,
increment_rejection: bool = False,
) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = (
select(PositionRoundtableNodeRow)
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
.where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
PositionRoundtableSessionRow.user_id == user_id,
)
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
if thread_id is not _UNSET:
row.thread_id = thread_id or None
if status is not _UNSET:
row.status = str(status)
if latest_answer is not _UNSET:
row.latest_answer = latest_answer
if artifact_manifest is not _UNSET:
row.artifact_manifest = _dumps(artifact_manifest)
if upstream_revision is not _UNSET:
row.upstream_revision = _dumps(upstream_revision)
if last_rejection is not _UNSET:
row.last_rejection = _dumps(last_rejection)
if invalidated_by is not _UNSET:
row.invalidated_by = invalidated_by or None
if increment_revision:
row.revision += 1
if increment_rejection:
row.rejection_count += 1
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._node_dict(row)
async def reject_node(
self,
session_id: str,
node_key: str,
user_id: str,
*,
title: str,
reason: str,
rejected_by: str,
) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
"""Reject one delivered node and invalidate every later stage.
Historical answers and artifact manifests are deliberately preserved.
``invalidated_by`` gates downstream rework until the rejected upstream
stage is completed again.
"""
now = datetime.now(UTC)
async with self._sf() as session:
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or parent.user_id != user_id:
return None
rows = (
await session.execute(
select(PositionRoundtableNodeRow)
.where(PositionRoundtableNodeRow.session_id == session_id)
.order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
)
).scalars().all()
target = next((row for row in rows if row.node_key == node_key), None)
if target is None:
return None
rejection = {
"title": title,
"reason": reason,
"rejected_by": rejected_by,
"rejected_at": now.isoformat(),
}
summary_snapshot = _loads(parent.summary_snapshot)
if isinstance(summary_snapshot, dict) and summary_snapshot.get("status") == "done":
summary_snapshot = {
**summary_snapshot,
"status": "stale",
"invalidated_by": target.node_key,
"invalidated_at": now.isoformat(),
}
parent.summary_snapshot = _dumps(summary_snapshot)
action_plan_snapshot = _loads(parent.action_plan_snapshot)
if isinstance(action_plan_snapshot, dict) and action_plan_snapshot.get("status") == "done":
action_plan_snapshot = {
**action_plan_snapshot,
"status": "stale",
"invalidated_by": target.node_key,
"invalidated_at": now.isoformat(),
}
parent.action_plan_snapshot = _dumps(action_plan_snapshot)
if parent.status == "completed":
parent.status = "active"
target.status = "rejected"
target.last_rejection = _dumps(rejection)
target.rejection_count += 1
target.invalidated_by = None
target.updated_at = now
for row in rows:
if row.stage_index <= target.stage_index:
continue
if _has_material(row) or row.status in {"running", "done", "stale", "rejected", "error"}:
row.status = "stale"
else:
row.status = "locked"
row.invalidated_by = target.node_key
row.updated_at = now
parent.updated_at = now
await session.commit()
await session.refresh(parent)
for row in rows:
await session.refresh(row)
return self._session_dict(parent), [self._node_dict(row) for row in rows]
async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
return await self.update_session(session_id, user_id, status="archived")
async def list_results(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
return await self.list_nodes(session_id, user_id)
@@ -0,0 +1,604 @@
"""Persistence coverage for the standalone position-collaboration workflow."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import HTTPException
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 deerflow.persistence.base import Base
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
@pytest_asyncio.fixture
async def store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'position-roundtable.db'}")
async with engine.begin() as conn:
# Register both tables with Base.metadata before creating the temporary DB.
import deerflow.persistence.position_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
return PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
@pytest.mark.asyncio
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
created = await store.create_session(
"user-a",
external_task_id="task-1",
task_snapshot={"title": "测试任务"},
)
await store.update_session(
created["id"],
"user-a",
intent_thread_id="intent-thread",
intent_snapshot={"objective": "识别意图"},
)
chain = {
"id": "chain-a",
"seats": [
{"agent_id": "agent-a", "position_id": "planning"},
{"agent_id": "agent-b", "position_id": "solution"},
],
}
nodes = [
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"status": "ready",
},
{
"node_key": "stage-2-seat-1-agent-b",
"stage_index": 1,
"seat_index": 0,
"agent_id": "agent-b",
"position_id": "solution",
"status": "locked",
},
]
activated, created_nodes = await store.activate_session(
created["id"], "user-a", chain_id="chain-a", chain_snapshot=chain, nodes=nodes
)
assert activated["status"] == "active"
assert activated["chain_snapshot"] == chain
assert [node["status"] for node in created_nodes] == ["ready", "locked"]
# Repeated activate calls must return the first frozen snapshot/nodes unchanged.
repeated, repeated_nodes = await store.activate_session(
created["id"], "user-a", chain_id="other", chain_snapshot={"id": "other"}, nodes=[]
)
assert repeated["chain_id"] == "chain-a"
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():
chain = {
"id": "chain-a",
"seats": [
{"agent_id": "agent-a", "name": "有效智能体", "position_id": "planning"},
{"agent_id": "missing-agent", "name": "缺失智能体", "position_id": "solution"},
],
}
with pytest.raises(HTTPException) as error:
position_roundtable_router._chain_nodes(
chain,
available_agent_ids={"agent-a"},
)
assert error.value.status_code == 400
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
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={})
_, nodes = await store.activate_session(
created["id"],
"user-a",
chain_id="chain-a",
chain_snapshot={"id": "chain-a"},
nodes=[
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"status": "ready",
}
],
)
assert len(nodes) == 1
updated = await store.update_node(
created["id"],
nodes[0]["node_key"],
"user-a",
thread_id="langgraph-thread-a",
status="done",
latest_answer="节点回答",
artifact_manifest=[{"name": "产物.md"}],
upstream_revision={"intent": 1},
increment_revision=True,
)
assert updated is not None
assert updated["thread_id"] == "langgraph-thread-a"
assert updated["latest_answer"] == "节点回答"
assert updated["artifact_manifest"] == [{"name": "产物.md"}]
assert updated["revision"] == 1
assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None
@pytest.mark.asyncio
async def test_reject_node_marks_current_and_invalidates_later_stages(store):
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
_, nodes = await store.activate_session(
created["id"],
"user-a",
chain_id="chain-a",
chain_snapshot={"id": "chain-a"},
nodes=[
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"status": "ready",
},
{
"node_key": "stage-2-seat-1-agent-b",
"stage_index": 1,
"seat_index": 0,
"agent_id": "agent-b",
"position_id": "solution",
"status": "ready",
},
{
"node_key": "stage-3-seat-1-agent-c",
"stage_index": 2,
"seat_index": 0,
"agent_id": "agent-c",
"position_id": "command",
"status": "ready",
},
],
)
await store.update_node(
created["id"],
nodes[0]["node_key"],
"user-a",
status="done",
latest_answer="第一阶段产物",
increment_revision=True,
)
await store.update_node(
created["id"],
nodes[1]["node_key"],
"user-a",
status="done",
latest_answer="第二阶段产物",
increment_revision=True,
)
rejected = await store.reject_node(
created["id"],
nodes[0]["node_key"],
"user-a",
title="依据不足",
reason="第一阶段判断依据需要补充。",
rejected_by="user-a",
)
assert rejected is not None
_, rejected_nodes = rejected
by_key = {node["node_key"]: node for node in rejected_nodes}
first = by_key[nodes[0]["node_key"]]
second = by_key[nodes[1]["node_key"]]
third = by_key[nodes[2]["node_key"]]
assert first["status"] == "rejected"
assert first["rejection_count"] == 1
assert first["last_rejection"]["title"] == "依据不足"
assert first["invalidated_by"] is None
assert second["status"] == "stale"
assert second["latest_answer"] == "第二阶段产物"
assert second["invalidated_by"] == nodes[0]["node_key"]
assert third["status"] == "locked"
assert third["invalidated_by"] == nodes[0]["node_key"]
@pytest.mark.asyncio
async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
created = await store.create_session(
"user-a",
external_task_id=None,
task_snapshot={
"title": "测试任务",
"description": "形成协同方案",
"direction": "态势研判",
},
)
await store.update_session(
created["id"],
"user-a",
intent_snapshot={"objective": "明确处置意图", "constraints": ["时间紧"]},
)
chain = {
"id": "chain-a",
"seats": [
{"agent_id": "agent-a", "name": "研判智能体", "position_id": "planning"},
{"agent_id": "agent-b", "name": "处置智能体", "position_id": "solution"},
],
}
_, nodes = await store.activate_session(
created["id"],
"user-a",
chain_id="chain-a",
chain_snapshot=chain,
nodes=[
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"status": "ready",
},
{
"node_key": "stage-2-seat-1-agent-b",
"stage_index": 1,
"seat_index": 0,
"agent_id": "agent-b",
"position_id": "solution",
"status": "ready",
},
],
)
for node in nodes:
await store.update_node(
created["id"],
node["node_key"],
"user-a",
status="done",
latest_answer=f"{node['agent_id']} 产物",
increment_revision=True,
)
session = await store.get_session(created["id"], "user-a")
assert session is not None
nodes = await store.list_nodes(created["id"], "user-a") or []
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"]
session = await store.update_session(
created["id"], "user-a", summary_thread_id="summary-thread", summary_snapshot=summary
)
assert session is not None
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(
created["id"],
"user-a",
action_plan_thread_id="action-plan-thread",
action_plan_snapshot=action_plan,
status="completed",
)
assert session is not None
assert session["summary_snapshot"]["status"] == "done"
assert session["action_plan_snapshot"]["status"] == "done"
rejected = await store.reject_node(
created["id"],
nodes[0]["node_key"],
"user-a",
title="重做第一阶段",
reason="前序依据变化",
rejected_by="user-a",
)
assert rejected is not None
rejected_session, _ = rejected
assert rejected_session["status"] == "active"
assert rejected_session["summary_snapshot"]["status"] == "stale"
assert rejected_session["summary_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
assert rejected_session["action_plan_snapshot"]["status"] == "stale"
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
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
artifact = tmp_path / "handoff.md"
artifact.write_text("上游产物正文:可供下一岗位直接参考。", encoding="utf-8")
calls: list[tuple[str, str]] = []
class StubStore:
async def get_session(self, session_id, user_id):
assert (session_id, user_id) == ("session-a", "user-a")
return {"task_snapshot": {"title": "测试任务"}, "intent_snapshot": {"goal": "方案分析"}}
async def list_nodes(self, session_id, user_id):
assert (session_id, user_id) == ("session-a", "user-a")
return [
{
"node_key": "stage-1-seat-1-agent-a",
"stage_index": 0,
"seat_index": 0,
"agent_id": "agent-a",
"position_id": "planning",
"thread_id": "upstream-thread",
"status": "done",
"revision": 1,
"latest_answer": "上游问答结果",
"artifact_manifest": [
{
"name": "handoff.md",
"path": "mnt/user-data/outputs/handoff.md",
"mime_type": "text/markdown",
},
{
"name": "outside.md",
"path": "mnt/user-data/uploads/outside.md",
"mime_type": "text/markdown",
},
],
},
{
"node_key": "stage-2-seat-1-agent-b",
"stage_index": 1,
"seat_index": 0,
"agent_id": "agent-b",
"position_id": "solution",
"thread_id": "current-thread",
"status": "ready",
"revision": 0,
"latest_answer": None,
"artifact_manifest": [],
},
{
"node_key": "stage-1-seat-2-agent-stale",
"stage_index": 0,
"seat_index": 1,
"agent_id": "agent-stale",
"position_id": "planning",
"thread_id": "stale-thread",
"status": "stale",
"revision": 1,
"latest_answer": "失效上游产物",
"artifact_manifest": [],
},
]
async def resolve(thread_id: str, path: str):
calls.append((thread_id, path))
assert thread_id == "upstream-thread"
assert path == "mnt/user-data/outputs/handoff.md"
return artifact
monkeypatch.setattr(position_roundtable_router, "aresolve_thread_virtual_path", resolve)
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=StubStore())),
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
)
context = await position_roundtable_router._handoff_context(
request, "session-a", "stage-2-seat-1-agent-b"
)
upstream = context["upstream_nodes"][0]
assert len(context["upstream_nodes"]) == 1
assert upstream["latest_answer"] == "上游问答结果"
assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
assert "content" not in upstream["artifacts"][1]
assert calls == [("upstream-thread", "mnt/user-data/outputs/handoff.md")]
@pytest.mark.asyncio
async def test_archived_session_rejects_node_writes_and_keeps_history_readable():
class ArchivedStore:
async def get_session(self, session_id, user_id):
assert (session_id, user_id) == ("session-a", "user-a")
return {"id": session_id, "status": "archived"}
async def get_node(self, session_id, node_key, user_id):
return {
"node_key": node_key,
"status": "ready",
"thread_id": None,
}
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=ArchivedStore())),
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
)
with pytest.raises(HTTPException) as error:
await position_roundtable_router.bind_node_thread(
request,
"session-a",
"stage-1-seat-1-agent-a",
position_roundtable_router.NodeThreadRequest(thread_id="thread-a"),
)
assert error.value.status_code == 409
assert "已归档" in str(error.value.detail)
@pytest.mark.asyncio
async def test_locked_node_cannot_bind_a_thread_before_its_stage_is_ready():
class LockedStore:
async def get_session(self, session_id, user_id):
return {"id": session_id, "status": "active"}
async def get_node(self, session_id, node_key, user_id):
return {
"node_key": node_key,
"status": "locked",
"thread_id": None,
}
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=LockedStore())),
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
)
with pytest.raises(HTTPException) as error:
await position_roundtable_router.bind_node_thread(
request,
"session-a",
"stage-2-seat-1-agent-b",
position_roundtable_router.NodeThreadRequest(thread_id="thread-b"),
)
assert error.value.status_code == 409
assert "未解锁" in str(error.value.detail)