feat: 模型配置支持删除,岗位行动规划对接任务系统字典与批量新增 Skill
模型管理新增 DELETE 接口与前端删除能力(支持含斜杠的模型名);行动规划改为 9 字段 JSON,prepare 传入 taskId/字典并绑定 position-roundtable-action-create Skill。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
af509eb670
commit
c9c2dc7ea8
12 files changed
+697
-95
No files matched your search
@@ -30,7 +30,9 @@ Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内
|
||||
|
||||
> ⚠️ 维护提示:资源文件(``_roundtable_seed_assets/``)是**发布默认值**的唯一可信源。
|
||||
> 线上调优后,请把改动写回对应的资源 .md,否则下次新机器补建出来的还是旧版。
|
||||
> ``_ensure_one`` 只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
|
||||
> ``_ensure_one`` 通常只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
|
||||
> 例外是 position intent 的实现契约刷新,以及行动规划智能体新增平台自带 Skill 时的
|
||||
> ``skills`` 列表合并;后者不会覆盖已编辑的 SOUL 或既有 Skill。
|
||||
|
||||
为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建
|
||||
------------------------------------------------------------
|
||||
@@ -52,6 +54,8 @@ import logging
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -108,6 +112,47 @@ _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:
|
||||
@@ -135,6 +180,8 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
|
||||
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.
|
||||
|
||||
+17
-11
@@ -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 生成”类水印。
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
id: position-action-planner
|
||||
name: 行动规划智能体
|
||||
description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。
|
||||
skills:
|
||||
- position-roundtable-action-create
|
||||
@@ -378,7 +378,7 @@ async def create_model_configuration(body: ModelUpsertRequest, request: Request)
|
||||
return _to_admin_item(created)
|
||||
|
||||
|
||||
@router.put("/{model_name}", response_model=ModelAdminItem)
|
||||
@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:
|
||||
@@ -396,6 +396,20 @@ async def update_model_configuration(model_name: str, body: ModelUpsertRequest,
|
||||
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."""
|
||||
|
||||
@@ -156,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):
|
||||
@@ -1060,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 {}
|
||||
@@ -1103,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()])
|
||||
@@ -1549,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)
|
||||
@@ -1582,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),
|
||||
)
|
||||
@@ -1689,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)
|
||||
|
||||
Reference in new issue
Block a user