diff --git a/pipeline_service/agent_loop.py b/pipeline_service/agent_loop.py index e8a47b8..c8f4616 100644 --- a/pipeline_service/agent_loop.py +++ b/pipeline_service/agent_loop.py @@ -577,7 +577,7 @@ AGENT_TOOLS = [ {"name":"git_status","description":"查看git仓库状态","params":{"repo_dir":"仓库子目录(可选,默认 apps/modules 下第一个)"}}, {"name":"git_commit_push","description":"git add + commit + push(develop 用它提交模块仓库本地提交作为产出证据;无远程只 commit 不 push,非 git 目录自动 init)","params":{"message":"提交信息","repo_dir":"仓库子目录(可选)"}}, {"name":"ask_question","description":"向用户提问(缺少信息时使用)。需要用户提供文件或结构化信息时用 form 声明动态表单,用户可在待办里直接上传/填写完成","params":{"question":"问题","form":"可选,JSON:{\"fields\":[{\"name\":\"字段名\",\"type\":\"file|text|textarea\",\"label\":\"提示\",\"target\":\"文件目标相对路径(如 env/test.json,目录以/结尾保留原文件名)\",\"required\":true}]}"}}, - {"name":"deliver","description":"提交最终交付件(代码/文档文件已用write_file写好时调用)","params":{"deliverable_type":"交付件类型(如code_files/design_doc)","summary":"概述","result":"交付件正文","files":"JSON数组[{\"path\":\"modules/{模块}/src/x.py\",\"content\":\"代码内容\"}](可选)"}}, + {"name":"deliver","description":"提交最终交付件(代码/文档文件已用write_file写好时调用)。deliverable_type 必须用本角色工具schema里声明的合法类型值,result 是交付摘要/索引(交付件本体是 write_file 写好的 docs/ 文档与代码文件,系统自动采集文件清单供审核方核对)","params":{"deliverable_type":"交付件类型(以工具schema声明的本角色合法值为准)","summary":"概述","result":"交付件摘要/索引正文","files":"JSON数组[{\"path\":\"modules/{模块}/src/x.py\",\"content\":\"代码内容\"}](可选)"}}, ] AGENT_SYSTEM_PROMPT = """你是软件开发产线中的「__ROLE__」角色Agent。 @@ -617,8 +617,8 @@ __TOOLS__ 调工具: {"action":"tool_call","tool":"工具名","params":{}} -提交交付件: -{"action":"deliver","deliverable_type":"code_files","summary":"概述","result":"文档内容","files":[{"path":"modules/{模块}/src/file.py","content":"代码"}]} +提交交付件(deliverable_type 用工具声明里本角色的合法类型值): +{"action":"deliver","deliverable_type":"合法类型值","summary":"概述","result":"交付摘要/索引","files":[{"path":"modules/{模块}/src/file.py","content":"代码"}]} 提问: {"action":"ask","question":"问题"} @@ -1283,12 +1283,38 @@ async def _build_qna_section(sor, task_id, role, agent_id=None): async def _get_deliverable_content(sor, task_id): + """取任务最近一次交付件:(content 摘要, deliverable_type, files_json 本体文件清单)。 + + content 只是摘要/索引(本体在 docs/ 编号树,超长全文进 content 会撑爆审核上下文); + files_json 是引擎自动采集的本体文件相对路径清单——审核注入时 PM/QC 按清单 read_file 本体。 + """ recs = await sor.sqlExe( - "SELECT content, deliverable_type FROM pipeline_deliverables " + "SELECT content, deliverable_type, files_json FROM pipeline_deliverables " "WHERE task_id=${tid}$ ORDER BY created_at DESC LIMIT 1", {"tid": task_id}) if recs: - return getattr(recs[0], 'content', '') or '', getattr(recs[0], 'deliverable_type', '') or '' - return '', '' + return (getattr(recs[0], 'content', '') or '', + getattr(recs[0], 'deliverable_type', '') or '', + getattr(recs[0], 'files_json', '') or '') + return '', '', '' + + +def _build_review_files_block(files_json): + """交付件本体文件清单区块(追加在 PM/QC 审核输入里)。 + + 2026-09-14 pbls 实测:审核注入只有 content[:6000] 摘要,QC/PM 可能只看索引打分, + 人工从库进来也以为 10KB 摘要就是全部产出。注入本体清单 + 强制 read_file 指引。 + """ + try: + files = json.loads(files_json or '[]') + except Exception: + files = [] + files = [f for f in files if isinstance(f, str) and f.strip()][:50] + if not files: + return "" + return ("\n\n## 交付件本体文件清单(工作空间相对路径)\n" + "上方 content 只是交付摘要/索引。交付件本体是下列文件——必须逐个 read_file " + "核对真实内容后再逐项判定/评分,禁止只凭摘要放行:\n" + + "\n".join("- " + f for f in files)) async def _get_project_repos(sor, project_id): @@ -2184,6 +2210,24 @@ _FORCE_PRODUCE_HINT = ( ) +def _validate_deliverable_type(role, deliverable_type, allowed_types): + """deliver 类型守卫:角色声明了合法类型清单时,非法类型拒绝并返回清单(可行动报错)。 + + 2026-09-14 pbls 实测:工具描述示例值被 LLM 照抄(requirement 交付标成 design_doc), + 引擎零校验 → 交付件类型错、文件名错(-XI6..._design_doc.md)、审核语义全歪。 + 对齐 create_task 角色守卫模式:声明即校验,未声明放行(逃逸阀,不阻塞未适配产线)。 + 返回 None=通过;返回字符串=拒绝原因(回填给 LLM 重试 deliver)。 + """ + if not allowed_types: + return None # 产线未声明 → 不校验 + dt = (deliverable_type or '').strip() + if dt in allowed_types: + return None + return (f"FAIL: deliverable_type '{dt or '(空)'}' 不是本角色({role})的合法交付件类型。" + f"合法类型:{'、'.join(allowed_types)}。请用合法类型重新调用 deliver" + f"(如 {allowed_types[0]})。") + + async def _build_fallback_deliverable(space_dir, written_files, role): """循环结束未 deliver 时,检测 write_file/git 实际产出,构造真实交付件给 QC(而非占位符「Agent未产出交付件」)。""" lines = [] @@ -2419,6 +2463,25 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): # platform_model_tools,分发在 _exec_agent_tool。 from .platform_model_tools import PLATFORM_MODEL_TOOLS_V1 all_tools = AGENT_TOOLS + capability_tools + PLATFORM_MODEL_TOOLS_V1 + + # ── deliverable_type 守卫(2026-09-14 pbls):角色声明了合法交付类型时, + # ① 工具描述动态改写为只列合法值(源头防照抄示例)② deliver 拦截校验(硬门禁)。 + # 未声明的产线两处都放行(逃逸阀,与 create_task 角色守卫同模式)。 + allowed_deliverable_types = [] + try: + from pipeline_core import get_role_spec + _rspec = get_role_spec(await _resolve_pipeline_id(project_id), role) + allowed_deliverable_types = list(getattr(_rspec, 'deliverable_types', None) or []) + except Exception: + allowed_deliverable_types = [] + if allowed_deliverable_types: + _types_txt = '/'.join(allowed_deliverable_types) + all_tools = [ + dict(t, params={**t.get('params', {}), + 'deliverable_type': f'交付件类型(本角色只能用:{_types_txt})'}) + if t.get('name') == 'deliver' else t + for t in all_tools] + tools_text = json.dumps(all_tools, ensure_ascii=False) # 注入角色技能(角色专属技能全量 + 其余 scope 目录层,优先级 角色>项目>产线>组织>通用) @@ -2505,6 +2568,17 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): params = {} if tool == "deliver": + # deliverable_type 守卫:非法类型不 break,回填 FAIL 让 LLM 换合法类型重交。 + # 唯一合法类型时缺省自动填充(纯文本 deliver 常不带类型,不必折腾 LLM) + if not (params.get("deliverable_type") or '').strip() \ + and len(allowed_deliverable_types) == 1: + params["deliverable_type"] = allowed_deliverable_types[0] + _dt_err = _validate_deliverable_type( + role, params.get("deliverable_type"), allowed_deliverable_types) + if _dt_err: + msgs.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": _dt_err}) + logger.info(f"role_agent deliver type rejected: task={task_id} {_dt_err[:120]}") + continue deliverable = {"action": "deliver", **params} break if tool == "ask_question": @@ -2543,6 +2617,17 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): raw = resp.get("content", "") if isinstance(resp, dict) else str(resp) act = _parse_agent_action(raw) if act.get('action') == 'deliver': + # deliverable_type 守卫(文本兜底路径同 native 路径,2026-09-14 pbls) + if not (act.get("deliverable_type") or '').strip() \ + and len(allowed_deliverable_types) == 1: + act["deliverable_type"] = allowed_deliverable_types[0] + _dt_err = _validate_deliverable_type( + role, act.get("deliverable_type"), allowed_deliverable_types) + if _dt_err: + msgs.append({"role": "assistant", "content": raw}) + msgs.append({"role": "user", "content": _dt_err}) + logger.info(f"role_agent deliver type rejected(text): task={task_id} {_dt_err[:120]}") + continue deliverable = act break elif act.get('action') == 'ask': @@ -2590,7 +2675,9 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): from appPublic.uniqueID import getID did = getID() result_text = deliverable.get("result") or "" - deliverable_type = deliverable.get("deliverable_type") or role + # 类型缺省:守卫已保证声明角色只能填合法值;未声明产线空值时用首个合法类型/角色名兜底 + deliverable_type = (deliverable.get("deliverable_type") or '').strip() \ + or (allowed_deliverable_types[0] if allowed_deliverable_types else role) summary = deliverable.get("summary", "") # 写入代码文件 @@ -2612,13 +2699,16 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): else: logger.error(f"code file failed: {abs_path} err={msg}") - # 写交付件文档 + # 写交付件文档(result 摘要快照,评审留痕用;交付件本体是 docs/ 编号树 + files 清单) file_path = '' if result_text: deliverable_dir = os.path.join(project_dir, 'deliverables', role) os.makedirs(deliverable_dir, exist_ok=True) - safe_type = (deliverable_type or 'deliverable').replace('/', '_') - file_path = os.path.join(deliverable_dir, f"{task_id}_{safe_type}.md") + safe_type = re.sub(r'[^A-Za-z0-9_.-]', '_', deliverable_type or 'deliverable') + # 文件名清洗(2026-09-14 pbls):getID() 随机 ID 可能 '-' 开头 + # (实测 -XI6QB04..._design_doc.md),'-' 开头文件名在 shell 里被当选项,难用且易误判 + safe_tid = re.sub(r'^[^A-Za-z0-9]+', 't', task_id or 'task') + file_path = os.path.join(deliverable_dir, f"{safe_tid}_{safe_type}.md") try: with open(file_path, 'w', encoding='utf-8') as f: f.write(result_text or '') @@ -2626,10 +2716,23 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None): logger.error(f"deliverable write failed: {file_path} err={e}") file_path = '' + # 交付件本体文件清单(工作空间相对路径):write_file 实写 + deliver files + 摘要快照。 + # PM/QC 审核注入时随清单强制 read_file 本体,不再只看 content 摘要(pbls 教训)。 + body_files = [] + for f in list(written_files) + files_written + ([file_path] if file_path else []): + try: + rel = os.path.relpath(f, space_dir) + except Exception: + continue + if rel and not rel.startswith('..') and rel not in body_files: + body_files.append(rel) + files_json = json.dumps(body_files[:50], ensure_ascii=False) + await sor.C("pipeline_deliverables", { "id": did, "project_id": project_id, "task_id": task_id, "deliverable_type": deliverable_type, "title": title, "content": result_text, "file_path": file_path, + "files_json": files_json, "quality_score": 80, "review_status": "pending", "created_by": agent_id or role, }) @@ -3014,7 +3117,7 @@ async def pm_review_run(project_id, agent_id=None, model_name=None): if repos: await _setup_repos(sor, space_dir, project_id) - deliverable_content, deliverable_type = await _get_deliverable_content(sor, task_id) + deliverable_content, deliverable_type, deliverable_files_json = await _get_deliverable_content(sor, task_id) if not deliverable_content: from .task_capability import reject_task await reject_task(task_id, project_id, who="agent.pm", agent_id=agent_id, comment="没有交付件") @@ -3033,7 +3136,8 @@ async def pm_review_run(project_id, agent_id=None, model_name=None): .replace('__ROLE_SKILLS__', role_skills) msgs = [{"role": "system", "content": pm_system}] - msgs.append({"role": "user", "content": f"请审核以下交付件(类型:{deliverable_type}):\n\n{content_preview}"}) + msgs.append({"role": "user", "content": f"请审核以下交付件(类型:{deliverable_type}):\n\n{content_preview}" + + _build_review_files_block(deliverable_files_json)}) # 编排缺口通知注入:代码查漏发现的 G1/G2/G3 缺口(此前只进日志没人看), # 这里随任务上下文交给 PM——PM 可在本回合核实后用 update_task_deps 等修正。 try: @@ -3360,6 +3464,7 @@ async def qc_review_run(project_id, agent_id=None, model_name=None): if task_kind == 'human_task_qc' and human_task_id: # 人类任务 QC:读 pipeline_human_tasks 的处理结果作为检查对象 + deliverable_files_json = '' ht_recs = await sor.sqlExe( "SELECT title, description, result_data FROM pipeline_human_tasks WHERE id=${hid}$", {"hid": human_task_id}) @@ -3379,7 +3484,7 @@ async def qc_review_run(project_id, agent_id=None, model_name=None): await qc_reject_task(task_id, project_id, who="agent.qc", agent_id=agent_id, comment="人类任务无处理结果") return {"status": "rejected", "task_id": task_id, "reason": "人类任务无处理结果"} else: - deliverable_content, deliverable_type = await _get_deliverable_content(sor, task_id) + deliverable_content, deliverable_type, deliverable_files_json = await _get_deliverable_content(sor, task_id) if not deliverable_content: from .task_capability import qc_reject_task await qc_reject_task(task_id, project_id, who="agent.qc", agent_id=agent_id, comment="没有交付件") @@ -3394,7 +3499,8 @@ async def qc_review_run(project_id, agent_id=None, model_name=None): .replace('__ROLE_SKILLS__', role_skills) msgs = [{"role": "system", "content": qc_system}] - msgs.append({"role": "user", "content": f"请检查以下交付件(类型:{deliverable_type}):\n\n{content_preview}"}) + msgs.append({"role": "user", "content": f"请检查以下交付件(类型:{deliverable_type}):\n\n{content_preview}" + + _build_review_files_block(deliverable_files_json)}) # 能力工具上下文(list_features 需要 project_id;QC 审查需求/设计时核对功能落库) capability_ctx = { diff --git a/pipeline_service/sdlc_ability.py b/pipeline_service/sdlc_ability.py index d5d1126..836a2a3 100644 --- a/pipeline_service/sdlc_ability.py +++ b/pipeline_service/sdlc_ability.py @@ -283,6 +283,7 @@ SDL_ROLES = [ system_prompt="""你是需求分析师。先 load_skill 加载 role 技能,按其中的职责与应遵守规范执行任务。""", next_role="agent.design", task_title="需求分析", + deliverable_types=["requirement_spec"], ), RoleSpec( name="agent.design", @@ -291,6 +292,7 @@ SDL_ROLES = [ system_prompt="""你是系统设计师。先 load_skill 加载 role 技能,按其中的职责与应遵守规范执行任务。""", next_role="agent.develop", task_title="应用架构与模块设计", + deliverable_types=["design_doc"], ), RoleSpec( name="agent.develop", @@ -299,6 +301,7 @@ SDL_ROLES = [ system_prompt="""你是开发工程师。先 load_skill 加载 role 技能,按其中的职责与应遵守规范执行任务。""", next_role="agent.deploy_test", task_title="应用脚手架开发", + deliverable_types=["code_files"], ), RoleSpec( name="agent.deploy_test", @@ -307,6 +310,7 @@ SDL_ROLES = [ system_prompt="""你是测试环境部署工程师。先 load_skill 加载 role 技能,按其中的职责与应遵守规范执行任务。""", next_role="agent.test", task_title="部署测试", + deliverable_types=["deploy_doc", "deploy_evidence", "deploy_config"], ), RoleSpec( name="agent.test", @@ -316,6 +320,7 @@ SDL_ROLES = [ # 生产部署需人工指令:test 通过后不自动派发 deploy_prod,deploy_prod 由用户明确指令触发 next_role="", task_title="功能测试", + deliverable_types=["test_report", "test_docs", "test_plan"], ), RoleSpec( name="agent.deploy_prod", @@ -323,6 +328,7 @@ SDL_ROLES = [ aliases=["deploy_production", "production", "release"], system_prompt="""你是生产环境部署工程师。先 load_skill 加载 role 技能,按其中的职责与应遵守规范执行任务。""", next_role="", + deliverable_types=["deploy_doc", "deploy_evidence", "release_notes"], ), RoleSpec( name="agent.qc",