--- name: cockpit-agent-patterns description: Cockpit session agent anti-patterns and fixes. --- # Cockpit Agent 设计模式 会话 agent(cockpit_chat.dspy)开发中发现的陷阱和修复。 ## 对话历史结构化 **反模式**: 历史以独立 user/assistant 角色混入 LLM 上下文,LLM 模仿旧 tool_call。 **正确做法**: 聚合为系统级"参考历史记录:",用户当前输入为唯一 user 消息: ``` [system] 参考历史记录: 用户: 切换到人事系统 助手: OK 已切换 [user] 人事系统项目当前进展 ← 唯一主输入,历史不会污染决策 ``` ## 项目名匹配 **反模式**: `_find_project` 硬编码精确/子串匹配。 **正确做法**: 精确匹配失败时调 `_call_llm_raw(temp=0)` 做文本分类——给 LLM 用户输入和项目列表,让它选。不要硬编码匹配逻辑。 ## owner 级配置 get/set 不匹配(保存后看不到) **反模式**: 工作环境等 owner 级配置(user/org)用 `get_work_env(sor, user_id, org_id)` 读取,优先级 user → org。专门的 org 级配置页面(如 sd_org_remote「机构远程空间」)加载时走默认 get,返回的是 user 级配置(往往是空/local),用户保存到 org 级后退出再进,get 又返回 user 级 → 看起来"保存没生效"。 **修复**: get API 加 `owner_type` 参数。org 页面加载传 `owner_type=org`(`get_work_env(sor, '', org_id)` 只查 org 级),user 页面传 `owner_type=user`(只查 user 级)。前端加载 script 用 `?action=get&owner_type=org`,与 set 的 `owner_type='org'` 对齐。 ## 切换项目不持久化(下一轮回退老项目) **反模式**: `_t_switch_project`/`_t_create_project` 只改 `self.project_id`(AgentExecutor 实例内存字段),不写 `pipeline_agent_settings`。AgentExecutor 每轮 run 新建、run 结束销毁,所以切换在下一条消息就回退。 **修复**: switch/create 成功后必须持久化 `current_project_id` 到 `pipeline_agent_settings`(user_id 唯一键,UPDATE-first + SELECT-check + INSERT 兜底)。否则 cockpit_chat_v2 每轮从 settings 读旧项目,历史隔离也跟着错(切到新项目却加载旧项目历史)。 ## 需求描述被当"查进展"(新需求没被关注) **反模式**: system_prompt 写"用工具查询数据" + 示例 `diagnose_project`,把 LLM 往"诊断/查询"带;auto-inject 兜底又按关键词默认注入 diagnose_project/list_tasks。用户发需求描述(含"系统/模块/任务"等词)时,agent 去 diagnose 旧任务而不是 create_task 推进需求。 **修复**(三层): 1. system_prompt 明确工作流:"描述需求→create_task+start_agents;问进展→list_tasks/diagnose;问问题→list_questions"。 2. auto-inject 兜底加"新需求"启发式:`len(input)>40 且含需求特征词(实现/开发/系统/功能/模块/切换/监控/备份/同步...)` → 注入 create_task 引导,而非 diagnose。 3. 需求特征词表放代码里,prompt 保持简洁(不靠冗长禁令)。 ## create_project 不设置 workspace_dir(所有项目共用 default 目录) **反模式**: `_t_create_project` 只 `sor.C("sd_projects", {id,name,description,status})`,不设 `workspace_dir`/`org_id`。结果 `_resolve_workspace`(agent_loop.py)遇到空 workspace_dir 回退 `~/pipeline_ws/default`,**所有项目共用同一目录,设计文档/代码互相覆盖**;而前端 `get_workspace_path`(workspace.py)回退 `/d/pipeline/workspaces//<项目名>`,两处不一致 → 前端工作空间浏览器显示"不可用",文档实际写在 default。 **修复**: create_project 时设 `workspace_dir = os.path.join(workspace_base, org_id, name)` + `os.makedirs`,`org_id` 从 `users.orgid` 查。**workspace_base 不硬编码**,从 appbase `params` 表读(`params_name='workspace_base'` → `params_value`),带默认值 `/d/pipeline/workspaces` 兜底(`_get_param` try/except,params 表不存在时用默认值不 500)。现有项目要手动 `UPDATE sd_projects SET workspace_dir=...` 并把 default 里的文档 `mv` 回项目目录。 ## tool_call JSON 泄露 **反模式**: LLM 在 reply 中夹杂 `{"action":"tool_call",...}`。 **修复**: `_parse()` 用 regex 剥离 tool_call JSON,`rstrip('}')` 清除残留花括号。 ## LLM 光说不练(deepseek 特有问题) **反模式**: "我来逐一回答"但不调 answer_question,"启动Agent"但不调 start_agents。 更严重的是 deepseek-v4-pro:即使 prompt 明确要求"第一步必须调用工具"、"禁止 reply",LLM 仍然 第一个动作就输出 `{"action":"reply",...}`。尝试了激进规则、few-shot 示例、移除 reply 格式选项、 精简到 3 行核心指令——全部无效。 **修复(双阶段代码级兜底)**: 1. **首轮注入**(`_tool_call_count == 0` 时): - 根据用户输入关键词推断合适工具(任务→list_tasks,问题→list_questions,其他→diagnose_project) - 拦截 reply,追加 "请调用 {tool},只输出JSON" 到消息数组,continue 继续 loop 2. **延续推动**(`_tool_call_count <= 2` 时): - LLM 调了 1-2 次工具就想停 → 追加 "请继续:查看失败任务详情、回答问题、或启动agent" - 防止 LLM 拿到诊断结果就 reply 而非继续深入 关键:`_tool_call_count` 只在工具成功返回时递增(结果不以 "未知工具" 或 "ERROR" 开头)。 换用 Claude/GPT-4 后此机制可移除——prompt 本身就能驱动工具调用。 详见: `references/deepseek-tool-calling.md` > ⚠️ **2026-08-15 已废弃**:上面的 auto-inject 双阶段兜底(硬编码 req_keywords + 强制注入 hint_tool)已被移除。它把 agent 变成了"只能路由工具的傻子"——见下一节。诚实降级取代强制路由。 ## 会话 agent 不是"路由器"——诚实降级 + 能力自省(2026-08-15 重构) **反模式(三重病态叠加,把 agent 变成傻子路由器)**: 1. system prompt 写 `必须只输出 tool_call JSON,禁止 reply` → 剥夺 LLM 说"我做不到"的能力,面对超出工具集能力的指令只能硬套一个最接近的工具。 2. auto-inject 用硬编码关键词(`req_keywords=["实现","开发","系统","模块"...]` + `if kw in user_input`)强制注入工具 → 违反"意图识别交给 LLM 不硬编码"铁律,LLM 想停就被塞 hint_tool。 3. `ask_user` 是假的:`return f"QUESTION: {params['question']}"` 把问题回传给 LLM 自己,用户看不到 → LLM 连"问用户"都做不到。 **触发症状**:用户指令超出工具集能力(如"重做三个模块"但无 reset_task 工具)时,agent 不诚实说明,而是退化成 diagnose→list_tasks→list_deliverables→list_questions→run_command 找规范文件,最后卡在 run_command 的 confirm 弹窗(requires_confirmation)没人确认就搁置。 **修复(把 Hermes 通用能力还给它)**: 1. 删"禁止 reply"。prompt 写"能力自省与诚实降级:请求超出工具能力时,明确告诉用户你做不到+缺什么能力+给替代方案+必要时 ask_user 拍板"。 2. 删 auto-inject 硬编码关键词。`reply` 和 `ask_user` 是合法终止动作(直接输出并 return,不再强制注入工具)。 3. `ask_user` 真正抛给用户:作为终止动作 `yield {"type":"ask_user","message":q}` 给前端,前端 cockpit_chat_v2.dspy 加 `elif t == 'ask_user'` 分支展示 `❓ 问题`。 **用户定位(架构原则)**:会话 agent = Hermes 通用能力(推理/判断/诚实降级/能力自省/澄清)+ 项目工具,不是"只能路由、不能判断的壳"。auto-inject 只在"LLM 真·空转(连续 N 轮既无 tool_call 也无 reply/ask_user)"时才温和提示一次,且不硬编码关键词。 ## Prompt 占位符未替换 **反模式**: system prompt 模板中有 `{tools_description}` 占位符,但 `_build_system_prompt` 将其作为字面文本传给 LLM——LLM 看到的是 `## 可用工具\n{tools_description}` 而非实际工具列表。 **修复**: `_build_system_prompt` 中必须构建 tools_text 后用 `prompt.replace("{tools_description}", tools_text)` 替换。 同时 `{current_project}` 应替换为项目名而非项目 ID(`_load_project_context` 获取名称)。 ## msgs 数组污染 **反模式**: `msgs.append({"role":"assistant","content":raw})` 把原始 tool_call JSON 塞进 LLM 上下文。 **修复**: 改为 `msgs.append({"role":"assistant","content":"已调用 {tool}"})` ## 通知推送 **反模式**: 角色 agent 提问后会话 agent 不主动告知用户。 **修复**: 每轮开始查询 pending questions,yield 为第一条 NDJSON + 注入 system prompt。 ## 回复控件 **反模式**: 最终回复用 Text 控件,不支持 markdown。 **修复**: 最终回复/通知用 MdWidget(支持 **粗体**、列表、换行),工具结果用 Text。 ## _save_ctx 竞态 **反模式**: SELECT-then-INSERT 导致 duplicate key。 **修复**: UPDATE-first + try/except,INSERT 仅兜底。 ## 登录门禁 **反模式**: `get_user()` 返回 None 时静默失败。 **修复**: send_message/list_messages 入口检查,None 返回"请先登录"。 ## claimed_by 僵尸锁(PM 驳回后任务永久卡死) **反模式**: PM agent 驳回交付件时只 `UPDATE state='submitted'`,不清 `claimed_by`。 agent poller 条件 `claimed_by IS NULL` → 跳过此任务 → 任务永远不被认领。 **修复**: PM reject 路径必须 `SET state='submitted', claimed_by=NULL`。 同时定期清理:`UPDATE pipeline_tasks SET claimed_by=NULL WHERE state='submitted' AND claimed_by IS NOT NULL`。 ## 进程重启不生效(start.sh kill -0 陷阱) **症状**: 代码已部署到 site-packages,重启后旧行为依旧。 **根因**: `start.sh` 用 `kill -0 $(cat pipeline.pid)` 检查旧进程。PID 文件被删但进程还活着时, kill -0 返回成功 → 打印 "Already running" → 跳过启动 → 旧进程代码永不被替换。 **修复**: 部署最后一步必须 `pkill -9 -f pipeline_app` 强制杀进程,再 `bash start.sh`。 **进阶陷阱(多进程并存 + pkill 255 断链)**——重启流程两个独立坑,都会造成"改完还报旧错": 1. `pkill -9 -f pipeline_app` 在**没匹配到进程时返回 255**,放在 `&&` 命令链里会断掉后续的 `bash start.sh`。所以 kill 和 start 必须分两条命令执行,或用 `;` 而非 `&&`,或用 `ps aux|grep|awk '{print $2}'|xargs -r kill -9`(`-r` 空输入不报错)。 2. 反复重启若没彻底杀,会**累积多个 pipeline_app 进程**,最早的进程占着 9090 端口,新启动的进程监听失败但 start.sh 仍打印 "Started",curl 打到旧进程 → 修复看起来"没生效"。改完代码必须验证: ```bash ps aux | grep pipeline_app | grep -v grep | wc -l # 必须 = 1 ``` 部署后先确认单进程、再 curl 验证,否则排查方向会被旧进程带偏(本次 remote_dir 相对路径验证连踩 3 次"假失败")。 ## LLM 编造工具名 **反模式**: LLM 输出 `tapd_get_tasks`、`run_shell_command`、`get_task_detail` 等不存在的工具名。 **修复**: 在 `_dispatch_sdlc_tool` 的 handlers dict 中加别名映射: - `get_tasks` / `get_task_list` → `list_tasks` - `get_task` / `get_task_detail` → `task_detail` - `get_deliverable` → `view_deliverable` ## list_tasks 输出格式化 **反模式**: `"- [state] title (角色: role) id=xxx"` 纯文本一坨,MdWidget 渲染效果差。 **修复**: 输出 markdown 表格 + emoji 图标: ``` | 状态 | 任务 | 角色 | ID | |------|------|------|----| | ⏳ submitted | 开发模块 | design | abc123 | | ✅ approved | ... | ... | ... | ``` ## 用户意图识别(停止 / 补充 / 新任务) **反模式**: 新消息到达时前端自动 abort 旧请求。 **正确做法**: - 后端检测停止关键词(停止/取消/停/stop/cancel)→ 立即返回"已停止" - 其他消息照常处理为新对话轮次 - 不前断 abort——用户可能在补充信息 ## 工具结果 MdWidget 渲染 **反模式**: `list_tasks` 返回 markdown 表格,但 `cockpit_chat_v2.dspy` 的 `_w_card` 用 `_w_text` 包裹——表格显示为原始 `|...|` 文本。 **修复**: `cockpit_chat_v2.dspy` 的 `tool_result` handler 检测结果是否以 `|`, `#`, `- ` 开头(markdown 特征),是则用 `_w_md(result[:2000])` 作为 card body: ```python is_md = result.startswith('|') or result.startswith('#') or result.startswith('- ') body = _w_md(result[:2000]) if is_md else result[:600] ``` ## Pipeline 浏览器登录 CDP 浏览器测试 pipeline cockpit 的登录流程: 1. `https://pipeline.opencomputing.cn/rbac/user/login.ui` — Bricks 登录表单 2. 填入 username/password(如 admin/admin123),点击 Form Submit 按钮 3. 或用 fetch POST `/rbac/user/up_login.dspy`(注意必须有 `/user/` 前缀)+ `_webbricks_=1` 4. `password_encode()` 在 `ahserver/globalEnv.py`,用 RC4 + 配置 key;DB 存的是 encoded 值 5. 登录后 session 生效,导航到 `/pipeline-sdlc` 即可渲染 cockpit **验证前端的正确姿势(SSRF 阻止私有地址)**:`browser_navigate` 会拒绝 localhost/127.0.0.1("Blocked: URL targets a private address",config `browser.allow_private_urls:false`),所以 SSH 隧道 `localhost:19090` 对浏览器工具**无效**——别在隧道上浪费时间。直接 `browser_navigate` 到**生产公网地址** `https://pipeline.opencomputing.cn/...`(公网不触发 SSRF 防护)。先 `browser_cdp` 调 `Target.getTargets` 确认 CDP 可达(config `browser.cdp_url: localhost:9222`),再导航。若页面 body 显示的是 `.ui` 的 JSON 源码而非渲染表单(select/input 数量为 0、`bricks.app` 未 run),是未登录/渲染失败,用 `browser_console` 查 `js_errors` 定位,不是修复引入的问题。