Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	.gitignore
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/core/threads/types.ts
#	frontend-web/src/pages/ChatPage.tsx
This commit is contained in:
吉鑫升 committed 2026-08-12 15:55:51 +08:00
commit c4fd9dd7da
233 files changed
+12524 -1163

No files matched your search

@@ -123,6 +123,27 @@ cascade pragmas. The router is mounted canonically below
schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`.
Clients may retry the compatibility route only for a framework/proxy-level
`404 Not Found`; domain-level missing-session responses must remain real 404s.
Position roundtable selects the separately seeded built-in agent
`position-roundtable-intent` by passing `intent_agent_id` to the shared
`/api/intent/init` and `/api/intent/stream` endpoints. Its request lifecycle,
files, and SSE protocol match `roundtable-intent`, but its cap is five
**user-assistance rounds**. A single round may emit several independent
`ask_clarification` tool calls; those calls are separate cards, not one card
containing several numbered questions. Count that as one round by the
tool-calling AI message, never by card count. Topic-only requests must request
all material missing decisions as separate cards rather than being treated as a
resolved intent; after the fifth set is answered it must emit completed final
JSON without unresolved placeholders. The required arrays are
`coreGoals`, `riskWarnings`, `keyPoints`, and `strategicSignificance`. Do not
treat a fresh dedicated-agent `ask_clarification` as stale: it must win over
a same-run premature `[INTENT_READY]` and leave the UI waiting for the user.
Conversely, the prior turn’s historical clarification must not block the user
reply from completing. Do not
change `roundtable-intent`'s `{objective,constraints,assumptions}` contract for
this feature. The position router accepts the new schema for activation and
injects all four sections into downstream position-node and closing-agent
context; legacy stored summaries are mapped read-only for compatibility.
`/api/intent/init` must create the empty Step-1 thread through
`multi_agent._create_thread_direct`, exactly like multi-agent initialization;
do not restore the old HTTP loopback to `/api/threads`, because deployed
@@ -436,6 +457,15 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
### Position-collaboration foundation
### Roundtable business-chain human validation
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
boolean `human_validation` field on each seat (`ChainSeatSchema`). It is a
frontend Step 2 checkpoint, not a server-side approval task: after the marked
seat completes (or, in a DAG, after its stage completes), the client pauses and
uses the existing resume button or intervention composer to continue. No new
database column, migration, or validation card is required.
The position-collaboration workspace is a separate frontend route and must not
use the original roundtable coordinator or jobs. Its shared configuration
integration point is the existing `/api/roundtable-chains` store: each `seats` JSON item
@@ -1133,6 +1163,26 @@ the initial intent workspace plus the summary/action-plan closing agents. Do
not delete a built-in id because historical chain nodes persist those ids;
custom role ids may be added and removed.
### Position-roundtable task-scoped session history
`position_roundtable_sessions.external_task_id` is optional. When populated from the frontend route `taskId`, new rows set `task_scoped = true`: a task can have multiple sessions and all users opening that task see the same sessions, nodes and persisted conversation snapshots. When it is NULL/empty, keep the original `user_id`-scoped personal workspace behavior. `20260808_01` adds `task_scoped` with default `false` (so all historical records remain personal) plus the task/update-time index; it must not backfill or make `external_task_id` non-null.
### Roundtable artifact submission status
`roundtable_artifact_submissions` keeps task-level submission state for a
generated deliverable and is deliberately separate from task details and any
external task-system delivery. The API is
`GET /api/roundtable/tasks/{taskId}/artifacts?submitted=true` and
`PUT /api/roundtable/tasks/{taskId}/artifacts/submission`. All callers use the
normal Gateway Bearer authentication; records are shared by task while
`created_by`/`submitted_by`/`cancelled_by` are audit-only. The server derives a
SHA-256 idempotency key from `taskId + sourceKey + path + revision`: submitting
the same revision is an upsert, while cancellation only flips `submitted` and
sets `cancelled_at` (it never deletes history). Store only the sandbox file
reference (`threadId`, path, source/node/session fields, name/MIME and revision)
and timestamps; do not persist Markdown content or call a formal delivery
interface. Previews continue to read the DeerFlow artifact by `threadId + path`.
See `docs/` directory for detailed documentation:
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. Startup checks `croniter`, `hindsight-client`, `aiomysql`, and Redis, installing missing packages from wheelhouse only; `aiomysql==0.3.2` includes the pinned `PyMySQL==1.2.0` transitive dependency. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
+41 -1
View File
@@ -138,7 +138,7 @@ FastAPI application providing REST endpoints for frontend integration:
| `POST /api/skills/install` | Install skill from `.skill` archive |
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace, plus `human_validation` to mark a Step 2 client-side pause checkpoint after that seat/stage completes (the original roundtable has no extra validation card) |
| `GET/PUT /api/business-mapping` | Read global business mappings. Updates to 3Q/6BF/7BF require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. |
| `GET /api/memory` | Retrieve memory data |
@@ -159,6 +159,8 @@ FastAPI application providing REST endpoints for frontend integration:
The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username` directly (its `username`, `userId`, or `yUserId` URL parameter identifies the user), so this compatibility flow does not depend on the retired Consumer login service. It calls the Gateway address configured as `b1ConsumerUrl` directly; it does not use a Vite `/api` reverse proxy. An empty TaskCOP store remains empty; task data is introduced through normal creation or the JSON import API. The Gateway CORS middleware always merges the local TaskCOP Vite origins `http://localhost:8080` and `http://127.0.0.1:8080`, including when `GATEWAY_CORS_ORIGINS` is set for another frontend. `scripts/env.sh` also includes them in its startup default. For a deployment, set `GATEWAY_CORS_ORIGINS` to the exact additional frontend origins that are allowed to call the Gateway.
Position-roundtable history supports two modes. A non-empty `external_task_id` from the frontend route's `taskId` creates a row with `task_scoped = true`, so all sessions, nodes and persisted conversation snapshots for that task are shared across users; `user_id` is then creator audit metadata only. When the field is absent or empty, the original per-user personal-session behavior remains unchanged. Alembic revision `20260808_01` adds `task_scoped` with the safe default `false` for historical rows plus the task/update-time index.
AI writing supports a sample-imitation mode: the frontend can upload or paste a sample article, the Gateway extracts text for document files, and the `ai_writing` graph runs a `sample_analyzer` node before normal intent/research/outline/draft stages. The resulting style profile is injected into outline and draft prompts with explicit guardrails against copying source sentences, facts, names, or data.
### Custom Agents
@@ -494,6 +496,24 @@ checkpoint or sandbox-output volume is replaced. Apply Alembic revision
Its live intent conversation still uses the shared `/api/intent/init` and
`/api/intent/stream` endpoints used by the original multi-agent roundtable.
Position roundtable identifies itself with `intent_agent_id: "position-roundtable-intent"`;
this is a separately seeded built-in agent, not an alias of `roundtable-intent`.
It preserves the same thread, SSE streaming, and file-reading lifecycle as the
generic Step-1 agent, but has its own **at-most-five user-assistance-round**
brake. A round is one AI turn plus the user reply, not one tool call: the
dedicated agent may emit several independent `ask_clarification` calls in the
same turn, which the frontend renders as several separate selection cards and
submits together. A topic-only request is not considered resolved: the agent
asks for every independent missing decision (such as scope, decision use, or
task constraints) as separate cards. For the dedicated agent, a fresh
`ask_clarification` emitted in a stream is a strict user-input pause and wins over any same-run premature
`[INTENT_READY]`; a historical clarification from the prior turn does not
block the user answer from completing. Its final
`[INTENT_READY]` JSON contains four string arrays: `coreGoals`, `riskWarnings`,
`keyPoints`, and `strategicSignificance`; it must contain completed conclusions,
not “待确认” or other unresolved placeholders. The multi-agent endpoint remains
backwards compatible when this optional field is omitted and continues to emit
`objective`, `constraints`, and `assumptions`.
`/api/intent/init` creates its empty thread directly through the in-process
thread store/checkpointer, matching multi-agent initialization and avoiding a
deployment-sensitive `127.0.0.1:<gateway-port>` loopback. If initial checkpoint
@@ -522,3 +542,23 @@ is required. It is the initial intent-identification workspace and owns the
summary/action-plan closing agents. Built-in rows retain stable ids so
historical chain nodes remain readable; they can be edited or disabled but not
deleted. Custom roles may be freely added or removed.
## Roundtable artifact submission status
The task-scoped roundtable workbench exposes a lightweight submission-status
API for generated deliverables:
- `GET /api/roundtable/tasks/{taskId}/artifacts?submitted=true` returns the
submitted artifact references for a task (or `items: []`).
- `PUT /api/roundtable/tasks/{taskId}/artifacts/submission` records
`submitted: true` or cancels it with `submitted: false`.
Both routes use the normal Gateway `Authorization: Bearer <token>` middleware.
The record is shared by `taskId`; the authenticated user is retained only in
audit columns. Its server-owned idempotency key is
`taskId + sourceKey + path + revision`. Repeating the same submitted payload
returns the existing record; cancellation updates its state and timestamp
without deleting the audit row. DeerFlow stores only the artifact reference
(`threadId` + sandbox `path`, plus the optional session/node/name/MIME fields),
never the Markdown body and never a task-system delivery. Continue to preview
the file through the existing thread-artifact endpoint.
@@ -48,8 +48,9 @@ from app.gateway.routers import (
llmwiki,
mcp,
memory,
menu_overrides,
models,
menu_overrides,
model_management,
models,
multi_agent,
notifications,
open_3qfx,
@@ -66,8 +67,9 @@ from app.gateway.routers import (
recommend,
flow_extract,
recommended_questions,
roundtable_chains,
roundtable_diagnostics,
roundtable_chains,
roundtable_artifact_submissions,
roundtable_diagnostics,
roundtable_draft_shares,
roundtable_drafts,
roundtable_jobs,
@@ -1424,8 +1426,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
)
# Include routers
# Models API is mounted at /api/models
app.include_router(models.router)
# Models API is mounted at /api/models
app.include_router(models.router)
# Administrator-managed config.yaml model settings and connection tests.
app.include_router(model_management.router)
# MCP API is mounted at /api/mcp
app.include_router(mcp.router)
@@ -1465,8 +1469,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
app.include_router(roundtable_drafts.router)
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
app.include_router(roundtable_task_drafts.router)
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
app.include_router(roundtable_task_drafts.router)
# Task-scoped artifact submission status; stores references only, never artifact content.
app.include_router(roundtable_artifact_submissions.router)
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
app.include_router(flow_extract.router)
@@ -311,6 +311,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
# Task-scoped artifact submission status: shared by task; actor is audit-only.
app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
@@ -30,7 +30,9 @@ Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内
> ⚠️ 维护提示:资源文件(``_roundtable_seed_assets/``)是**发布默认值**的唯一可信源。
> 线上调优后,请把改动写回对应的资源 .md,否则下次新机器补建出来的还是旧版。
> ``_ensure_one`` 只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
> ``_ensure_one`` 通常只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
> 例外是 position intent 的实现契约刷新,以及行动规划智能体新增平台自带 Skill 时的
> ``skills`` 列表合并;后者不会覆盖已编辑的 SOUL 或既有 Skill。
为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建
------------------------------------------------------------
@@ -52,12 +54,18 @@ import logging
from collections.abc import Iterable
from pathlib import Path
import yaml
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
INTENT_AGENT_ID = "roundtable-intent"
# Position roundtable deliberately owns a separate intent agent. Its dialogue
# lifecycle is shared with Step 1 above, but its final structured hand-off is
# different and must not change the multi-agent roundtable contract.
POSITION_INTENT_AGENT_ID = "position-roundtable-intent"
RECOMMENDER_AGENT_ID = "roundtable-recommender"
COORDINATOR_AGENT_ID = "roundtable-coordinator"
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
@@ -92,6 +100,7 @@ _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
# roundtable-dashboard 支撑 Step 3 大屏多页结构化数据(report-json + 问答/增量改)。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
INTENT_AGENT_ID,
POSITION_INTENT_AGENT_ID,
RECOMMENDER_AGENT_ID,
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
@@ -103,20 +112,61 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
_ACTION_PLAN_SKILL = "position-roundtable-action-create"
def _ensure_action_plan_skill(agent_dir: Path) -> bool:
"""Merge the owned batch-create Skill into an existing action planner.
The planner SOUL remains editable in the agent configuration drawer and is
therefore never overwritten here. The skill binding, however, is a runtime
integration contract: without it an already-deployed planner cannot read
the new SKILL.md or invoke the batch API script after a code upgrade.
"""
config_path = agent_dir / "config.yaml"
try:
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
except (OSError, yaml.YAMLError):
logger.warning("Unable to read action planner config for skill binding: %s", config_path)
return False
if not isinstance(config, dict):
logger.warning("Action planner config is not a mapping; skip skill binding: %s", config_path)
return False
skills = config.get("skills")
if skills is None:
normalized: list[str] = []
elif isinstance(skills, list):
normalized = [str(name).strip() for name in skills if str(name).strip()]
else:
logger.warning("Action planner skills is not a list; skip skill binding: %s", config_path)
return False
if _ACTION_PLAN_SKILL in normalized:
return False
config["skills"] = [*normalized, _ACTION_PLAN_SKILL]
try:
config_path.write_text(
yaml.safe_dump(config, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
except OSError:
logger.warning("Unable to write action planner skill binding: %s", config_path)
return False
return True
def _ensure_one(agent_id: str) -> bool:
"""Copy the verified SOUL.md + config.yaml for one functional agent into
``.deer-flow/agents/<id>/`` if that directory is missing.
Returns True if the files were just created, False if the directory already
existed (we never overwrite — respect any local edits) or the seed assets
are missing.
"""
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False
The position-roundtable intent agent is an implementation-owned compatibility
contract: unlike user-managed agents, its schema must stay aligned with the
position workspace. Its two seed files are therefore refreshed when they
differ, so a previously seeded incorrect prompt is repaired after deployment.
Returns True if files were created or refreshed, False if no work was needed
or the seed assets are missing.
"""
src_dir = _ASSETS_DIR / agent_id
if not src_dir.is_dir():
# Assets should always ship with the code; if they don't, surface a loud
@@ -128,6 +178,27 @@ def _ensure_one(agent_id: str) -> bool:
)
return False
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
if agent_id == ACTION_PLAN_AGENT_ID:
return _ensure_action_plan_skill(agent_dir)
if agent_id != POSITION_INTENT_AGENT_ID:
# Other built-in agents may have been tuned on a deployed machine;
# preserve the long-standing no-overwrite behavior for them.
return False
refreshed = False
for fname in _SEED_FILES:
src = src_dir / fname
if not src.exists():
continue
target = agent_dir / fname
source_bytes = src.read_bytes()
if not target.exists() or target.read_bytes() != source_bytes:
target.write_bytes(source_bytes)
refreshed = True
return refreshed
agent_dir.mkdir(parents=True, exist_ok=True)
for fname in _SEED_FILES:
src = src_dir / fname
@@ -138,9 +209,9 @@ def _ensure_one(agent_id: str) -> bool:
def ensure_roundtable_functional_agents() -> list[str]:
"""Seed the three roundtable functional agents (``roundtable-intent`` /
``roundtable-recommender`` / ``roundtable-coordinator``) if their on-disk
directories are missing. Idempotent — fast no-op when all exist.
"""Seed the built-in roundtable functional agents when their on-disk
directories are missing, and refresh the implementation-owned position
intent schema when it has changed. Idempotent after synchronization.
Called from the startup lifespan and from the entry of intent/init,
intent/stream, recommend/stream, and multi_agent/init so the routes
@@ -26,23 +26,29 @@
```json
{
"id": "subtask-001",
"title": "简短任务名称",
"goal": "任务目标",
"basis": ["方案总结章节或岗位交付依据"],
"owner_position": "建议责任岗位",
"dependencies": ["前置子任务 id"],
"deliverable": "可核验交付物",
"acceptance_criteria": ["验收条件"],
"priority": "P0"
"actionName": "行动名称",
"actionDescription": "行动描述",
"actionType": 0,
"publishChannel": 0,
"infoFormat": 0,
"responsiblePosition": 0,
"startTime": "",
"endTime": "",
"taskId": 0
}
```
`priority` 仅使用 `P0`、`P1`、`P2`。`dependencies` 没有前置时使用空数组。不要输出注释、尾逗号或额外字段来替代这些必填字段。
`actionType`、`publishChannel`、`infoFormat`、`responsiblePosition` 必须从隐藏上下文传入的对应字典选择,并将该项 `dictValue` 写为整数。`taskId` 必须等于隐藏上下文中前端传入的任务 ID。不要输出注释、尾逗号、旧的 `id/title/goal/basis` 等字段,不能用 0 或空值代替未选择的字典项。
## 行动批量新增
首次生成完整行动清单后:仅当隐藏上下文的 `taskId` 是正整数且四类字典(发布渠道、行动类型、信息形式、责任岗位)均完整时,必须先用 `read_file` 读取 `position-roundtable-action-create` Skill 的 `SKILL.md` 全文,再按其说明调用脚本批量新增行动。若 taskId 为本地演示标识/为空,或字典接口未就绪,只生成报告与 JSON;枚举字段可暂填 `0` 占位,并明确说明未调用外部任务系统。不得逐条调用旧的 `/task/action/add`,也不得自行用 curl 或 requests 替代脚本。
脚本成功时才可以说明行动已入库;脚本失败时如实报告失败原因。相同任务、相同行动清单会由脚本自动幂等跳过。已经成功新增后,如用户要求修改行动,先重写 Markdown 和 JSON;因为当前没有删除/更新行动接口,只有用户明确说“确认新增行动”或“重新批量新增”时才可以按 Skill 使用 `--force` 再次新增,并需提示重复风险。
## 边界
- 只依据用户消息中提供的任务信息、意图、方案总结和岗位交付。
- 不调用联网、派活、澄清、展示文件等工具。
- 不调用联网、派活、澄清、展示文件等工具;仅可为行动批量新增按专属 Skill 使用 `read_file` 与 `bash`。
- 不删除或修改上游岗位产物。
- 不在报告中添加“AI 生成”类水印。
@@ -1,3 +1,5 @@
id: position-action-planner
name: 行动规划智能体
description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。
skills:
- position-roundtable-action-create
@@ -0,0 +1,145 @@
# 岗位会商任务意图理解智能体
你是岗位多智能体会商 Step 1 的“意图理解专员”。你的唯一职责是把用户输入背后的任务意图结构化后交给后续岗位业务链处理。
你不写报告、不答题、不创作、不规划、不执行、不调用任何操作工具。这些都是后续岗位业务链的事。你只负责听懂、整理、澄清和交接。
---
## 0. 四条不可违背的硬规则
### 硬规则 1 · 不替代后续岗位干活
无论用户让你“写报告 / 写代码 / 写诗 / 查资料 / 做规划 / 出方案”,你都不要直接产出具体内容。
- 正确做法:把诉求结构化成任务意图,输出 [INTENT_READY],交给后续岗位业务链。
- 错误做法:用户说“帮我写一份市场分析报告”后,开始写报告正文。
### 硬规则 2 · 最多 5 轮用户协助,确认完整后才收敛
一次“同一轮中调用一个或多个 ask_clarification + 用户统一回答”算一轮用户协助。
- 首轮:若任务卡和用户输入已经足以形成可执行的四项意图,可直接输出 [INTENT_READY]。
- 第 1 至第 5 轮用户协助:若信息不足,必须发起用户协助;不能把“主题已识别”误判为“意图已明确”。
- 同一轮可发起多张用户协助卡:一个相互独立的决策点对应一次 ask_clarification 调用和一张卡。不得把多个问题塞进一张卡;用户可逐卡选择后一次性提交。
- 第 5 次协助应覆盖所有剩余关键项。收到该轮回答后,必须基于明确答案收敛为最终意图。
- 绝不进入第 6 次用户协助。系统会在达到上限后移除 ask_clarification。
### 硬规则 3 · 输出 [INTENT_READY] 后立刻闭嘴
最终 JSON 代码块闭合后,禁止再输出任何字符:不继续解释、不问“还需要补充吗”、不调用工具、不复述、不展望。
### 硬规则 4 · 只做理解和澄清,不当守门员
这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。
---
## 1. 任务意图字段定义
最终结构必须包含以下四个字段,且都必须是字符串数组:
| 字段 | 含义 | 填写原则 |
| --- | --- | --- |
| coreGoals | 用户想实现或想获得的核心结果 | 将原本的一句话任务目标拆为 1 个或多个清晰目标 |
| riskWarnings | 已知风险、依赖或执行风险 | 必须分析任务级风险;范围、地区、周期、数据来源、对象或决策用途缺失时,应先通过用户协助确认,最终结果不得保留待确认表述 |
| keyPoints | 范围、对象、优先级、时间、资源、交付形式等关键要点 | 仅汇总用户已明确的事实与结论,最终结果不得保留待确认项 |
| strategicSignificance | 任务对后续岗位协同、业务链或决策推进的意义 | 必须分析该任务将支撑哪类后续决策、业务链协同或资源投入;可以作出任务级合理推断,但不得虚构组织现状或业务数据 |
核心原则:
- 用户已给出任务目标、范围、用途和约束时,直接汇总,不要追问。
- “只给出一个主题或一句极短诉求”不等于任务意图完整。任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助。
- 同一轮应识别所有仍会影响后续业务链的关键缺口,并为每个相互独立的缺口各调用一次 ask_clarification,避免一个问题一轮地消耗用户;不要把它们合并为一张多题卡。
- 最终 [INTENT_READY] 中禁止出现“待确认”“未明确”“需补充”“暂无”“缺少”等未完成表述;信息不足时继续协助,不得提前交接。
- 空数组只适用于用户明确说明“不涉及/没有”这一项时。
---
## 2. 工作流
~~~
用户输入
↓
是否能形成可执行的四项意图?
├─ 能 → 立即输出 [INTENT_READY],结束
└─ 不能 → 同一轮可分别调多个 ask_clarification(每张卡只问一个决策点)
↓
用户回答 → 再判断
↓
最多 5 轮用户协助 → 收到明确答案后输出 [INTENT_READY]
~~~
### 提问前自检清单
任一项为“否”,就应发起用户协助;同轮把全部为“否”的独立决策点分别做成卡片:
1. 我是否不仅能写出核心目标,也能明确主要分析范围或决策用途?
2. 我是否能基于任务给出至少一项任务级风险提示和战略意义?
3. 用户是否已经在任务卡、当前消息或历史消息中给出这些关键信息?
4. 这些缺口是否已经在本轮协助中一次性列出?
5. 是否已达到第 5 次用户协助?若是,等待用户对最后一组问题的回答,不再发起第 6 次协助。
---
## 3. 唯一允许的工具:ask_clarification
除 ask_clarification 外,禁止调用任何工具、技能或操作。尤其禁止搜索、写文件、读写沙箱、展示文件、创建任务、编排智能体或生成交付物。
当任务只有主题、或缺少决定范围/用途/风险判断的关键信息时,必须调用它。每个协助问题只包含一个决策点;同一轮需要确认多个点时,连续调用多次工具,生成多张独立选择卡:
~~~
ask_clarification(
question="本次分析主要面向哪个范围?",
clarification_type="analysis_scope",
context="范围会决定后续岗位的分析边界",
options=["选项 A", "选项 B", "其他,我补充说明"],
allow_multiple=False
)
~~~
若还需要确认交付用途、时间边界、对象、风险限制等,必须在同一次回复中继续分别调用 ask_clarification,而不是将它们写进上面这张卡。不得重复询问用户已经回答过的问题。
---
## 4. 结束协议:[INTENT_READY]
可以收敛时,先用 1–2 句话、30 字以内复述理解,然后严格以 JSON 代码块结束。JSON 代码块后不得有任何内容。
输出示例的文本顺序必须是:
~~~
✅ 我理解的任务是:<一句话复述>
[INTENT_READY]
JSON 代码块(语言标识 json):
{
"coreGoals": ["..."],
"riskWarnings": [],
"keyPoints": ["..."],
"strategicSignificance": []
}
~~~
格式要求:
- [INTENT_READY] 单独成行。
- [INTENT_READY] 后紧跟语言标识为 json 的 Markdown 代码块,包裹合法 JSON 对象。
- 四个字段名称必须严格为 coreGoals / riskWarnings / keyPoints / strategicSignificance。
- 四个字段必须都是字符串数组;除用户明确说明不涉及外,不要输出空数组。
- 输出前逐项检查:四个数组中不得出现“待确认”“未明确”“需补充”“暂无”“缺少”等词语。
- JSON 必须严格合法:英文双引号、无注释、无尾随逗号;字符串中不可直接换行。
- JSON 代码块闭合后,禁止再写任何字符。
- 不得输出旧字段 objective、constraints、assumptions。
---
## 5. 禁止事项
- 禁止直接产出报告、代码、诗、方案、分析或任何交付物。
- 禁止调用任何非 ask_clarification 的工具。
- 禁止第 5 次用户协助后再发起第 6 次协助。
- 禁止重复询问用户已回答的问题。
- 禁止拒绝用户、要求用户换问题或以风险为由中断。
- 禁止编造外部事实、组织现状或业务数据;信息缺口必须在用户协助阶段确认,最终只输出明确的执行风险和战略意义。
- 禁止在 [INTENT_READY] 后再输出一个字。
@@ -0,0 +1,3 @@
id: position-roundtable-intent
name: 岗位会商任务意图理解
description: 岗位会商专用的任务意图理解智能体。通过必要的简短追问,将任务整理为核心目标、风险提示、关键要点与战略意义,供后续岗位业务链执行。
@@ -34,6 +34,7 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
仅 ``status == "done"`` 时带 ``summary``;澄清时还带
``question``、``clarification_type``、``clarification_context``、
``options``、``allow_custom``、``allow_multiple``(见 ``resolve_allow_multiple``)。
多卡回合还带 ``clarifications`` 完整数组;顶层字段始终镜像第一张卡以兼容旧客户端。
终态判定优先级(实现细节,勿随意调整)
------------------------------------
@@ -65,7 +66,10 @@ from app.gateway.roundtable_diag import record_foreground_diag
# Reuse the building blocks already in multi_agent.py — keeps both routers
# identical in connection handling, payload shape, and error mapping.
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
from app.gateway.routers._roundtable_seed import (
POSITION_INTENT_AGENT_ID,
ensure_roundtable_functional_agents,
)
from app.gateway.routers.multi_agent import (
_ROUNDTABLE_THREAD_METADATA,
_auth_headers,
@@ -87,6 +91,7 @@ router = APIRouter(prefix="/api/intent", tags=["intent"])
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
INTENT_AGENT_ID = "roundtable-intent"
_SUPPORTED_INTENT_AGENT_IDS = frozenset({INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID})
# SOUL 规定的交接信号:``[INTENT_READY]`` + markdown json 代码块。
# group(1) 解析为 IntentSummary;解析失败则继续走澄清/asking 分支。
@@ -96,7 +101,49 @@ _INTENT_READY_PATTERN = re.compile(
)
def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, Any]:
def _requested_intent_agent_id(payload: dict[str, Any]) -> str:
"""Resolve the explicitly supported Step-1 agent without opening a route
for arbitrary custom-agent execution.
Existing multi-agent callers omit the field and therefore keep using
``roundtable-intent``. Position roundtable sends the dedicated ID so its
prompt and final JSON schema remain isolated.
"""
requested = str(payload.get("intent_agent_id") or "").strip()
if not requested:
return INTENT_AGENT_ID
if requested not in _SUPPORTED_INTENT_AGENT_IDS:
raise HTTPException(status_code=400, detail="Unsupported intent agent")
return requested
def _clarification_blocks_ready(
intent_agent_id: str,
clarification_calls: Any,
*,
is_fresh: bool,
) -> bool:
"""Position intent must wait for a fresh clarification answer.
The generic Step-1 route intentionally preserves READY-first behavior for
stale tool calls in historical snapshots. Position roundtable instead has
a four-part hand-off whose missing context is requested interactively, so
a newly dispatched clarification is a strict terminal pause. A historical
tool call from the preceding user-assistance turn must not block the next
user answer from reaching READY.
"""
return (
intent_agent_id == POSITION_INTENT_AGENT_ID
and bool(clarification_calls)
and is_fresh
)
def _intent_thread_metadata(
task_context: dict[str, Any] | None,
*,
intent_agent_id: str = INTENT_AGENT_ID,
) -> dict[str, Any]:
"""Build task-scoped metadata for a newly-created intent thread.
Normal roundtable Step 1 calls still use the original system-thread tag.
@@ -122,6 +169,8 @@ def _intent_thread_metadata(task_context: dict[str, Any] | None) -> dict[str, An
"position_roundtable_phase": "intent",
}
)
if intent_agent_id == POSITION_INTENT_AGENT_ID:
metadata["position_roundtable_intent_agent_id"] = intent_agent_id
return metadata
@@ -190,6 +239,32 @@ def _count_past_clarifications(messages: list[dict[str, Any]]) -> int:
return count
def _count_past_clarification_rounds(messages: list[dict[str, Any]]) -> int:
"""Count completed assistance *rounds*, rather than question cards.
The generic roundtable agent retains its historic three-tool-call brake.
Position roundtable deliberately lets one model turn emit several
independent ``ask_clarification`` cards, so using the tool-call count
there would accidentally spend all five user-assistance rounds at once.
One AI message containing one or more clarification calls is one round.
"""
if not isinstance(messages, list):
return 0
rounds = 0
for msg in messages:
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
if any(
(tc.get("name") or "").lower() == "ask_clarification"
for tc in msg.get("tool_calls") or []
if isinstance(tc, dict)
):
rounds += 1
return rounds
async def _fetch_thread_messages(
client: httpx.AsyncClient,
base: str,
@@ -225,8 +300,18 @@ async def _fetch_thread_messages(
# 硬上限:超过这个轮数,无论模型怎么想,都强制移除 ask_clarification 工具。
# 与 SOUL.md §0 「硬规则 2」对齐——"绝不存在第 4 轮"。
# 通用会商沿用 3 次工具调用的历史保护;岗位会商按"一轮可多张卡"计数,
# 允许最多 5 次用户协助。
_MAX_CLARIFICATION_ROUNDS = 3
_POSITION_MAX_CLARIFICATION_ROUNDS = 5
def _max_clarification_rounds(intent_agent_id: str) -> int:
return (
_POSITION_MAX_CLARIFICATION_ROUNDS
if intent_agent_id == POSITION_INTENT_AGENT_ID
else _MAX_CLARIFICATION_ROUNDS
)
def _is_repeated_clarification(
@@ -310,6 +395,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
# 调用前自检:内网部署 .deer-flow/ 被 gitignore,首次启动时本 agent 目录可能不存在,
# 缺失则就地用嵌入模板补建,避免下一步 /stream 报 500。
ensure_roundtable_functional_agents()
intent_agent_id = _requested_intent_agent_id(payload)
# Keep Step-1 initialization aligned with multi-agent initialization:
# create the empty thread in-process rather than looping back through
@@ -321,7 +407,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
try:
thread_id = await _create_thread_direct(
request,
metadata=_intent_thread_metadata(task_context),
metadata=_intent_thread_metadata(task_context, intent_agent_id=intent_agent_id),
)
except HTTPException:
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
@@ -341,7 +427,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
raise HTTPException(status_code=500, detail=diagnostic) from exc
return {
"status": "success",
"agent_id": INTENT_AGENT_ID,
"agent_id": intent_agent_id,
"thread_id": thread_id,
}
@@ -363,6 +449,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
}
"""
thread_id = str(payload.get("thread_id") or "").strip()
intent_agent_id = _requested_intent_agent_id(payload)
message = str(payload.get("message") or "").strip()
# 不写死任何默认模型名:空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致,内网部署只配
@@ -386,21 +473,30 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
base = _loopback_base(request)
headers = _auth_headers(request)
fallback_instruction = (
"四个岗位会商字段都必须保留为字符串数组,依据已获得的用户确认形成明确结论,"
"不得写待确认、未明确、需补充、暂无或缺少"
if intent_agent_id == POSITION_INTENT_AGENT_ID
else "缺的字段用 assumptions 兜底"
)
async def stream_generator() -> AsyncIterator[str]:
# ASGITransport:进程内直调本机 ASGI app,**不走 TCP / 不经过网络栈**,
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
async with _make_loopback_client(request) as client:
try:
# —— 3 轮硬上限兜底 ——
# 在发起 run 之前先读一次 thread state,数当前 ask_clarification 已发出次数:
# past=0/1 → 正常,不动 message,不动工具白名单
# past=2(本轮即将变成第 3 轮)→ 给消息加前缀,强提示"必须 INTENT_READY"
# past≥3 → 直接把 ask_clarification 也加进 excluded_tools,工具物理消失
# 这一步是个"刹车":SOUL 让模型自律,这里让系统兜底。
past_clarifications = _count_past_clarifications(
await _fetch_thread_messages(client, base, headers, thread_id)
# —— 澄清轮次硬上限兜底 ——
# 通用会商保持原来的 3 次 ask 工具调用上限;岗位会商允许一个
# AI turn 同时产生多张独立协助卡,所以必须按 AI 消息(一次
# "提问 + 用户回答")计轮,不能按卡片数计轮。
past_messages = await _fetch_thread_messages(client, base, headers, thread_id)
past_clarifications = _count_past_clarifications(past_messages)
past_rounds = (
_count_past_clarification_rounds(past_messages)
if intent_agent_id == POSITION_INTENT_AGENT_ID
else past_clarifications
)
max_rounds = _max_clarification_rounds(intent_agent_id)
# 1. ask_clarification 的工具白名单——超过硬上限就移除。
base_excluded_tools = [
@@ -420,13 +516,13 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"task",
"write_todos",
]
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
if past_rounds >= max_rounds:
base_excluded_tools.append("ask_clarification")
logger.info(
"intent stream: past_clarifications=%d >= %d, force-disabling "
"intent stream: past_rounds=%d >= %d, force-disabling "
"ask_clarification to break the loop",
past_clarifications,
_MAX_CLARIFICATION_ROUNDS,
past_rounds,
max_rounds,
)
# 带了上传文件时,解锁「读」类工具(read_file / ls / grep / view_image),
@@ -440,22 +536,42 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
# 仅文件、无文本时给一句兜底引导,让模型先读文件再澄清意图。
effective_message = message or "我上传了文件,请先阅读文件内容,再据此理解并澄清我的任务意图。"
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
if intent_agent_id == POSITION_INTENT_AGENT_ID and past_rounds < max_rounds:
# The position flow needs an actionable four-part hand-off,
# not merely a recognizable topic. One turn may ask several
# independent decisions, each rendered as its own selection
# card by the existing Step-1 UI.
effective_message = (
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
f"本轮请直接输出 [INTENT_READY],缺的字段用 assumptions 兜底,"
"[岗位会商收敛检查] 仅识别出主题不等于意图已明确。"
"若当前任务还无法判断分析范围、决策用途、关键风险或战略意义,"
"请在同一次回复中为每个相互独立的关键缺口分别调用一次 "
"ask_clarification;每个工具调用只能问一个决策点,不能把多项"
"问题合并成一张卡片。用户会一次性回答这些卡片。"
"如果四项意图已经足够明确,则直接输出最终结果。"
"不要以风险提示和战略意义为空的 [INTENT_READY] 结束。"
"本轮是第 %d/%d 轮用户协助;第 %d 轮若仍有缺口,必须把所有"
"剩余关键缺口分别发成卡片,收到回答后立即收敛。\n\n"
f"用户最新输入:{effective_message}"
) % (past_rounds + 1, max_rounds, max_rounds)
if past_rounds >= max_rounds:
effective_message = (
f"[系统强制] 已澄清 {past_rounds} 轮,触发硬上限。"
f"本轮请直接输出 [INTENT_READY],{fallback_instruction},"
f"绝对不要再调任何工具,也不要再询问任何问题。\n\n"
f"用户最新输入:{message}"
)
elif past_clarifications >= _MAX_CLARIFICATION_ROUNDS - 1:
elif (
intent_agent_id != POSITION_INTENT_AGENT_ID
and past_rounds >= max_rounds - 1
):
effective_message = (
f"[系统提示] 已澄清 {past_clarifications} 轮,这是最后一轮。"
f"本轮请直接输出 [INTENT_READY],未明确的字段用 assumptions 兜底。\n\n"
f"[系统提示] 已澄清 {past_rounds} 轮,这是最后一轮。"
f"本轮请直接输出 [INTENT_READY],{fallback_instruction}。\n\n"
f"用户最新输入:{message}"
)
body = _run_payload(
agent_name=INTENT_AGENT_ID,
agent_name=intent_agent_id,
model_name=model_name,
thread_id=thread_id,
new_message=effective_message,
@@ -479,6 +595,26 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
captured = done
messages = _parse_last_messages(captured)
current_rounds = (
_count_past_clarification_rounds(messages)
if intent_agent_id == POSITION_INTENT_AGENT_ID
else _count_past_clarifications(messages)
)
clarification_is_fresh = current_rounds > past_rounds
# A position-roundtable clarification is an explicit pause.
# Some models emit a premature [INTENT_READY] text after the
# tool call in the same upstream run. For this dedicated flow
# the fresh clarification must win so the user can answer it;
# generic roundtable keeps its historical READY-first order.
dispatch_msg = _find_dispatch_message(messages)
tool_calls = dispatch_msg.get("tool_calls") or []
clarification_calls = [
tc
for tc in tool_calls
if isinstance(tc, dict)
and (tc.get("name") or "").lower() == "ask_clarification"
]
# --- 终态分支 1:结构化意图已就绪(最高优先级)---
# 模型可能在较早的 AIMessage 里已输出 [INTENT_READY],而最后一轮
@@ -507,7 +643,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# Step1Panel 已经把 `parts.after` 隐藏掉,体验闭环。
ready_text = msg_text[: m.end()].rstrip() + "\n"
break
if summary and ready_text:
if (
summary
and ready_text
and not _clarification_blocks_ready(
intent_agent_id,
clarification_calls,
is_fresh=clarification_is_fresh,
)
):
final_frame = {
"status": "done",
"content": ready_text,
@@ -519,42 +663,49 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# --- 终态分支 2:本轮以 ask_clarification 结束 ---
# _find_dispatch_message:从后往前找带 tool_calls 的 AIMessage
# (SkillStop/澄清 ToolMessage 之后的那条 AI 消息)。
dispatch_msg = _find_dispatch_message(messages)
content = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or []
clarification_call = next(
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
None,
)
if clarification_call is not None:
cargs = clarification_call.get("args") or {}
question = str(cargs.get("question") or "").strip()
if clarification_calls:
# ClarificationMiddleware 常在 tool 之后还有一条纯文本 AIMessage,
# 比 tool_call 旁的短 preamble 更适合作为气泡正文。
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
display_content = follow_up or content or question
# 兜底:模型偶尔会复述上一轮的同一澄清问题(SOUL §2.5 prompt
# 已经强约束,但小模型 / 长上下文仍可能漏跟随)。检测到完全
# 重复时,把 status 从 clarification 降级为 asking,前端只渲染
# 文本气泡不弹卡片,避免用户看到"同样选项又出现一次"。
if _is_repeated_clarification(
messages,
new_question=question,
new_options=cargs.get("options"),
new_tool_call_id=clarification_call.get("id"),
):
logger.warning(
"intent stream: dropping duplicate ask_clarification "
"(question=%r) — already asked & answered earlier in thread",
question[:80],
clarification_payloads: list[dict[str, Any]] = []
for clarification_call in clarification_calls:
cargs = clarification_call.get("args") or {}
question = str(cargs.get("question") or "").strip()
# 逐卡过滤已被用户回答过的重复问题,不能因为其中一张
# 重复卡而吞掉同轮其它有效的协助卡。
if _is_repeated_clarification(
messages,
new_question=question,
new_options=cargs.get("options"),
new_tool_call_id=clarification_call.get("id"),
):
logger.warning(
"intent stream: dropping duplicate ask_clarification "
"(question=%r) — already asked & answered earlier in thread",
question[:80],
)
continue
clarification_payloads.append(
{
"id": str(clarification_call.get("id") or ""),
"question": question,
"clarification_type": str(cargs.get("clarification_type") or ""),
"clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
}
)
if not clarification_payloads:
fallback_msg = (
display_content
follow_up
or content
or "(系统识别到智能体重复了上一问题,已自动跳过。请补充其他信息继续推进。)"
)
final_frame: dict[str, Any] = {
final_frame = {
"status": "asking",
"content": fallback_msg,
"summary": None,
@@ -562,15 +713,15 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
return
first = clarification_payloads[0]
display_content = follow_up or content or str(first["question"])
final_frame: dict[str, Any] = {
"status": "clarification",
"content": display_content,
"question": question,
"clarification_type": str(cargs.get("clarification_type") or ""),
"clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
# 兼容旧客户端保留首张卡的顶层字段;完整列表使得网络
# 重连、缓存重放等没有实时 tool frame 的路径也能还原多卡。
**first,
"clarifications": clarification_payloads,
"summary": None,
}
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
@@ -0,0 +1,451 @@
"""Administrator model configuration backed by DeerFlow's ``config.yaml``.
The public ``/api/models`` endpoint intentionally stays small and never
returns secrets. This router is the complementary administrator surface: it
edits only the top-level ``models:`` section in the active DeerFlow config,
keeps a disk backup before every write, and swaps the gateway's runtime config
after validation so new requests can use the change without a restart.
"""
from __future__ import annotations
import asyncio
import copy
import logging
import os
import re
import shutil
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from time import perf_counter
from typing import Any
import yaml
from fastapi import APIRouter, HTTPException, Request
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.gateway.deps import get_optional_user_from_request
from deerflow.config.app_config import AppConfig, reload_app_config
from deerflow.config.model_config import ModelConfig
from deerflow.models.factory import create_chat_model
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/models", tags=["admin-models"])
_MODEL_BLOCK_RE = re.compile(r"(?m)^models:[^\r\n]*(?:\r?\n|$)")
_TOP_LEVEL_KEY_RE = re.compile(r"(?m)^[A-Za-z_][A-Za-z0-9_-]*:[^\r\n]*(?:\r?\n|$)")
_BACKUP_DIR_NAME = ".model-config-backups"
_MUTATION_LOCK = asyncio.Lock()
_EDITOR_FIELDS = frozenset(
{
"name",
"display_name",
"description",
"use",
"model",
"api_key",
"base_url",
"request_timeout",
"max_retries",
"max_tokens",
"temperature",
"stream_usage",
"supports_thinking",
"supports_reasoning_effort",
"supports_vision",
"when_thinking_enabled",
"when_thinking_disabled",
"thinking",
"use_responses_api",
"output_version",
}
)
_PRESERVED_FIELDS = frozenset(ModelConfig.model_fields) | _EDITOR_FIELDS
class ModelAdminItem(BaseModel):
"""A model configuration safe to return to an administrator browser."""
name: str
display_name: str | None = None
description: str | None = None
use: str
model: str
base_url: str | None = None
request_timeout: float | None = None
max_retries: int | None = None
max_tokens: int | None = None
temperature: float | None = None
stream_usage: bool | None = None
supports_thinking: bool = False
supports_reasoning_effort: bool = False
supports_vision: bool = False
when_thinking_enabled: dict[str, Any] | None = None
when_thinking_disabled: dict[str, Any] | None = None
thinking: dict[str, Any] | None = None
use_responses_api: bool | None = None
output_version: str | None = None
extra_settings: dict[str, Any] = Field(default_factory=dict)
api_key_configured: bool = False
api_key_source: str | None = None
source: str = "config.yaml"
class ModelAdminListResponse(BaseModel):
models: list[ModelAdminItem]
config_path: str
updated_at: str | None = None
class ModelUpsertRequest(BaseModel):
"""Editable subset of the ``ModelConfig`` schema plus safe extra fields."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=160)
display_name: str | None = Field(default=None, max_length=160)
description: str | None = Field(default=None, max_length=1000)
use: str = Field(min_length=1, max_length=255)
model: str = Field(min_length=1, max_length=255)
# ``None`` means "keep the currently configured secret" on update.
# The value is write-only in practice: it is never part of a response.
api_key: str | None = Field(default=None, max_length=4096)
clear_api_key: bool = False
base_url: str | None = Field(default=None, max_length=2048)
request_timeout: float | None = Field(default=None, ge=1, le=600)
max_retries: int | None = Field(default=None, ge=0, le=20)
max_tokens: int | None = Field(default=None, ge=1, le=1_000_000)
temperature: float | None = Field(default=None, ge=0, le=2)
stream_usage: bool | None = None
supports_thinking: bool = False
supports_reasoning_effort: bool = False
supports_vision: bool = False
when_thinking_enabled: dict[str, Any] | None = None
when_thinking_disabled: dict[str, Any] | None = None
thinking: dict[str, Any] | None = None
use_responses_api: bool | None = None
output_version: str | None = Field(default=None, max_length=128)
extra_settings: dict[str, Any] = Field(default_factory=dict)
@field_validator("name", "use", "model")
@classmethod
def _trim_required(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("value cannot be empty")
return normalized
@field_validator("display_name", "description", "base_url", "api_key", "output_version")
@classmethod
def _trim_optional(cls, value: str | None) -> str | None:
return value.strip() if isinstance(value, str) else value
class ModelTestRequest(BaseModel):
model: ModelUpsertRequest
class ModelTestResponse(BaseModel):
success: bool
latency_ms: int
message: str
preview: str | None = None
def _mask_env_source(value: Any) -> str | None:
if isinstance(value, str) and value.startswith("$") and len(value) > 1:
return value[1:]
return None
def _to_admin_item(raw: dict[str, Any]) -> ModelAdminItem:
api_key = raw.get("api_key")
extra = {
key: copy.deepcopy(value)
for key, value in raw.items()
if key not in _PRESERVED_FIELDS
}
return ModelAdminItem(
name=str(raw.get("name") or ""),
display_name=raw.get("display_name"),
description=raw.get("description"),
use=str(raw.get("use") or ""),
model=str(raw.get("model") or ""),
base_url=raw.get("base_url"),
request_timeout=raw.get("request_timeout"),
max_retries=raw.get("max_retries"),
max_tokens=raw.get("max_tokens"),
temperature=raw.get("temperature"),
stream_usage=raw.get("stream_usage"),
supports_thinking=bool(raw.get("supports_thinking", False)),
supports_reasoning_effort=bool(raw.get("supports_reasoning_effort", False)),
supports_vision=bool(raw.get("supports_vision", False)),
when_thinking_enabled=copy.deepcopy(raw.get("when_thinking_enabled")),
when_thinking_disabled=copy.deepcopy(raw.get("when_thinking_disabled")),
thinking=copy.deepcopy(raw.get("thinking")),
use_responses_api=raw.get("use_responses_api"),
output_version=raw.get("output_version"),
extra_settings=extra,
api_key_configured=bool(api_key),
api_key_source=_mask_env_source(api_key),
)
def _resolve_config_path() -> Path:
return AppConfig.resolve_config_path()
def _read_config_data(path: Path) -> tuple[str, dict[str, Any], list[dict[str, Any]]]:
raw_text = path.read_text(encoding="utf-8")
try:
data = yaml.safe_load(raw_text) or {}
except yaml.YAMLError as exc:
raise HTTPException(status_code=500, detail=f"Unable to parse active config.yaml: {exc}") from exc
if not isinstance(data, dict):
raise HTTPException(status_code=500, detail="The active config.yaml root must be a mapping")
raw_models = data.get("models") or []
if not isinstance(raw_models, list) or not all(isinstance(item, dict) for item in raw_models):
raise HTTPException(status_code=500, detail="config.yaml models must be a list of mappings")
return raw_text, data, [copy.deepcopy(item) for item in raw_models]
def _render_models_block(models: list[dict[str, Any]], newline: str) -> str:
rendered = yaml.safe_dump(
{"models": models},
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
width=120,
)
if newline != "\n":
rendered = rendered.replace("\n", newline)
return rendered if rendered.endswith(newline) else f"{rendered}{newline}"
def _replace_models_block(raw_text: str, models: list[dict[str, Any]]) -> str:
"""Replace the active model entries without touching other config sections.
PyYAML does not retain comments. Keep every comment found in the old
model section as documentation after the newly rendered model list rather
than silently deleting an operator's notes or disabled examples.
"""
block_match = _MODEL_BLOCK_RE.search(raw_text)
if block_match is None:
raise HTTPException(status_code=500, detail="The active config.yaml has no top-level models section")
newline = "\r\n" if "\r\n" in raw_text else "\n"
next_key = _TOP_LEVEL_KEY_RE.search(raw_text, block_match.end())
end = next_key.start() if next_key else len(raw_text)
previous_section = raw_text[block_match.end() : end]
preserved_comments = "".join(
line
for line in previous_section.splitlines(keepends=True)
if line.lstrip().startswith("#")
)
rendered = _render_models_block(models, newline)
if preserved_comments:
if not preserved_comments.endswith(("\n", "\r")):
preserved_comments = f"{preserved_comments}{newline}"
rendered = f"{rendered}{preserved_comments}"
return f"{raw_text[:block_match.start()]}{rendered}{raw_text[end:]}"
def _backup_and_write_config(path: Path, raw_before: str, raw_after: str) -> Path:
backup_dir = path.parent / _BACKUP_DIR_NAME
backup_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
backup_path = backup_dir / f"{path.stem}.{stamp}.yaml"
# Persist the exact text that was read for this mutation. This makes the
# rollback deterministic even if another operator changes the file while
# the gateway is processing a request.
with backup_path.open("w", encoding="utf-8", newline="") as handle:
handle.write(raw_before)
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
handle.write(raw_after)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, path)
except Exception:
try:
os.unlink(temp_name)
except OSError:
pass
raise
return backup_path
def _restore_backup(path: Path, backup_path: Path) -> None:
try:
shutil.copy2(backup_path, path)
except Exception:
logger.exception("Failed to restore model config backup at %s", backup_path)
def _request_to_raw_model(payload: ModelUpsertRequest, existing: dict[str, Any] | None = None) -> dict[str, Any]:
merged = copy.deepcopy(existing or {})
supplied = payload.model_dump(exclude={"extra_settings", "clear_api_key"}, exclude_none=True)
merged.update(supplied)
for key, value in payload.extra_settings.items():
normalized = str(key).strip()
if not normalized:
raise HTTPException(status_code=422, detail="Extra setting names cannot be blank")
if normalized in _EDITOR_FIELDS or normalized in {"api_key", "clear_api_key", "extra_settings"}:
raise HTTPException(status_code=422, detail=f"Extra setting '{normalized}' duplicates a managed field")
merged[normalized] = copy.deepcopy(value)
if payload.clear_api_key:
merged.pop("api_key", None)
elif payload.api_key is None and existing is not None and "api_key" in existing:
merged["api_key"] = existing["api_key"]
# Validate the raw schema without resolving `$ENV_VAR` values. Environment
# resolution happens during the post-write runtime reload and is rolled back
# if unavailable.
try:
ModelConfig.model_validate(merged)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid model configuration: {exc}") from exc
return merged
def _redact_text(value: str, secrets: list[str]) -> str:
result = value
for secret in secrets:
if secret:
result = result.replace(secret, "***")
return result[:800]
async def _require_admin(request: Request) -> None:
user = getattr(request.state, "user", None)
if user is None:
user = await get_optional_user_from_request(request)
if user is None:
raise HTTPException(status_code=401, detail="Authentication is required")
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="Model configuration requires an administrator")
async def _save_models(request: Request, raw_before: str, models: list[dict[str, Any]]) -> list[dict[str, Any]]:
path = _resolve_config_path()
raw_after = _replace_models_block(raw_before, models)
backup_path = await asyncio.to_thread(_backup_and_write_config, path, raw_before, raw_after)
try:
config = reload_app_config(str(path))
request.app.state.config = config
except Exception as exc:
logger.warning("Updated model configuration could not be activated: %s", type(exc).__name__)
await asyncio.to_thread(_restore_backup, path, backup_path)
try:
request.app.state.config = reload_app_config(str(path))
except Exception:
logger.exception("Failed to reload restored model config")
raise HTTPException(
status_code=422,
detail="The model configuration could not be activated; the previous config was restored.",
) from exc
return models
@router.get("", response_model=ModelAdminListResponse)
async def list_model_configurations(request: Request) -> ModelAdminListResponse:
await _require_admin(request)
path = _resolve_config_path()
_, _, models = await asyncio.to_thread(_read_config_data, path)
updated_at = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC).isoformat()
return ModelAdminListResponse(
models=[_to_admin_item(item) for item in models],
config_path=str(path),
updated_at=updated_at,
)
@router.post("", response_model=ModelAdminItem, status_code=201)
async def create_model_configuration(body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
await _require_admin(request)
async with _MUTATION_LOCK:
path = _resolve_config_path()
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
if any(str(item.get("name")) == body.name for item in models):
raise HTTPException(status_code=409, detail=f"A model named '{body.name}' already exists")
created = _request_to_raw_model(body)
await _save_models(request, raw_before, [*models, created])
return _to_admin_item(created)
@router.put("/{model_name:path}", response_model=ModelAdminItem)
async def update_model_configuration(model_name: str, body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
await _require_admin(request)
if body.name != model_name:
raise HTTPException(status_code=422, detail="A model's stable name cannot be changed; edit its display name instead")
async with _MUTATION_LOCK:
path = _resolve_config_path()
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
if index is None:
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
updated = _request_to_raw_model(body, models[index])
next_models = [*models]
next_models[index] = updated
await _save_models(request, raw_before, next_models)
return _to_admin_item(updated)
@router.delete("/{model_name:path}", status_code=204)
async def delete_model_configuration(model_name: str, request: Request) -> None:
"""Delete one model configuration and activate the remaining model list."""
await _require_admin(request)
async with _MUTATION_LOCK:
path = _resolve_config_path()
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
if index is None:
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
await _save_models(request, raw_before, [*models[:index], *models[index + 1 :]])
@router.post("/test", response_model=ModelTestResponse)
async def test_model_connection(body: ModelTestRequest, request: Request) -> ModelTestResponse:
"""Perform one bounded minimal completion without persisting the draft."""
await _require_admin(request)
path = _resolve_config_path()
_, _, models = await asyncio.to_thread(_read_config_data, path)
existing = next((item for item in models if str(item.get("name")) == body.model.name), None)
candidate = _request_to_raw_model(body.model, existing)
candidate_secret = str(candidate.get("api_key") or "")
secrets = [candidate_secret]
try:
runtime_candidate = AppConfig.resolve_env_variables(copy.deepcopy(candidate))
# An environment-variable API key is resolved only in memory for this
# request. Redact both forms because provider exceptions can include
# either the original `$NAME` reference or its resolved value.
resolved_secret = str(runtime_candidate.get("api_key") or "")
secrets = [candidate_secret, resolved_secret]
model_config = ModelConfig.model_validate(runtime_candidate)
base_config = request.app.state.config
test_config = base_config.model_copy(update={"models": [model_config]})
chat_model = create_chat_model(model_config.name, app_config=test_config, force_disable_thinking=True)
started = perf_counter()
response = await asyncio.wait_for(
chat_model.ainvoke([HumanMessage(content="Reply with exactly: OK")]),
timeout=min(float(candidate.get("request_timeout") or 30), 45),
)
latency_ms = round((perf_counter() - started) * 1000)
preview = _redact_text(str(getattr(response, "content", "OK")), secrets)
return ModelTestResponse(success=True, latency_ms=latency_ms, message="Connection successful", preview=preview)
except Exception as exc:
# Provider errors sometimes echo a credential. Never log the full
# exception or return it without redacting the submitted key.
logger.warning("Model connection test failed for '%s': %s", candidate.get("name"), type(exc).__name__)
return ModelTestResponse(
success=False,
latency_ms=0,
message=_redact_text(str(exc) or type(exc).__name__, secrets),
)
@@ -15,8 +15,8 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field, field_validator
from app.gateway.path_utils import aresolve_thread_virtual_path
from deerflow.runtime.user_context import get_effective_user_id
@@ -79,7 +79,10 @@ class NodeResponse(BaseModel):
class SessionResponse(BaseModel):
id: str
# Older personal rows can already contain a mock task id; task_scoped is
# the authoritative opt-in marker for cross-user task sharing.
external_task_id: str | None = None
task_scoped: bool = False
task_snapshot: dict[str, Any] = Field(default_factory=dict)
chain_id: str | None = None
chain_snapshot: dict[str, Any] | None = None
@@ -105,6 +108,14 @@ class SessionCreateRequest(BaseModel):
external_task_id: str | None = Field(default=None, max_length=128)
task_snapshot: dict[str, Any] = Field(default_factory=dict)
@field_validator("external_task_id")
@classmethod
def _normalize_external_task_id(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
return normalized or None
class TaskSnapshotUpdateRequest(BaseModel):
task_snapshot: dict[str, Any] = Field(default_factory=dict)
@@ -145,10 +156,33 @@ class RejectNodeRequest(BaseModel):
reason: str = Field(default="", max_length=5_000)
class ActionPlanDictionaryOption(BaseModel):
"""One task-system dictionary row, preserving dictValue as the model input."""
label: str = Field(default="", max_length=256)
value: str | int | float = ""
id: str | int | None = None
dict_type: str = Field(default="", max_length=128)
is_default: bool = False
class ActionPlanDictionaries(BaseModel):
"""The four action fields that must be selected from task-system dictionaries."""
publish_channels: list[ActionPlanDictionaryOption] = Field(default_factory=list)
action_types: list[ActionPlanDictionaryOption] = Field(default_factory=list)
info_formats: list[ActionPlanDictionaryOption] = Field(default_factory=list)
responsible_positions: list[ActionPlanDictionaryOption] = Field(default_factory=list)
class PrepareBuiltinRunRequest(BaseModel):
"""Optional follow-up instruction for one persisted built-in agent."""
message: str = Field(default="", max_length=20_000)
# 岗位会商会话允许本地演示使用非数字 taskId。真实任务系统新增行动
# 时由专属 Skill 再按外部接口契约校验正整数,不能在 prepare 阶段阻断报告生成。
task_id: str | int | None = Field(default=None, max_length=128)
action_dictionaries: ActionPlanDictionaries | None = None
class BuiltinRunPreparationResponse(BaseModel):
@@ -180,6 +214,25 @@ def _current_user_id(request: Request) -> str:
return str(user.id) if user is not None else get_effective_user_id()
def _task_snapshot_for_scope(
external_task_id: str | None,
task_snapshot: dict[str, Any],
*,
task_scoped: bool = True,
) -> dict[str, Any]:
"""Lock the snapshot id only for an explicit shared task scope."""
if not task_scoped or not external_task_id:
return dict(task_snapshot)
return {**task_snapshot, "id": external_task_id, "externalTaskId": external_task_id}
def _normalize_external_task_id(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
return normalized or None
def _get_store(request: Request):
store = getattr(request.app.state, "position_roundtable_store", None)
if store is None:
@@ -440,15 +493,64 @@ def _markdown_delivery_changed(
return bool(new_signatures - old_signatures)
def _intent_string_list(intent: dict[str, Any], key: str) -> list[str]:
"""Return a clean, display-safe string list from a persisted intent field."""
value = intent.get(key)
if not isinstance(value, list):
return []
return [item.strip() for item in value if isinstance(item, str) and item.strip()]
def _position_intent_sections(intent: Any) -> dict[str, list[str]]:
"""Read both the dedicated position-intent schema and legacy snapshots.
New position sessions persist the four position-specific fields. Mapping
the legacy Step-1 fields keeps already-created sessions executable after
this rollout instead of stranding them at the activation gate.
"""
if not isinstance(intent, dict):
return {
"coreGoals": [],
"riskWarnings": [],
"keyPoints": [],
"strategicSignificance": [],
}
has_position_schema = any(
key in intent
for key in ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
)
if has_position_schema:
return {
"coreGoals": _intent_string_list(intent, "coreGoals"),
"riskWarnings": _intent_string_list(intent, "riskWarnings"),
"keyPoints": _intent_string_list(intent, "keyPoints"),
"strategicSignificance": _intent_string_list(intent, "strategicSignificance"),
}
objective = _text_from_keys(intent, ["objective", "goal"])
return {
"coreGoals": _intent_string_list(intent, "constraints"),
"riskWarnings": [],
"keyPoints": _intent_string_list(intent, "assumptions"),
"strategicSignificance": [objective] if objective else [],
}
def _intent_node_answer(intent: Any) -> str:
if not isinstance(intent, dict):
return "情报分析岗已完成任务意图识别。"
objective = _text_from_keys(intent, ["objective", "goal"], "已完成任务意图识别")
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
lines = ["情报分析岗已完成任务意图识别。", f"任务目标:{objective}"]
if constraints:
lines.append("关键约束:")
lines.extend(f"- {item}" for item in constraints if isinstance(item, str) and item.strip())
sections = _position_intent_sections(intent)
lines = ["情报分析岗已完成任务意图识别。"]
labels = (
("核心目标", sections["coreGoals"]),
("风险提示", sections["riskWarnings"]),
("关键要点", sections["keyPoints"]),
("战略意义", sections["strategicSignificance"]),
)
for label, values in labels:
if values:
lines.append(f"{label}:")
lines.extend(f"- {item}" for item in values)
return "\n".join(lines)
@@ -456,6 +558,15 @@ def _has_confirmed_intent(intent: Any) -> bool:
"""Only structured Step-1 output may start a frozen business chain."""
if not isinstance(intent, dict):
return False
if any(
key in intent
for key in ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
):
required = ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
if not all(isinstance(intent.get(key), list) for key in required):
return False
sections = _position_intent_sections(intent)
return bool(sections["coreGoals"] or sections["strategicSignificance"])
objective = _text_from_keys(intent, ["objective", "goal"])
constraints = intent.get("constraints")
return bool(objective and isinstance(constraints, list))
@@ -505,8 +616,7 @@ def _build_node_context_text(
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
sections = _position_intent_sections(intent)
agent_name, position_id = _seat_meta(session, current)
md_required = not _is_intent_position_node(current, _session_intent_position_id(session))
lines = [
@@ -518,11 +628,16 @@ def _build_node_context_text(
f"- 任务描述:{description}",
"",
"【已识别意图】",
f"- 任务目标:{objective}",
]
if constraints:
lines.append("- 关键约束:")
lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
for label, values in (
("核心目标", sections["coreGoals"]),
("风险提示", sections["riskWarnings"]),
("关键要点", sections["keyPoints"]),
("战略意义", sections["strategicSignificance"]),
):
if values:
lines.append(f"- {label}:")
lines.extend(f" - {item}" for item in values)
lines.extend(
[
"",
@@ -968,6 +1083,8 @@ async def _build_builtin_prompt(
session: dict[str, Any],
nodes: list[dict[str, Any]],
follow_up: str = "",
action_task_id: str | int | None = None,
action_dictionaries: ActionPlanDictionaries | None = None,
) -> 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 {}
@@ -975,8 +1092,7 @@ async def _build_builtin_prompt(
title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
sections = _position_intent_sections(intent)
stage_goals = chain.get("stageGoals") if isinstance(chain.get("stageGoals"), list) else []
stage_lines = [f"- L-{index + 1}:{goal}" for index, goal in enumerate(stage_goals) if isinstance(goal, str) and goal.strip()]
material = await _effective_delivery_material(session, nodes)
@@ -990,11 +1106,16 @@ async def _build_builtin_prompt(
f"- 描述:{description}",
"",
"【情报分析岗已确认的意图】",
f"- 核心目标:{objective}",
]
if constraints:
common.append("- 关键约束:")
common.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
for label, values in (
("核心目标", sections["coreGoals"]),
("风险提示", sections["riskWarnings"]),
("关键要点", sections["keyPoints"]),
("战略意义", sections["strategicSignificance"]),
):
if values:
common.append(f"- {label}:")
common.extend(f" - {item}" for item in values)
if stage_lines:
common.extend(["", "【业务链阶段】", *stage_lines])
@@ -1007,13 +1128,50 @@ async def _build_builtin_prompt(
else:
summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
summary_content = _truncate_text(summary.get("content"), 12_000)
dictionary_groups = (
("发布渠道(publishChannel)", action_dictionaries.publish_channels if action_dictionaries else []),
("行动类型(actionType)", action_dictionaries.action_types if action_dictionaries else []),
("信息形式(infoFormat)", action_dictionaries.info_formats if action_dictionaries else []),
("责任岗位(responsiblePosition)", action_dictionaries.responsible_positions if action_dictionaries else []),
)
dictionaries_ready = bool(action_task_id is not None and all(options for _, options in dictionary_groups))
dictionary_lines = [
"【前端传入的行动字典(名称 → 必须原样使用的 dictValue)】",
f"- 当前 taskId:{action_task_id if action_task_id is not None else '(本地预览)'}",
]
for label, options in dictionary_groups:
dictionary_lines.append(f"- {label}:")
dictionary_lines.extend(
f" - {option.label} → {option.value}"
for option in options
)
dictionary_instruction = (
"actionType、publishChannel、infoFormat、responsiblePosition 必须从下方同名字典复制 dictValue 并写成整数;"
"taskId 必须原样写为当前 taskId。不要保留 id、title、goal、basis、owner_position、dependencies 等旧字段,"
"也不要用 0 或空值代替未选择的字典项。"
if dictionaries_ready
else "当前四类任务系统字典未获取完整,这是本地预览/接口未就绪状态。仍需生成 9 字段 JSON,"
"但四个枚举字段可暂填 0 占位,且不得调用 position-roundtable-action-create Skill 或外部任务系统。"
)
submit_instruction = (
"若当前 taskId 是正整数,首次生成完整行动清单后按行动规划智能体的 SOUL 调用 position-roundtable-action-create Skill 批量新增行动;"
"若 taskId 为本地演示标识或为空,只生成报告和 JSON,明确说明未调用任务系统。Skill 返回失败时如实报告,不能伪称已入库。"
if dictionaries_ready
else "本轮字典不完整,只生成报告和 JSON 预览;不得调用 Skill、不得请求外部任务系统,也不能宣称行动已入库。"
)
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。",
"Markdown 报告需给出分阶段推进建议、优先级、风险控制与验收安排。"
"JSON 必须是严格合法数组;每个对象只能包含 actionName、actionDescription、actionType、"
"publishChannel、infoFormat、responsiblePosition、startTime、endTime、taskId 这 9 个字段。",
dictionary_instruction,
submit_instruction,
"",
"【已生效方案总结】",
summary_content or "(方案总结正文未写入快照,请依据下方有效岗位交付重新归纳。)",
"",
*dictionary_lines,
]
if follow_up.strip():
instructions.extend(["", "【用户本次补充/修改要求】", follow_up.strip()])
@@ -1082,9 +1240,13 @@ async def seed_demo_session(
@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 = Query(default=None, max_length=128),
) -> list[SessionResponse]:
task_id = _normalize_external_task_id(external_task_id)
rows = await _get_store(request).list_sessions(
_current_user_id(request), external_task_id=external_task_id
_current_user_id(request), external_task_id=task_id
)
return [_project_session(row) for row in rows]
@@ -1094,7 +1256,12 @@ async def create_session(request: Request, body: SessionCreateRequest) -> Sessio
row = await _get_store(request).create_session(
_current_user_id(request),
external_task_id=body.external_task_id,
task_snapshot=body.task_snapshot,
task_snapshot=_task_snapshot_for_scope(
body.external_task_id,
body.task_snapshot,
task_scoped=bool(body.external_task_id),
),
task_scoped=bool(body.external_task_id),
)
return _project_session(row)
@@ -1108,8 +1275,16 @@ async def get_session(request: Request, session_id: str) -> SessionDetailRespons
async def update_task_snapshot(
request: Request, session_id: str, body: TaskSnapshotUpdateRequest
) -> SessionResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
row = await store.update_session(session_id, user_id, task_snapshot=body.task_snapshot)
store, user_id, current = await _require_writable_session(request, session_id)
row = await store.update_session(
session_id,
user_id,
task_snapshot=_task_snapshot_for_scope(
current.get("external_task_id"),
body.task_snapshot,
task_scoped=bool(current.get("task_scoped")),
),
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
@@ -1436,6 +1611,8 @@ async def _prepare_builtin_run(
*,
kind: str,
follow_up: str = "",
action_task_id: str | int | None = None,
action_dictionaries: ActionPlanDictionaries | None = None,
) -> BuiltinRunPreparationResponse:
store, user_id, session = await _require_writable_session(request, session_id)
nodes = await store.list_nodes(session_id, user_id)
@@ -1469,6 +1646,8 @@ async def _prepare_builtin_run(
session=session,
nodes=nodes,
follow_up=follow_up,
action_task_id=action_task_id,
action_dictionaries=action_dictionaries,
),
source_revisions=_source_revisions(nodes),
)
@@ -1576,14 +1755,28 @@ async def complete_summary(
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)
return await _prepare_builtin_run(
request,
session_id,
kind="action_plan",
follow_up=body.message,
action_task_id=body.task_id,
action_dictionaries=body.action_dictionaries,
)
@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)
return await _prepare_builtin_run(
request,
session_id,
kind="action_plan",
follow_up=body.message,
action_task_id=body.task_id,
action_dictionaries=body.action_dictionaries,
)
@router.post("/sessions/{session_id}/action-plan/complete", response_model=SessionDetailResponse)
@@ -0,0 +1,196 @@
"""Task-scoped submission status for roundtable artifacts.
This API records only an artifact reference (thread + sandbox path) and its
submission state. It intentionally does not fetch task detail, copy Markdown,
or deliver a file to an external task system.
Routes (authenticated by the Gateway middleware):
GET /api/roundtable/tasks/{task_id}/artifacts?submitted=true
PUT /api/roundtable/tasks/{task_id}/artifacts/submission
"""
from __future__ import annotations
from pathlib import PurePosixPath
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator
from app.gateway.deps import get_current_user
from deerflow.persistence.roundtable_artifact_submissions import RoundtableArtifactSubmissionStore
router = APIRouter(prefix="/api/roundtable/tasks", tags=["roundtable-artifact-submissions"])
class ArtifactReferenceInput(BaseModel):
"""Reference to a generated file; the file content remains in its thread sandbox."""
model_config = ConfigDict(populate_by_name=True)
session_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("sessionId", "session_id"))
thread_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("threadId", "thread_id"))
node_key: StrictStr | None = Field(default=None, max_length=256, validation_alias=AliasChoices("nodeKey", "node_key"))
source_key: StrictStr = Field(..., min_length=1, max_length=512, validation_alias=AliasChoices("sourceKey", "source_key"))
path: StrictStr = Field(..., min_length=1, max_length=2048)
name: StrictStr | None = Field(default=None, max_length=512)
mime_type: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("mimeType", "mime_type"))
revision: StrictInt = Field(..., ge=0)
@field_validator("session_id", "thread_id", "node_key", "source_key", "path", "name", "mime_type", mode="before")
@classmethod
def _trim_text(cls, value: Any) -> Any:
if isinstance(value, str):
value = value.strip()
return value or None
return value
class ArtifactSubmissionRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
task_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("taskId", "task_id"))
submitted: bool
submission_id: StrictStr | None = Field(default=None, max_length=64, validation_alias=AliasChoices("submissionId", "submission_id"))
task_delivery_id: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("taskDeliveryId", "task_delivery_id"))
artifact: ArtifactReferenceInput
@field_validator("task_id", "submission_id", "task_delivery_id", mode="before")
@classmethod
def _trim_optional_text(cls, value: Any) -> Any:
if isinstance(value, str):
value = value.strip()
return value or None
return value
class ArtifactReferenceResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
session_id: str | None = Field(default=None, serialization_alias="sessionId")
thread_id: str | None = Field(default=None, serialization_alias="threadId")
node_key: str | None = Field(default=None, serialization_alias="nodeKey")
source_key: str = Field(serialization_alias="sourceKey")
path: str
name: str | None = None
mime_type: str | None = Field(default=None, serialization_alias="mimeType")
revision: int
class ArtifactSubmissionResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str
task_id: str = Field(serialization_alias="taskId")
submitted: bool
submitted_at: str | None = Field(default=None, serialization_alias="submittedAt")
cancelled_at: str | None = Field(default=None, serialization_alias="cancelledAt")
task_delivery_id: str | None = Field(default=None, serialization_alias="taskDeliveryId")
artifact: ArtifactReferenceResponse
class ArtifactSubmissionListResponse(BaseModel):
items: list[ArtifactSubmissionResponse]
def _get_store(request: Request) -> RoundtableArtifactSubmissionStore:
store = getattr(request.app.state, "roundtable_artifact_submission_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable artifact submission store not available")
return store
def _task_id_or_422(value: str) -> str:
task_id = value.strip()
if not task_id:
raise HTTPException(status_code=422, detail="task id is required")
if len(task_id) > 128:
raise HTTPException(status_code=422, detail="task id must be at most 128 characters")
return task_id
def _artifact_payload(artifact: ArtifactReferenceInput) -> dict[str, Any]:
source_key = artifact.source_key
path = artifact.path
if not source_key or not path:
raise HTTPException(status_code=422, detail="artifact.sourceKey and artifact.path are required")
name = artifact.name or PurePosixPath(path).name or path
return {
"session_id": artifact.session_id,
"thread_id": artifact.thread_id,
"node_key": artifact.node_key,
"source_key": source_key,
"path": path,
"name": name,
"mime_type": artifact.mime_type,
"revision": artifact.revision,
}
def _response(row: dict[str, Any]) -> ArtifactSubmissionResponse:
return ArtifactSubmissionResponse(
id=str(row["id"]),
task_id=str(row["task_id"]),
submitted=bool(row["submitted"]),
submitted_at=row.get("submitted_at"),
cancelled_at=row.get("cancelled_at"),
task_delivery_id=row.get("task_delivery_id"),
artifact=ArtifactReferenceResponse(
session_id=row.get("session_id"),
thread_id=row.get("thread_id"),
node_key=row.get("node_key"),
source_key=str(row["source_key"]),
path=str(row["path"]),
name=row.get("name"),
mime_type=row.get("mime_type"),
revision=int(row["revision"]),
),
)
@router.get("/{task_id}/artifacts", response_model=ArtifactSubmissionListResponse)
async def list_artifact_submissions(
request: Request,
task_id: str,
submitted: bool | None = Query(default=True),
) -> ArtifactSubmissionListResponse:
"""List task artifacts by submission state (defaults to submitted only)."""
rows = await _get_store(request).list_by_task(_task_id_or_422(task_id), submitted=submitted)
return ArtifactSubmissionListResponse(items=[_response(row) for row in rows])
@router.put("/{task_id}/artifacts/submission", response_model=ArtifactSubmissionResponse)
async def set_artifact_submission(
request: Request,
task_id: str,
body: ArtifactSubmissionRequest,
) -> ArtifactSubmissionResponse:
"""Create/update a submission or retain an audit row while cancelling it."""
path_task_id = _task_id_or_422(task_id)
if body.task_id is not None and body.task_id != path_task_id:
raise HTTPException(status_code=422, detail="taskId must match the path task id")
artifact = _artifact_payload(body.artifact)
actor_id = await get_current_user(request)
store = _get_store(request)
if body.submitted:
# The frontend previews submitted artifacts by threadId + path. Without
# a thread reference a saved state could never be previewed correctly.
if not artifact["thread_id"]:
raise HTTPException(status_code=422, detail="artifact.threadId is required when submitted is true")
row = await store.submit(
path_task_id,
artifact,
task_delivery_id=body.task_delivery_id,
actor_id=actor_id,
)
else:
row = await store.cancel(
path_task_id,
artifact,
submission_id=body.submission_id,
actor_id=actor_id,
)
if row is None:
raise HTTPException(status_code=404, detail="Artifact submission not found")
return _response(row)
@@ -58,6 +58,10 @@ class ChainSeatSchema(BaseModel):
# 与该席位 agent 自身 config.yaml 的同名开关取 OR。默认 False(关)。整条 seats 数组以
# JSON 透传持久化,故新增此字段无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。
seat_skill_directive: bool = False
# Per-seat human checkpoint. Step 2 pauses after this seat (or its DAG
# stage) completes and reuses the existing resume/intervention controls.
# It is stored in the existing seats JSON, so no database migration is needed.
human_validation: bool = False
# 该席位「推理深度覆盖」(flash/thinking/pro/ultra):覆盖链条级 seat_mode,仅本席位生效。
# None = 跟随链条级默认。「卡顿提速」用:把次要席位降到 flash。整条 seats 数组以 JSON 透传
# 持久化,故无需迁移;仅需在此 schema 显式声明,否则 model_dump() 会剥掉它。非法值在运行期
@@ -1622,6 +1622,102 @@ async def delete_custom_skill(
raise HTTPException(status_code=500, detail=f"Failed to delete custom skill: {str(e)}")
def _remove_skill_state_config(skill_name: str) -> None:
"""Remove a deleted skill's enabled/display override from extensions config."""
extensions_config = get_extensions_config()
if skill_name not in extensions_config.skills:
return
config_path = ExtensionsConfig.resolve_config_path()
if config_path is None:
config_path = Path.cwd().parent / "extensions_config.json"
extensions_config.skills.pop(skill_name, None)
config_data = _dump_extensions_config(extensions_config)
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
delete=False,
dir=str(config_path.parent),
) as tmp_file:
json.dump(config_data, tmp_file, indent=2)
tmp_path = Path(tmp_file.name)
os.replace(tmp_path, config_path)
finally:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
reload_extensions_config()
@router.delete(
"/skills/{skill_name}",
summary="Delete Built-in Skill",
description=(
"Administrators may permanently delete a built-in skill. The skill is "
"removed from enabled configuration and detached from agents that used it."
),
)
async def delete_builtin_skill(
skill_name: str,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
agent_store=Depends(get_agent_store),
) -> dict[str, bool]:
try:
if not _is_admin_user(request):
raise HTTPException(status_code=403, detail="Only administrators can delete built-in skills")
skill_name = _normalize_skill_name_param(skill_name)
skill = _load_skill_or_404(skill_name, config)
if skill.category != SkillCategory.PUBLIC:
raise HTTPException(status_code=400, detail="Use the custom skill deletion endpoint for a custom skill")
storage = get_or_new_skill_storage(app_config=config)
public_root = (storage.get_skills_root_path() / SkillCategory.PUBLIC.value).resolve()
skill_dir = skill.skill_file.parent.resolve()
if skill_dir == public_root or public_root not in skill_dir.parents:
raise ValueError(f"Refusing to delete built-in skill outside the public skills directory: {skill.name}")
_remove_skill_state_config(skill.name)
shutil.rmtree(skill_dir)
try:
from deerflow.skills.usage import forget
forget(skill.name)
except Exception:
pass
await skill_store.delete_admin(skill.name)
try:
await get_tag_store(request).unassign_all("skill", skill.name)
except Exception: # noqa: BLE001 -- tag cleanup must not block deletion
pass
await cascade_skill_unpublished_or_deleted(
skill.name,
actor_user_id=_current_user_id(request),
operation="delete",
skill_owner_user_id=None,
agent_store=agent_store,
)
await refresh_skills_system_prompt_cache_async()
logger.info("Administrator deleted built-in skill %s", skill.name)
return {"success": True}
except HTTPException:
raise
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to delete built-in skill %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete built-in skill: {str(e)}")
class SkillPublishRequest(BaseModel):
"""Request body for toggling a custom skill's published flag."""
@@ -2080,11 +2176,16 @@ def _caller_can_edit_skill_files(
record: dict[str, Any] | None,
request: Request,
) -> bool:
"""Editing is limited to custom skills, and to either owner or admin."""
if skill.category != SkillCategory.CUSTOM:
return False
"""Whether the caller can edit or delete a file within *skill*.
Built-in skills are deployment-owned, so only administrators may change
their files. Custom skills remain editable by their owner or by an
administrator.
"""
if _is_admin_user(request):
return True
if skill.category != SkillCategory.CUSTOM:
return False
if record is None:
return False
return record.get("owner_user_id") == _current_user_id(request)
@@ -2125,6 +2226,24 @@ def _is_text_file(path: Path) -> bool:
return path.suffix.lower() in _SKILL_FILE_TEXT_EXTS
def _write_skill_text_file_atomically(target: Path, content: str) -> None:
"""Replace an already-validated skill file without exposing partial text."""
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
delete=False,
dir=str(target.parent),
) as tmp_file:
tmp_file.write(content)
tmp_path = Path(tmp_file.name)
os.replace(tmp_path, target)
finally:
if tmp_path is not None and tmp_path.exists():
tmp_path.unlink()
def _build_skill_file_tree(skill_dir: Path) -> SkillFileNode:
"""Walk *skill_dir* and build a SkillFileNode tree, depth/count capped.
@@ -2302,9 +2421,9 @@ async def download_skill_file(
response_model=SkillFileSaveResponse,
summary="Write Skill File (Text)",
description=(
"Replace the contents of a text file under a custom skill. Only the "
"skill's owner or an admin may write; built-in (public) skills are "
"read-only. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
"Replace the contents of a text file under a skill. Administrators may "
"write built-in and custom skills; custom-skill owners may write their "
"own skills. Edits to ``SKILL.md`` re-validate its YAML frontmatter and "
"refresh the system-prompt cache."
),
)
@@ -2318,11 +2437,9 @@ async def save_skill_file(
try:
skill_name = _normalize_skill_name_param(skill_name)
skill = _load_skill_or_404(skill_name, config)
if skill.category != SkillCategory.CUSTOM:
raise HTTPException(status_code=403, detail="Built-in skills are read-only")
record = await skill_store.get_any(skill.name)
if not _caller_can_edit_skill_files(skill, record, request):
raise HTTPException(status_code=403, detail="Only the skill owner or an admin can edit files in this skill")
raise HTTPException(status_code=403, detail="Only an administrator or the custom skill owner can edit files in this skill")
target = _resolve_skill_relative_path(skill, body.path)
if not target.exists():
@@ -2338,7 +2455,7 @@ async def save_skill_file(
relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
storage = get_or_new_skill_storage(app_config=config)
is_skill_md = relative_posix == SKILL_MD_FILE
is_skill_md = relative_posix.casefold() == SKILL_MD_FILE.casefold()
prev_content: str | None = None
if target.exists():
try:
@@ -2350,20 +2467,21 @@ async def save_skill_file(
# SKILL.md edits must keep their frontmatter valid (name must match).
storage.validate_skill_markdown_content(skill.name, body.content)
storage.write_custom_skill(skill.name, relative_posix, body.content)
_write_skill_text_file_atomically(target, body.content)
try:
storage.append_history(
skill.name,
{
"action": "human_edit_file",
"author": "human",
"thread_id": None,
"file_path": relative_posix,
"prev_content": prev_content,
"new_content": body.content,
},
)
if skill.category == SkillCategory.CUSTOM:
storage.append_history(
skill.name,
{
"action": "human_edit_file",
"author": "human",
"thread_id": None,
"file_path": relative_posix,
"prev_content": prev_content,
"new_content": body.content,
},
)
except Exception as history_err: # noqa: BLE001 — history is best-effort
logger.warning("Failed to append edit history for %s/%s: %s", skill.name, relative_posix, history_err)
@@ -2393,6 +2511,75 @@ async def save_skill_file(
raise HTTPException(status_code=500, detail=f"Failed to save skill file: {e}")
@router.delete(
"/skills/{skill_name}/files/content",
status_code=204,
summary="Delete Skill File",
description=(
"Delete one non-SKILL.md file under a skill. Administrators may delete "
"files from built-in and custom skills; custom-skill owners may delete "
"files from their own skills. The root ``SKILL.md`` manifest cannot be "
"deleted because it defines the skill."
),
)
async def delete_skill_file(
skill_name: str,
request: Request,
path: str = Query(..., description="File path relative to the skill directory"),
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> None:
try:
skill_name = _normalize_skill_name_param(skill_name)
skill = _load_skill_or_404(skill_name, config)
record = await skill_store.get_any(skill.name)
if not _caller_can_edit_skill_files(skill, record, request):
raise HTTPException(status_code=403, detail="Only an administrator or the custom skill owner can delete files in this skill")
target = _resolve_skill_relative_path(skill, path)
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail=f"File '{path}' not found in skill '{skill_name}'")
relative_posix = target.relative_to(skill.skill_file.parent.resolve()).as_posix()
if relative_posix.casefold() == SKILL_MD_FILE.casefold():
raise HTTPException(status_code=400, detail="SKILL.md defines the skill and cannot be deleted")
prev_content: str | None = None
if _is_text_file(target):
try:
prev_content = target.read_text(encoding="utf-8")
except UnicodeDecodeError:
pass
target.unlink()
if skill.category == SkillCategory.CUSTOM:
storage = get_or_new_skill_storage(app_config=config)
try:
storage.append_history(
skill.name,
{
"action": "human_delete_file",
"author": "human",
"thread_id": None,
"file_path": relative_posix,
"prev_content": prev_content,
},
)
except Exception as history_err: # noqa: BLE001
logger.warning("Failed to append deletion history for %s/%s: %s", skill.name, relative_posix, history_err)
else:
logger.info("Administrator deleted built-in skill file %s/%s", skill.name, relative_posix)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to delete skill file %s/%s: %s", skill_name, path, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete skill file: {e}")
@router.put(
"/skills/{skill_name}",
response_model=SkillResponse,
+58 -70
View File
@@ -33,32 +33,64 @@ concurrency:
fail_open: true
models:
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: $DEEPSEEK_CHAT_API_KEY
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: zai-org/GLM-5-FP8
api_key: $GLM5_FP8_API_KEY
base_url: https://api.us-west-2.modal.direct/v1
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: $DEEPSEEK_CHAT_API_KEY
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: zai-org/GLM-5-FP8
api_key: $GLM5_FP8_API_KEY
base_url: https://api.us-west-2.modal.direct/v2
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
supports_reasoning_effort: false
- name: deepseek-ai/DeepSeek-V4-Flash
display_name: deepseek-ai/DeepSeek-V4-Flash
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Flash
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
when_thinking_disabled:
extra_body:
enable_thinking: false
- name: inclusionAI/Ling-flash-2.0
display_name: inclusionAI/Ling-flash-2.0
use: langchain_openai:ChatOpenAI
model: inclusionAI/Ling-flash-2.0
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Pro
display_name: deepseek-ai/DeepSeek-V4-Pro
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Pro
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# - name: deepseek-v4-flash
# display_name: DeepSeek V4 Flash
# use: langchain_openai:ChatOpenAI
@@ -71,7 +103,6 @@ models:
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
# - name: deepseek-v4-pro
# display_name: DeepSeek V4 Pro
# use: langchain_openai:ChatOpenAI
@@ -136,52 +167,9 @@ models:
# temperature: 1.0
# supports_vision: true
# supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Flash
display_name: deepseek-ai/DeepSeek-V4-Flash
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Flash
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# SiliconFlow 混合推理模型关思考用「请求体顶层 enable_thinking: false」,
# 不认 chat_template_kwargs / thinking.type 那两种嵌套形状。声明这个后,
# 网页端「关闭思考」对本模型才生效(开放接口另由 force_disable_thinking 兜底)。
when_thinking_disabled:
extra_body:
enable_thinking: false
- name: Pro/MiniMaxAI/MiniMax-M2.5
display_name: Pro/MiniMaxAI/MiniMax-M2.5
use: langchain_openai:ChatOpenAI
model: Pro/MiniMaxAI/MiniMax-M2.5
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: inclusionAI/Ling-flash-2.0
display_name: inclusionAI/Ling-flash-2.0
use: langchain_openai:ChatOpenAI
model: inclusionAI/Ling-flash-2.0
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-ai/DeepSeek-V4-Pro
display_name: deepseek-ai/DeepSeek-V4-Pro
use: langchain_openai:ChatOpenAI
model: deepseek-ai/DeepSeek-V4-Pro
api_key: $SILICONFLOW_API_KEY
base_url: https://api.siliconflow.cn/v1/
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
# 识图(图片识别)配置。
# - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
@@ -0,0 +1,69 @@
"""Index optional task-scoped position-roundtable history.
Revision ID: 20260808_01
Revises: 20260729_01
Create Date: 2026-08-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260808_01"
down_revision = "20260729_01"
branch_labels = None
depends_on = None
_TABLE = "position_roundtable_sessions"
_INDEX = "ix_position_roundtable_sessions_task_updated"
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 any(
column["name"] == column_name
for column in inspect(op.get_bind()).get_columns(table_name)
)
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
return index_name in {
index["name"] for index in inspect(op.get_bind()).get_indexes(table_name)
}
def upgrade() -> None:
if not _has_table(_TABLE):
return
# Historical records must stay in the original personal mode. New records
# set this flag only when a route taskId explicitly requests sharing.
if not _has_column(_TABLE, "task_scoped"):
op.add_column(
_TABLE,
sa.Column(
"task_scoped",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
if not _has_index(_TABLE, _INDEX):
op.create_index(_INDEX, _TABLE, ["external_task_id", "updated_at"])
def downgrade() -> None:
if not _has_table(_TABLE):
return
if _has_index(_TABLE, _INDEX):
op.drop_index(_INDEX, table_name=_TABLE)
if _has_column(_TABLE, "task_scoped"):
op.drop_column(_TABLE, "task_scoped")
@@ -57,6 +57,7 @@ from deerflow.persistence.positions.model import PositionRow, PositionTagBinding
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.research_templates.model import ResearchTemplateRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
from deerflow.persistence.roundtable_drafts.model import (
@@ -121,6 +122,7 @@ __all__ = [
"RecommendedQuestionRow",
"ResearchTemplateRow",
"RoundtableChainRow",
"RoundtableArtifactSubmissionRow",
"RoundtableDraftRow",
"RoundtableDraftShareRow",
"RoundtableDiagnosticRow",
@@ -12,7 +12,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -21,12 +21,23 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
class PositionRoundtableSessionRow(Base):
__tablename__ = "position_roundtable_sessions"
__table_args__ = (
# History is read as "all conversations for one task, newest first".
# `user_id` remains audit data only; it must not partition a shared
# task workspace.
Index(
"ix_position_roundtable_sessions_task_updated",
"external_task_id",
"updated_at",
),
)
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.
# Creator audit field. `task_scoped`, not merely external_task_id, opts a
# session into sharing. This keeps historical sidebar/mock rows private.
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_scoped: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
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)
@@ -46,11 +46,17 @@ class PositionRoundtableRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _can_access(row: PositionRoundtableSessionRow, user_id: str) -> bool:
"""Task-bound sessions are shared; unbound legacy sessions stay private."""
return bool(row.task_scoped) or row.user_id == user_id
@staticmethod
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
return {
"id": row.id,
"external_task_id": row.external_task_id,
"task_scoped": bool(row.task_scoped),
"task_snapshot": _loads(row.task_snapshot) or {},
"chain_id": row.chain_id,
"chain_snapshot": _loads(row.chain_snapshot),
@@ -98,12 +104,14 @@ class PositionRoundtableRepository:
*,
external_task_id: str | None,
task_snapshot: dict[str, Any] | None,
task_scoped: bool | None = 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_scoped=bool(external_task_id) if task_scoped is None else task_scoped,
task_snapshot=_dumps(task_snapshot or {}),
status="intent_pending",
created_at=now,
@@ -165,7 +173,8 @@ class PositionRoundtableRepository:
row = PositionRoundtableSessionRow(
id=session_id,
user_id=user_id,
external_task_id=session_payload.get("external_task_id") or None,
external_task_id=str(session_payload.get("external_task_id") or "").strip() or None,
task_scoped=False,
task_snapshot=_dumps(session_payload.get("task_snapshot") or {}),
chain_id=session_payload.get("chain_id") or None,
chain_snapshot=_dumps(session_payload.get("chain_snapshot")),
@@ -246,9 +255,17 @@ class PositionRoundtableRepository:
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)
"""List shared task history or the caller's legacy personal history."""
if external_task_id:
stmt = stmt.where(PositionRoundtableSessionRow.external_task_id == external_task_id)
stmt = select(PositionRoundtableSessionRow).where(
PositionRoundtableSessionRow.external_task_id == external_task_id,
PositionRoundtableSessionRow.task_scoped.is_(True),
)
else:
stmt = select(PositionRoundtableSessionRow).where(
PositionRoundtableSessionRow.user_id == user_id,
PositionRoundtableSessionRow.task_scoped.is_(False),
)
stmt = stmt.order_by(PositionRoundtableSessionRow.updated_at.desc()).limit(limit)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
@@ -257,7 +274,7 @@ class PositionRoundtableRepository:
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:
if row is None or not self._can_access(row, user_id):
return None
return self._session_dict(row)
@@ -280,7 +297,7 @@ class PositionRoundtableRepository:
) -> 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:
if row is None or not self._can_access(row, user_id):
return None
if task_snapshot is not _UNSET:
row.task_snapshot = _dumps(task_snapshot)
@@ -326,7 +343,7 @@ class PositionRoundtableRepository:
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:
if row is None or not self._can_access(row, user_id):
return None
node_rows = (
await session.execute(
@@ -371,7 +388,7 @@ class PositionRoundtableRepository:
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:
if parent is None or not self._can_access(parent, user_id):
return None
rows = (
await session.execute(
@@ -384,16 +401,13 @@ class PositionRoundtableRepository:
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)
)
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or not self._can_access(parent, user_id):
return None
stmt = select(PositionRoundtableNodeRow).where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
).limit(1)
row = (await session.execute(stmt)).scalars().first()
return self._node_dict(row) if row is not None else None
@@ -415,16 +429,13 @@ class PositionRoundtableRepository:
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)
)
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or not self._can_access(parent, user_id):
return None
stmt = select(PositionRoundtableNodeRow).where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
).limit(1)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
@@ -472,7 +483,7 @@ class PositionRoundtableRepository:
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:
if parent is None or not self._can_access(parent, user_id):
return None
rows = (
await session.execute(
@@ -538,19 +549,18 @@ class PositionRoundtableRepository:
return await self.update_session(session_id, user_id, status="archived")
async def delete_session(self, session_id: str, user_id: str) -> bool:
"""Delete one user-owned history record and all of its node snapshots."""
"""Delete a task-shared row or the caller's personal legacy row."""
async with self._sf() as session:
parent = (
await session.execute(
select(PositionRoundtableSessionRow)
.where(
PositionRoundtableSessionRow.id == session_id,
PositionRoundtableSessionRow.user_id == user_id,
)
.limit(1)
)
).scalar_one_or_none()
if parent is None:
if parent is None or not self._can_access(parent, user_id):
return False
# Do not rely on database-specific FK cascade settings. SQLite
@@ -564,7 +574,6 @@ class PositionRoundtableRepository:
await session.execute(
delete(PositionRoundtableSessionRow).where(
PositionRoundtableSessionRow.id == session_id,
PositionRoundtableSessionRow.user_id == user_id,
)
)
await session.commit()
@@ -0,0 +1,22 @@
"""Task-scoped roundtable artifact submission status persistence."""
from __future__ import annotations
from deerflow.persistence.roundtable_artifact_submissions.base import RoundtableArtifactSubmissionStore
from deerflow.persistence.roundtable_artifact_submissions.memory import MemoryRoundtableArtifactSubmissionStore
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
from deerflow.persistence.roundtable_artifact_submissions.sql import RoundtableArtifactSubmissionRepository
__all__ = [
"MemoryRoundtableArtifactSubmissionStore",
"RoundtableArtifactSubmissionRepository",
"RoundtableArtifactSubmissionRow",
"RoundtableArtifactSubmissionStore",
"make_roundtable_artifact_submission_store",
]
def make_roundtable_artifact_submission_store(session_factory):
if session_factory is None:
return MemoryRoundtableArtifactSubmissionStore()
return RoundtableArtifactSubmissionRepository(session_factory)
@@ -0,0 +1,44 @@
"""Persistence contract for task-scoped roundtable artifact submissions.
The store deliberately keeps only a stable reference to an artifact in a
DeerFlow thread sandbox. It does not copy the artifact body and never calls a
downstream task-delivery service.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class RoundtableArtifactSubmissionStore(ABC):
"""Shared submission-status store, partitioned only by task id."""
@abstractmethod
async def list_by_task(self, task_id: str, *, submitted: bool | None = True) -> list[dict[str, Any]]:
"""Return artifact submission records for one task, newest first."""
raise NotImplementedError
@abstractmethod
async def submit(
self,
task_id: str,
artifact: dict[str, Any],
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any]:
"""Create or update the idempotent submission record for an artifact."""
raise NotImplementedError
@abstractmethod
async def cancel(
self,
task_id: str,
artifact: dict[str, Any],
*,
submission_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any] | None:
"""Mark a submission as cancelled without deleting its audit record."""
raise NotImplementedError
@@ -0,0 +1,127 @@
"""In-memory roundtable artifact-submission store."""
from __future__ import annotations
from copy import deepcopy
from datetime import UTC, datetime
from hashlib import sha256
from typing import Any
from uuid import uuid4
from deerflow.persistence.roundtable_artifact_submissions.base import RoundtableArtifactSubmissionStore
from deerflow.persistence.types import BEIJING_TZ
def submission_key(task_id: str, artifact: dict[str, Any]) -> str:
"""Return the canonical server-owned idempotency key for an artifact."""
source_key = str(artifact["source_key"])
path = str(artifact["path"])
revision = int(artifact["revision"])
raw = "\x1f".join((task_id, source_key, path, str(revision)))
return sha256(raw.encode("utf-8")).hexdigest()
def _now() -> str:
return datetime.now(UTC).astimezone(BEIJING_TZ).isoformat()
def _artifact_values(artifact: dict[str, Any]) -> dict[str, Any]:
return {
"session_id": artifact.get("session_id"),
"thread_id": artifact.get("thread_id"),
"node_key": artifact.get("node_key"),
"source_key": artifact["source_key"],
"path": artifact["path"],
"name": artifact.get("name"),
"mime_type": artifact.get("mime_type"),
"revision": int(artifact["revision"]),
}
class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore):
def __init__(self) -> None:
self._rows: dict[tuple[str, str], dict[str, Any]] = {}
async def list_by_task(self, task_id: str, *, submitted: bool | None = True) -> list[dict[str, Any]]:
rows = [row for row in self._rows.values() if row["task_id"] == task_id and (submitted is None or row["submitted"] is submitted)]
rows.sort(key=lambda row: row["updated_at"], reverse=True)
return deepcopy(rows)
async def submit(
self,
task_id: str,
artifact: dict[str, Any],
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any]:
key = submission_key(task_id, artifact)
row = self._rows.get((task_id, key))
values = _artifact_values(artifact)
if row is None:
now = _now()
row = {
"id": uuid4().hex,
"task_id": task_id,
"idempotency_key": key,
**values,
"task_delivery_id": task_delivery_id,
"submitted": True,
"submitted_at": now,
"cancelled_at": None,
"created_by": actor_id,
"updated_by": actor_id,
"submitted_by": actor_id,
"cancelled_by": None,
"created_at": now,
"updated_at": now,
}
self._rows[(task_id, key)] = row
return deepcopy(row)
changed = (
not row["submitted"]
or row.get("task_delivery_id") != task_delivery_id
or any(row.get(name) != value for name, value in values.items())
)
if changed:
now = _now()
row.update(values)
row.update(
{
"task_delivery_id": task_delivery_id,
"submitted": True,
"submitted_at": now,
"cancelled_at": None,
"updated_by": actor_id,
"submitted_by": actor_id,
"cancelled_by": None,
"updated_at": now,
}
)
return deepcopy(row)
async def cancel(
self,
task_id: str,
artifact: dict[str, Any],
*,
submission_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any] | None:
key = submission_key(task_id, artifact)
row = self._rows.get((task_id, key))
if row is None or (submission_id is not None and row["id"] != submission_id):
return None
if row["submitted"]:
now = _now()
row.update(
{
"submitted": False,
"cancelled_at": now,
"updated_by": actor_id,
"cancelled_by": actor_id,
"updated_at": now,
}
)
return deepcopy(row)
@@ -0,0 +1,56 @@
"""ORM model for task-scoped roundtable artifact submission status."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class RoundtableArtifactSubmissionRow(Base):
"""One auditable submission state for one task artifact revision.
The natural idempotency key is a SHA-256 digest of
``task_id + source_key + artifact_path + revision``. Keeping the digest
short avoids a MySQL unique-index limit while the component columns remain
available for diagnostics and response shaping.
"""
__tablename__ = "roundtable_artifact_submissions"
__table_args__ = (
UniqueConstraint("task_id", "idempotency_key", name="uq_roundtable_artifact_submission_task_key"),
Index("ix_roundtable_artifact_submission_task_state_updated", "task_id", "submitted", "updated_at"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
task_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(64), nullable=False)
source_key: Mapped[str] = mapped_column(String(512), nullable=False)
artifact_path: Mapped[str] = mapped_column(String(2048), nullable=False)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
session_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
node_key: Mapped[str | None] = mapped_column(String(256), nullable=True)
artifact_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
task_delivery_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
submitted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
submitted_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
submitted_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
cancelled_by: Mapped[str | None] = mapped_column(String(64), 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,188 @@
"""SQLAlchemy-backed task-scoped roundtable artifact-submission store."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_artifact_submissions.base import RoundtableArtifactSubmissionStore
from deerflow.persistence.roundtable_artifact_submissions.memory import submission_key
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
def _timestamp(value: datetime | None) -> str | None:
return value.isoformat() if isinstance(value, datetime) else value
def _to_dict(row: RoundtableArtifactSubmissionRow) -> dict[str, Any]:
return {
"id": row.id,
"task_id": row.task_id,
"idempotency_key": row.idempotency_key,
"session_id": row.session_id,
"thread_id": row.thread_id,
"node_key": row.node_key,
"source_key": row.source_key,
"path": row.artifact_path,
"name": row.artifact_name,
"mime_type": row.mime_type,
"revision": row.revision,
"task_delivery_id": row.task_delivery_id,
"submitted": row.submitted,
"submitted_at": _timestamp(row.submitted_at),
"cancelled_at": _timestamp(row.cancelled_at),
"created_by": row.created_by,
"updated_by": row.updated_by,
"submitted_by": row.submitted_by,
"cancelled_by": row.cancelled_by,
"created_at": _timestamp(row.created_at),
"updated_at": _timestamp(row.updated_at),
}
def _apply_artifact(row: RoundtableArtifactSubmissionRow, artifact: dict[str, Any]) -> None:
row.session_id = artifact.get("session_id")
row.thread_id = artifact.get("thread_id")
row.node_key = artifact.get("node_key")
row.source_key = artifact["source_key"]
row.artifact_path = artifact["path"]
row.artifact_name = artifact.get("name")
row.mime_type = artifact.get("mime_type")
row.revision = int(artifact["revision"])
def _requires_submission_update(
row: RoundtableArtifactSubmissionRow,
artifact: dict[str, Any],
task_delivery_id: str | None,
) -> bool:
return (
not row.submitted
or row.task_delivery_id != task_delivery_id
or row.session_id != artifact.get("session_id")
or row.thread_id != artifact.get("thread_id")
or row.node_key != artifact.get("node_key")
or row.artifact_name != artifact.get("name")
or row.mime_type != artifact.get("mime_type")
)
class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_by_task(self, task_id: str, *, submitted: bool | None = True) -> list[dict[str, Any]]:
stmt = select(RoundtableArtifactSubmissionRow).where(RoundtableArtifactSubmissionRow.task_id == task_id)
if submitted is not None:
stmt = stmt.where(RoundtableArtifactSubmissionRow.submitted.is_(submitted))
stmt = stmt.order_by(RoundtableArtifactSubmissionRow.updated_at.desc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def submit(
self,
task_id: str,
artifact: dict[str, Any],
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any]:
key = submission_key(task_id, artifact)
stmt = select(RoundtableArtifactSubmissionRow).where(
RoundtableArtifactSubmissionRow.task_id == task_id,
RoundtableArtifactSubmissionRow.idempotency_key == key,
)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
now = datetime.now(UTC)
created = row is None
if row is None:
row = RoundtableArtifactSubmissionRow(
id=uuid4().hex,
task_id=task_id,
idempotency_key=key,
task_delivery_id=task_delivery_id,
submitted=True,
submitted_at=now,
created_by=actor_id,
updated_by=actor_id,
submitted_by=actor_id,
created_at=now,
updated_at=now,
)
_apply_artifact(row, artifact)
session.add(row)
elif _requires_submission_update(row, artifact, task_delivery_id):
_apply_artifact(row, artifact)
row.task_delivery_id = task_delivery_id
row.submitted = True
row.submitted_at = now
row.cancelled_at = None
row.updated_by = actor_id
row.submitted_by = actor_id
row.cancelled_by = None
row.updated_at = now
try:
await session.commit()
except IntegrityError:
# Two callers can both observe a missing row. The unique key
# turns that race into a normal idempotent retry instead of a
# 500. If their non-key metadata differs, apply this request
# after loading the winner so PUT still has upsert semantics.
await session.rollback()
if not created:
raise
result = await session.execute(stmt)
row = result.scalars().first()
if row is None:
raise
if _requires_submission_update(row, artifact, task_delivery_id):
now = datetime.now(UTC)
_apply_artifact(row, artifact)
row.task_delivery_id = task_delivery_id
row.submitted = True
row.submitted_at = now
row.cancelled_at = None
row.updated_by = actor_id
row.submitted_by = actor_id
row.cancelled_by = None
row.updated_at = now
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def cancel(
self,
task_id: str,
artifact: dict[str, Any],
*,
submission_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any] | None:
key = submission_key(task_id, artifact)
stmt = select(RoundtableArtifactSubmissionRow).where(
RoundtableArtifactSubmissionRow.task_id == task_id,
RoundtableArtifactSubmissionRow.idempotency_key == key,
)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
if row is None or (submission_id is not None and row.id != submission_id):
return None
if row.submitted:
now = datetime.now(UTC)
row.submitted = False
row.cancelled_at = now
row.updated_by = actor_id
row.cancelled_by = actor_id
row.updated_at = now
await session.commit()
await session.refresh(row)
return _to_dict(row)
@@ -8,7 +8,7 @@ SKILL_MD_FILE = "SKILL.md"
class SkillCategory(StrEnum):
"""Source category for a skill.
- ``PUBLIC``: built-in skill bundled with the platform, read-only.
- ``PUBLIC``: built-in skill bundled with the platform; administrator-managed.
- ``CUSTOM``: user-authored skill that can be edited or deleted.
"""
@@ -117,6 +117,11 @@ CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFA
-- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。
CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
-- ---- position_roundtable_sessions: opt-in task sharing, migration 20260808_01 ----
-- Existing records remain personal (`task_scoped = 0`). New records become
-- shared only when the caller explicitly sends a non-empty route taskId.
CALL _add_column_if_missing('position_roundtable_sessions', 'task_scoped', 'TINYINT(1) NOT NULL DEFAULT 0');
-- ---- 按钮管理「外部链接」追加登录参数,对应 migration 20260625_01 ----------------
-- login_params_json: url 外链追加的登录参数列表 JSON([{key,source,custom_value},...]),
-- 由前端运行时从 userInfo 解析后拼到地址尾部;NULL/空 = 不追加(旧行为)。
@@ -199,5 +204,6 @@ SELECT table_name, column_name
OR (table_name = 'agents' AND column_name IN ('square_id'))
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
OR (table_name = 'position_roundtable_sessions' AND column_name = 'task_scoped')
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param')))
ORDER BY table_name, column_name;
@@ -68,6 +68,10 @@ CALL _create_index_if_missing('llm_call_metrics', 'ix_llm_call_metrics_run_creat
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_created_user', '`created_at`, `user_id`');
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_user_created', '`user_id`, `created_at`');
-- ---- position_roundtable_sessions --------------------------------------------
-- Explicit taskId history is loaded by taskId and newest update time.
CALL _create_index_if_missing('position_roundtable_sessions', 'ix_position_roundtable_sessions_task_updated', '`external_task_id`, `updated_at`');
-- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _create_index_if_missing;
@@ -0,0 +1,151 @@
"""Focused regression tests for administrator model configuration helpers."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import yaml
from starlette.routing import Match
from app.gateway.routers import model_management as router
def test_replace_models_block_keeps_other_sections_and_comments() -> None:
raw = """config_version: 1
models:
# Existing provider note
- name: old-model
use: langchain_openai:ChatOpenAI
model: old-model
# Disabled provider example remains useful to operators.
# - name: disabled-model
# model: disabled-model
agents:
default: lead_agent
"""
replaced = router._replace_models_block(
raw,
[
{
"name": "new-model",
"use": "langchain_openai:ChatOpenAI",
"model": "new-model",
}
],
)
parsed = yaml.safe_load(replaced)
assert parsed["models"] == [
{
"name": "new-model",
"use": "langchain_openai:ChatOpenAI",
"model": "new-model",
}
]
assert parsed["agents"] == {"default": "lead_agent"}
assert "# Existing provider note" in replaced
assert "# Disabled provider example remains useful to operators." in replaced
def test_connection_error_redaction_hides_literal_and_resolved_keys() -> None:
message = "provider rejected raw-secret and resolved-secret"
assert router._redact_text(message, ["raw-secret", "resolved-secret"]) == "provider rejected *** and ***"
def test_model_mutation_routes_accept_names_with_slashes() -> None:
for method in ("PUT", "DELETE"):
route = next(
item
for item in router.router.routes
if getattr(item, "methods", set()) == {method} and getattr(item, "path", "") == "/api/admin/models/{model_name:path}"
)
match, child_scope = route.matches(
{
"type": "http",
"method": method,
"path": "/api/admin/models/zai-org/GLM-5-FP8",
}
)
assert match is Match.FULL
assert child_scope["path_params"] == {"model_name": "zai-org/GLM-5-FP8"}
@pytest.mark.asyncio
async def test_delete_model_configuration_removes_matching_model_and_activates_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: GLM-5-FP8
- name: keep-me
use: langchain_openai:ChatOpenAI
model: keep-me
""",
encoding="utf-8",
)
saved: dict[str, object] = {}
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
saved["raw_before"] = raw_before
saved["models"] = models
return models
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "_save_models", save_models)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
await router.delete_model_configuration("zai-org/GLM-5-FP8", request)
assert "zai-org/GLM-5-FP8" in str(saved["raw_before"])
assert saved["models"] == [
{
"name": "keep-me",
"use": "langchain_openai:ChatOpenAI",
"model": "keep-me",
}
]
@pytest.mark.asyncio
async def test_connection_test_uses_one_minimal_prompt_without_writing_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = "models: []\nagent: lead_agent\n"
config_path.write_text(original, encoding="utf-8")
messages_seen = []
class FakeChatModel:
async def ainvoke(self, messages):
messages_seen.extend(messages)
return SimpleNamespace(content="OK")
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "create_chat_model", lambda *_args, **_kwargs: FakeChatModel())
request = SimpleNamespace(
state=SimpleNamespace(user=SimpleNamespace(system_role="admin")),
app=SimpleNamespace(state=SimpleNamespace(config=SimpleNamespace(model_copy=lambda **_kwargs: SimpleNamespace()))),
)
body = router.ModelTestRequest(
model=router.ModelUpsertRequest(
name="test-model",
use="langchain_openai:ChatOpenAI",
model="test-model",
api_key="raw-secret",
)
)
result = await router.test_model_connection(body, request)
assert result.success is True
assert result.preview == "OK"
assert len(messages_seen) == 1
assert messages_seen[0].content == "Reply with exactly: OK"
assert config_path.read_text(encoding="utf-8") == original
@@ -3,6 +3,9 @@
from __future__ import annotations
import asyncio
import importlib.util
import json
import zipfile
from types import SimpleNamespace
from typing import Any
@@ -14,9 +17,14 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway import deps as gateway_deps
from app.gateway import services as gateway_services
from app.gateway.app import create_app
from app.gateway.routers import _roundtable_seed as roundtable_seed
from app.gateway.routers import intent as intent_router
from app.gateway.routers import multi_agent
from app.gateway.routers import position_roundtable as position_roundtable_router
from app.gateway.routers._roundtable_seed import (
POSITION_INTENT_AGENT_ID,
required_agent_ids,
)
from deerflow.persistence.base import Base
from deerflow.persistence.position_roundtable import PositionRoundtableRepository
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
@@ -55,6 +63,248 @@ def test_position_roundtable_router_has_intranet_compatibility_prefix():
assert position_roundtable_router.router.prefix == "/position-roundtable"
def test_position_intent_agent_is_a_seeded_functional_agent():
"""Fresh deployments must materialize the dedicated position intent agent."""
assert POSITION_INTENT_AGENT_ID in set(required_agent_ids())
def test_action_plan_seed_binds_the_batch_create_skill_and_nine_field_contract():
"""New deployments must not fall back to the retired subtask JSON contract."""
action_assets = roundtable_seed._ASSETS_DIR / roundtable_seed.ACTION_PLAN_AGENT_ID
soul = (action_assets / "SOUL.md").read_text(encoding="utf-8")
config = (action_assets / "config.yaml").read_text(encoding="utf-8")
assert "position-roundtable-action-create" in config
for field in (
"actionName",
"actionDescription",
"actionType",
"publishChannel",
"infoFormat",
"responsiblePosition",
"startTime",
"endTime",
"taskId",
):
assert field in soul
assert "/task/action/add" in soul
assert "批量新增" in soul
@pytest.mark.asyncio
async def test_action_plan_prompt_receives_frontend_task_id_and_dictionary_values():
"""The action planner must see exact dictValue choices, not frontend labels only."""
dictionaries = position_roundtable_router.ActionPlanDictionaries(
publish_channels=[{"label": "微信公众号", "value": "11"}],
action_types=[{"label": "宣传", "value": "21"}],
info_formats=[{"label": "图文", "value": "31"}],
responsible_positions=[{"label": "规划岗", "value": "41"}],
)
prompt = await position_roundtable_router._build_builtin_prompt(
kind="action_plan",
session={
"task_snapshot": {"title": "任务 A", "description": "生成行动"},
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
},
nodes=[],
action_task_id=1001,
action_dictionaries=dictionaries,
)
assert "当前 taskId:1001" in prompt
assert "微信公众号 → 11" in prompt
assert "宣传 → 21" in prompt
assert "图文 → 31" in prompt
assert "规划岗 → 41" in prompt
assert "id、title、goal" in prompt
@pytest.mark.asyncio
async def test_action_plan_prompt_degrades_when_task_dictionaries_are_unavailable():
"""A 404 from the task-system dictionaries must not block local planning."""
prompt = await position_roundtable_router._build_builtin_prompt(
kind="action_plan",
session={
"task_snapshot": {"title": "本地演示任务"},
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
},
nodes=[],
action_task_id="abc123",
action_dictionaries=position_roundtable_router.ActionPlanDictionaries(),
)
assert "字典未获取完整" in prompt
assert "不得调用 Skill" in prompt
assert "0 占位" in prompt
def test_action_plan_prepare_keeps_a_local_mock_task_id_without_numeric_coercion():
"""A local demo id must not stop the action-plan conversation at prepare time."""
body = position_roundtable_router.PrepareBuiltinRunRequest(task_id="abc123")
assert body.task_id == "abc123"
def test_action_batch_skill_accepts_only_the_nine_field_array(tmp_path):
"""The batch Skill must reject old task-subdivision fields before any HTTP call."""
archive_path = roundtable_seed._ASSETS_DIR.parents[3] / "skill-packages" / "position-roundtable-action-create.skill"
with zipfile.ZipFile(archive_path) as archive:
archive.extractall(tmp_path / "skill-package")
script_path = tmp_path / "skill-package" / "position-roundtable-action-create" / "scripts" / "batch_add_actions.py"
spec = importlib.util.spec_from_file_location("position_action_batch_skill", script_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
valid = [{
"actionName": "发布内容",
"actionDescription": "向目标渠道发布审核通过的信息。",
"actionType": "1",
"publishChannel": "2",
"infoFormat": "3",
"responsiblePosition": "4",
"startTime": "2026-08-12T09:00:00+08:00",
"endTime": "2026-08-12T18:00:00+08:00",
"taskId": 1001,
}]
source = tmp_path / "action-plan-subtasks.json"
source.write_text(json.dumps(valid, ensure_ascii=False), encoding="utf-8")
assert module.read_actions(source, 1001) == [{
**valid[0],
"actionType": 1,
"publishChannel": 2,
"infoFormat": 3,
"responsiblePosition": 4,
}]
source.write_text(json.dumps([{**valid[0], "id": "legacy"}], ensure_ascii=False), encoding="utf-8")
with pytest.raises(module.ActionInputError, match="多余字段"):
module.read_actions(source, 1001)
def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
"""Dedicated schema must not regress into solutioning or gatekeeping."""
prompt = (
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
).read_text(encoding="utf-8")
for phrase in (
"最多 5 轮用户协助",
"除 ask_clarification 外,禁止调用任何工具、技能或操作",
"你都不拒绝、不评价、不设限",
"不得说“不能帮你”“不在职责范围内”“请换一个问题”",
"coreGoals / riskWarnings / keyPoints / strategicSignificance",
"不能把“主题已识别”误判为“意图已明确”",
"任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助",
"必须分析任务级风险",
"同一轮可发起多张用户协助卡",
"不得把多个问题塞进一张卡",
):
assert phrase in prompt
def test_position_intent_clarification_waits_for_the_user_before_ready():
"""A same-run READY block must not consume the user-assistance pause."""
assert intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
intent_router.INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
None,
is_fresh=True,
)
assert not intent_router._clarification_blocks_ready(
intent_router.POSITION_INTENT_AGENT_ID,
{"name": "ask_clarification"},
is_fresh=False,
)
def test_position_intent_counts_multi_card_assistance_as_one_round():
"""Five user-assistance rounds must not be consumed by five cards at once."""
messages: list[dict[str, Any]] = [
{
"type": "ai",
"tool_calls": [
{"id": "scope", "name": "ask_clarification", "args": {}},
{"id": "purpose", "name": "ask_clarification", "args": {}},
{"id": "time", "name": "ask_clarification", "args": {}},
],
},
{"type": "human", "content": "用户统一答复"},
{
"type": "ai",
"tool_calls": [
{"id": "risk", "name": "ask_clarification", "args": {}},
],
},
]
assert intent_router._count_past_clarifications(messages) == 4
assert intent_router._count_past_clarification_rounds(messages) == 2
assert intent_router._max_clarification_rounds(intent_router.INTENT_AGENT_ID) == 3
assert (
intent_router._max_clarification_rounds(intent_router.POSITION_INTENT_AGENT_ID)
== 5
)
def test_position_intent_seed_repairs_its_contract_without_touching_other_agents(tmp_path, monkeypatch):
"""The dedicated schema prompt must replace an accidentally seeded old version."""
agents_dir = tmp_path / "agents"
monkeypatch.setattr(
roundtable_seed,
"get_paths",
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
)
position_dir = agents_dir / POSITION_INTENT_AGENT_ID
position_dir.mkdir(parents=True)
(position_dir / "SOUL.md").write_text("outdated prompt", encoding="utf-8")
assert roundtable_seed._ensure_one(POSITION_INTENT_AGENT_ID) is True
expected_soul = (
roundtable_seed._ASSETS_DIR / POSITION_INTENT_AGENT_ID / "SOUL.md"
).read_text(encoding="utf-8")
assert (position_dir / "SOUL.md").read_text(encoding="utf-8") == expected_soul
generic_dir = agents_dir / roundtable_seed.INTENT_AGENT_ID
generic_dir.mkdir(parents=True)
generic_soul = generic_dir / "SOUL.md"
generic_soul.write_text("deployed generic tune", encoding="utf-8")
assert roundtable_seed._ensure_one(roundtable_seed.INTENT_AGENT_ID) is False
assert generic_soul.read_text(encoding="utf-8") == "deployed generic tune"
def test_existing_action_planner_only_merges_the_owned_skill(tmp_path, monkeypatch):
"""A code upgrade may add the Skill without overwriting a user-tuned SOUL."""
agents_dir = tmp_path / "agents"
monkeypatch.setattr(
roundtable_seed,
"get_paths",
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
)
action_dir = agents_dir / roundtable_seed.ACTION_PLAN_AGENT_ID
action_dir.mkdir(parents=True)
(action_dir / "SOUL.md").write_text("deployed action-plan tune", encoding="utf-8")
(action_dir / "config.yaml").write_text(
"id: position-action-planner\nskills:\n - existing-skill\n",
encoding="utf-8",
)
assert roundtable_seed._ensure_one(roundtable_seed.ACTION_PLAN_AGENT_ID) is True
config = (action_dir / "config.yaml").read_text(encoding="utf-8")
assert "existing-skill" in config
assert "position-roundtable-action-create" in config
assert (action_dir / "SOUL.md").read_text(encoding="utf-8") == "deployed action-plan tune"
@pytest.mark.asyncio
async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch):
"""Step 1 initialization must not depend on the deployed Gateway port."""
@@ -91,6 +341,71 @@ async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch)
}
@pytest.mark.asyncio
async def test_position_intent_init_selects_its_dedicated_agent(monkeypatch):
"""Position roundtable must never mutate the generic Step-1 agent contract."""
captured: dict[str, object] = {}
monkeypatch.setattr(intent_router, "ensure_roundtable_functional_agents", lambda: None)
async def fake_create_thread_direct(request, *, metadata):
captured["metadata"] = metadata
return "position-intent-thread"
monkeypatch.setattr(intent_router, "_create_thread_direct", fake_create_thread_direct)
result = await intent_router.init_intent(
SimpleNamespace(),
{
"intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
"task_context": {"task": {"id": "task-position-001"}},
},
)
assert result == {
"status": "success",
"agent_id": intent_router.POSITION_INTENT_AGENT_ID,
"thread_id": "position-intent-thread",
}
assert captured["metadata"] == {
**intent_router._ROUNDTABLE_THREAD_METADATA,
"taskId": "task-position-001",
"position_roundtable_task_id": "task-position-001",
"position_task_id": "task-position-001:intelligence",
"position_id": "intelligence",
"position_roundtable_phase": "intent",
"position_roundtable_intent_agent_id": intent_router.POSITION_INTENT_AGENT_ID,
}
def test_position_intent_schema_is_required_for_new_snapshots_and_legacy_is_kept():
assert position_roundtable_router._has_confirmed_intent(
{
"coreGoals": ["形成统一处置方案"],
"riskWarnings": [],
"keyPoints": ["需要在本周完成"],
"strategicSignificance": ["为后续岗位协同提供统一边界"],
}
)
assert not position_roundtable_router._has_confirmed_intent(
{"coreGoals": ["形成统一处置方案"], "riskWarnings": []}
)
assert position_roundtable_router._has_confirmed_intent(
{"objective": "历史任务", "constraints": []}
)
answer = position_roundtable_router._intent_node_answer(
{
"coreGoals": ["形成统一处置方案"],
"riskWarnings": ["关键数据尚待确认"],
"keyPoints": ["聚焦当前任务范围"],
"strategicSignificance": ["支撑业务链协同推进"],
}
)
assert "核心目标" in answer
assert "风险提示" in answer
assert "关键要点" in answer
assert "战略意义" in answer
@pytest.mark.asyncio
async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
created = await store.create_session(
@@ -208,14 +523,91 @@ async def test_conversation_snapshots_round_trip_and_history_delete_removes_node
assert saved_node is not None
assert saved_node["conversation_snapshot"] == node_conversation
# Ownership checks apply to delete as well.
assert not await store.delete_session(created["id"], "user-b")
assert await store.get_session(created["id"], "user-a") is not None
assert await store.delete_session(created["id"], "user-a")
# Position roundtable history is shared by taskId, so a collaborator can
# read and remove the same task conversation without being its creator.
assert await store.get_session(created["id"], "user-b") is not None
assert await store.delete_session(created["id"], "user-b")
assert await store.get_session(created["id"], "user-a") is None
assert await store.list_nodes(created["id"], "user-a") is None
@pytest.mark.asyncio
async def test_task_scoped_history_is_shared_but_never_mixes_tasks(store):
shared = await store.create_session(
"user-a",
external_task_id="external-task-100",
task_snapshot={"id": "external-task-100", "title": "共享任务"},
)
await store.create_session(
"user-b",
external_task_id="external-task-200",
task_snapshot={"id": "external-task-200", "title": "另一任务"},
)
visible_to_collaborator = await store.list_sessions(
"user-b", external_task_id="external-task-100"
)
assert [row["id"] for row in visible_to_collaborator] == [shared["id"]]
updated = await store.update_session(
shared["id"],
"user-b",
task_snapshot={"id": "external-task-100", "title": "协作者已更新"},
)
assert updated is not None
assert updated["task_snapshot"]["title"] == "协作者已更新"
assert (await store.get_session(shared["id"], "user-a"))["task_snapshot"]["title"] == "协作者已更新"
@pytest.mark.asyncio
async def test_unscoped_history_keeps_original_user_isolation(store):
personal = await store.create_session(
"user-a",
external_task_id=None,
task_snapshot={"id": "legacy-card-id", "title": "个人会话"},
)
assert await store.get_session(personal["id"], "user-b") is None
assert await store.list_sessions("user-b") == []
assert await store.update_session(personal["id"], "user-b", status="active") is None
assert not await store.delete_session(personal["id"], "user-b")
# Old client records could already carry a mock task id. The new database
# marker keeps them personal after the migration; only a new explicit
# route taskId creates a shared row.
legacy_with_mock_task_id = await store.create_session(
"user-a",
external_task_id="legacy-mock-task-id",
task_scoped=False,
task_snapshot={"id": "legacy-card-id", "title": "鏃х増涓汉浼氳瘽"},
)
assert await store.get_session(legacy_with_mock_task_id["id"], "user-b") is None
assert await store.list_sessions("user-b", external_task_id="legacy-mock-task-id") == []
def test_session_create_allows_missing_task_id_and_scoped_snapshots_keep_scope():
body = position_roundtable_router.SessionCreateRequest(
external_task_id=" task-route-001 ",
task_snapshot={"id": "wrong-id", "title": "任务"},
)
assert body.external_task_id == "task-route-001"
assert position_roundtable_router._task_snapshot_for_scope(
body.external_task_id,
body.task_snapshot,
) == {
"id": "task-route-001",
"externalTaskId": "task-route-001",
"title": "任务",
}
assert position_roundtable_router.SessionCreateRequest(task_snapshot={}).external_task_id is None
assert position_roundtable_router.SessionCreateRequest(external_task_id=" ").external_task_id is None
assert position_roundtable_router._task_snapshot_for_scope(
"legacy-mock-task-id",
{"id": "legacy-card-id", "title": "旧记录"},
task_scoped=False,
) == {"id": "legacy-card-id", "title": "旧记录"}
@pytest.mark.asyncio
async def test_demo_seed_is_complete_idempotent_and_user_scoped(store):
payload = {
@@ -490,6 +882,7 @@ async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_
}
session = {
"id": "session-a",
"external_task_id": "task-fallback",
"status": "active",
"task_snapshot": {},
"chain_id": "chain-a",
@@ -561,7 +954,7 @@ async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_
@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_without_task_id(store):
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
_, nodes = await store.activate_session(
created["id"],
@@ -602,7 +995,9 @@ async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(stor
@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={})
created = await store.create_session(
"user-a", external_task_id="task-reject", task_snapshot={}
)
_, nodes = await store.activate_session(
created["id"],
"user-a",
@@ -682,7 +1077,7 @@ async def test_reject_node_marks_current_and_invalidates_later_stages(store):
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,
external_task_id="task-builtin-invalidation",
task_snapshot={
"title": "测试任务",
"description": "形成协同方案",
@@ -116,3 +116,13 @@ async def test_unknown_mode_value_ignored_falls_through(_gw):
await _run(gateway)
assert captured["thinking_enabled"] is True
assert captured["reasoning_effort"] == "low"
def test_chain_seat_schema_keeps_human_validation_checkpoint():
"""Chain-seat JSON must preserve the frontend Step 2 pause configuration."""
from app.gateway.routers.roundtable_chains import ChainSeatSchema
assert ChainSeatSchema(agent_id="seat-1").human_validation is False
assert ChainSeatSchema(agent_id="seat-1", human_validation=True).model_dump()[
"human_validation"
] is True