"""Agent loop — 角色agent循环认领执行任务(v3.3.0)。 v3.3.0 新增: - PM(项目经理)角色:审核交付件,决定通过/驳回 - 任务链:requirement → design → develop → test → deploy 前序角色完成后 → PM审核通过 → 自动创建下一角色任务 - 产物文件输出:交付件写入项目工作目录 deliverables/{role}/ - 工作目录自动创建:确保 workspace_dir 存在 架构: - 主agent(cockpit_chat)理解客户意图,开发类意图写入 pipeline_tasks(带 role)。 - 各角色agent(需求/设计/开发/测试/运维)调 role_agent_run/role_agent_loop 循环认领自己角色的任务并执行。 - PM agent 调 pm_review_loop 循环审核处于 review 状态的任务。 - 认领是原子的:UPDATE ... WHERE state='submitted' + claimed_by 令牌校验, 多agent并发不会重复认领。 - 角色agent缺信息时返回 need_info → 写入 pipeline_agent_questions, 任务置 waiting,等主agent/客户回答后回到 submitted 重新认领继续。 - 只认领没有步骤记录的任务(带步骤的 DAG 任务归 executor 管)。 """ import asyncio import json import os import logging logger = logging.getLogger("pipeline.agent_loop") # 角色别名归一化:前端/主agent传的角色名统一到认领用的规范名 ROLE_ALIASES = { 'developer': 'develop', 'dev': 'develop', 'development': 'develop', 'coding': 'develop', 'requirement': 'requirement', 'requirements': 'requirement', 'requirement_analysis': 'requirement', 'designer': 'design', 'ui': 'design', 'ux': 'design', 'testing': 'test', 'qa': 'test', 'tester': 'test', 'deployment': 'deploy', 'release': 'deploy', 'operation': 'ops', 'operations': 'ops', 'maintenance': 'ops', '运维': 'ops', 'pm': 'pm', 'project_manager': 'pm', '项目经理': 'pm', 'manager': 'pm', } # 任务链:前序角色完成后,PM审核通过 → 自动创建下一角色任务 # None 表示链末端 ROLE_CHAIN = { 'requirement': 'design', 'design': 'develop', 'develop': 'test', 'test': 'deploy', 'deploy': None, # 部署完成 = 链结束 } # PM 审核后任务状态 TASK_REVIEW = 'review' # 角色agent完成,待PM审核 TASK_APPROVED = 'approved' # PM审核通过 def _normalize_role(role): r = (role or '').strip().lower() return ROLE_ALIASES.get(r, r) ROLE_PROMPT = """你是软件开发产线项目中的「__ROLE__」角色agent。 当前认领到的任务: 标题:__TITLE__ 参数:__PARAMS__ __QNA__ ## 项目信息 - 工作目录:__WORKSPACE__ - 你的产出物将保存到:__DELIVERABLE_DIR__ 请站在你的角色立场完成这项工作。 ## 产出规范 1. 产出内容必须完整、可交付、可直接使用 2. 产出格式:根据角色不同,产出对应格式: - requirement(需求分析):需求规格文档(Markdown格式) - design(设计):设计文档 / 架构图描述(Markdown格式) - develop(开发):可运行的代码 + 代码说明 - test(测试):测试用例 + 测试报告(Markdown格式) - deploy(部署):部署文档 / 配置 / 脚本 输出要求——返回纯 JSON(不要 markdown 包裹): - 能完成时:{"status":"done","deliverable_type":"文档类型(requirement_doc/design_doc/code/test_report/deploy_doc)","result":"完整交付内容","summary":"一段话概述产出的关键结论"} - 缺少关键信息无法继续时:{"status":"need_info","question":"要向主agent/客户提出的具体问题","partial":"已完成的部分"}""" PM_PROMPT = """你是软件开发产线项目中的「项目经理(PM)」角色agent。 你的职责是审核其他角色agent产出的交付件,确保质量和完整性,并推动项目前进。 ## 任务链 项目按照以下阶段推进: 1. requirement(需求分析)→ 2. design(设计)→ 3. develop(开发)→ 4. test(测试)→ 5. deploy(部署) 当前待审核的任务: 标题:__TITLE__ 角色:__ROLE__ 交付件内容: __DELIVERABLE__ ## 你的决策 请基于交付件内容做出判断,输出纯JSON(不要markdown包裹): 1. 审核通过,启动下一阶段: {"status":"approved","comment":"审核意见","next_task_title":"下一阶段任务标题","next_task_description":"下一阶段任务详细描述"} 2. 审核不通过,需要修改: {"status":"rejected","comment":"驳回原因和具体修改要求","questions":"需要角色agent澄清的具体问题"} 3. 项目已完成(deploy阶段通过后): {"status":"completed","comment":"项目完成总结"} ## 审核标准 - requirement:需求是否清晰、完整、可量化 - design:方案是否合理、技术选型是否恰当、是否覆盖需求 - develop:代码是否可运行、是否遵循规范、是否实现设计 - test:测试覆盖是否充分、是否发现关键问题 - deploy:部署文档是否完整、配置是否正确""" def _get_db(): from sqlor.dbpools import DBPools db = DBPools() if not db.databases: from appPublic.jsonConfig import getConfig config = getConfig() if config.databases: db.databases = config.databases return db def _resolve_workspace(workspace_dir): """解析工作目录,如果不可写则回退到 /tmp。确保目录存在。""" if not workspace_dir: workspace_dir = '/tmp/pipeline_workspaces/default' # 检测是否可写 try: parent = os.path.dirname(workspace_dir) if parent and not os.access(parent, os.W_OK): # 回退到 /tmp proj_name = os.path.basename(workspace_dir) workspace_dir = f'/tmp/pipeline_workspaces/{proj_name}' except Exception: workspace_dir = f'/tmp/pipeline_workspaces/{os.path.basename(workspace_dir) or "default"}' # 确保目录存在 os.makedirs(workspace_dir, exist_ok=True) return workspace_dir async def _claim_task(sor, tenant_id: str, role: str, state: str = 'submitted'): """原子认领一条符合角色的任务。返回 task record 或 None。 认领策略: 1. 选出最早一条符合状态和角色的任务, 且排除有步骤记录的 DAG 任务(那些归 executor)。 2. UPDATE ... SET state='running', claimed_by=令牌 WHERE id=? AND state=原状态 3. 用令牌回查校验——别的agent抢先时校验失败,返回 None。 """ role = _normalize_role(role) recs = await sor.sqlExe( "SELECT id, title, params, pipeline_id, tenant_id, role FROM pipeline_tasks " "WHERE tenant_id=${tid}$ AND state=${state}$ AND (role=${role}$ OR role='') " "AND NOT EXISTS (SELECT 1 FROM pipeline_task_steps s WHERE s.task_id=pipeline_tasks.id) " "ORDER BY created_at ASC LIMIT 1", {"tid": tenant_id, "state": state, "role": role}) if not recs: return None task = recs[0] task_id = task.id from appPublic.uniqueID import getID claim_token = getID() await sor.sqlExe( "UPDATE pipeline_tasks SET state='running', claimed_by=${cb}$ " "WHERE id=${tid}$ AND state=${state}$", {"cb": claim_token, "tid": task_id, "state": state}) # 令牌校验:确认是本agent抢到的 check = await sor.sqlExe( "SELECT id FROM pipeline_tasks WHERE id=${tid}$ AND state='running' AND claimed_by=${cb}$", {"tid": task_id, "cb": claim_token}) if not check: logger.info(f"claim lost race: task={task_id} role={role}") return None return task async def _get_workspace_dir(sor, project_id): """获取项目的工作目录,确保可写并存在。""" recs = await sor.sqlExe( "SELECT workspace_dir FROM sd_projects WHERE id=${pid}$", {"pid": project_id}) ws = getattr(recs[0], 'workspace_dir', '') if recs else '' return _resolve_workspace(ws) async def _build_qna_section(sor, task_id: str) -> str: """把该任务已回答的问答历史注入prompt,让角色agent带着答案继续。""" from .questions import get_task_qna qna = await get_task_qna(task_id) if not qna: return "" lines = ["历史问答(这些问题已得到回答,请结合答案继续工作):"] for item in qna: src = item.get('answer_source') or '' src_label = "客户" if src == "customer" else "主agent" lines.append(f"问:{item.get('question', '')}") lines.append(f"答({src_label}):{item.get('answer', '')}") return "\n".join(lines) def _parse_result(raw: str) -> dict: """解析角色agent的 LLM 返回。解析失败时按 done + 原文处理。""" raw = (raw or "").strip() if raw.startswith("```"): raw = raw.split("\n", 1)[1].rsplit("```", 1)[0] try: d = json.loads(raw) if isinstance(d, dict): return d except (json.JSONDecodeError, ValueError): pass return {"status": "done", "result": raw} async def _write_deliverable_file(workspace_dir, role, task_id, deliverable_type, content): """将交付件写入项目工作目录的文件中。""" role_dir = os.path.join(workspace_dir, 'deliverables', role) os.makedirs(role_dir, exist_ok=True) # 文件名:任务ID_类型.md safe_type = (deliverable_type or 'deliverable').replace('/', '_') filename = f"{task_id}_{safe_type}.md" file_path = os.path.join(role_dir, filename) try: with open(file_path, 'w', encoding='utf-8') as f: f.write(content or '') logger.info(f"deliverable written: {file_path}") return file_path except Exception as e: logger.error(f"failed to write deliverable file: {file_path} err={e}") return '' async def _get_deliverable_content(sor, task_id): """获取任务的最新交付件内容。""" recs = await sor.sqlExe( "SELECT content, deliverable_type 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 '', '' async def _get_next_role(current_role): """获取任务链中的下一个角色。""" return ROLE_CHAIN.get(_normalize_role(current_role)) async def _create_next_task(sor, project_id, task, next_role, pm_comment=''): """为下一个角色创建任务。""" from appPublic.uniqueID import getID title = getattr(task, 'title', '') or '' params_str = getattr(task, 'params', '{}') or '{}' try: params = json.loads(params_str) if isinstance(params_str, str) else params_str except (json.JSONDecodeError, TypeError): params = {} new_title = f"{title}({next_role}阶段)" new_params = { **params, 'previous_role': _normalize_role(getattr(task, 'role', '')), 'previous_task_id': getattr(task, 'id', ''), 'pm_comment': pm_comment, } new_task_id = getID() await sor.C('pipeline_tasks', { 'id': new_task_id, 'tenant_id': project_id, 'pipeline_id': 'role_task', 'title': new_title, 'params': json.dumps(new_params, ensure_ascii=False), 'role': next_role, 'state': 'submitted', 'claimed_by': None, }) logger.info(f"_create_next_task: {next_role} task={new_task_id} from {getattr(task, 'id', '')}") return new_task_id, new_title async def role_agent_run(project_id: str, role: str, agent_id: str = None, model_name: str = None) -> dict: """角色agent单次迭代:认领一条任务 → 执行 → 交付。 非PM角色完成 → 任务置 review(等PM审核) PM角色 → 由 pm_review_run 处理 Returns: {"status": "idle"} 没有待办任务 {"status": "completed", "task_id", "deliverable_id"} 完成交付(已进入review) {"status": "need_info", "task_id", "question_id", "question"} 缺信息已提问 {"status": "failed", "task_id", "error"} 执行失败 """ role = _normalize_role(role) if role == 'pm': # PM不执行角色任务,走pm_review_run return {"status": "idle", "message": "PM agent请使用 pm_review_run"} db = _get_db() async with db.sqlorContext("pipeline") as sor: task = await _claim_task(sor, project_id, role) if not task: return {"status": "idle", "message": "没有待办任务"} task_id = task.id title = getattr(task, "title", "") or "" params_str = getattr(task, "params", "{}") or "{}" # 获取工作目录 workspace_dir = await _get_workspace_dir(sor, project_id) deliverable_dir = os.path.join(workspace_dir, 'deliverables', role) os.makedirs(deliverable_dir, exist_ok=True) qna_section = await _build_qna_section(sor, task_id) prompt = (ROLE_PROMPT .replace('__ROLE__', role) .replace('__TITLE__', title) .replace('__PARAMS__', params_str) .replace('__QNA__', qna_section) .replace('__WORKSPACE__', workspace_dir) .replace('__DELIVERABLE_DIR__', deliverable_dir)) from .llm_bridge import llm_call try: raw = await llm_call(prompt, model=model_name, temperature=0.4) except Exception as e: await sor.sqlExe( "UPDATE pipeline_tasks SET state='failed' WHERE id=${tid}$", {"tid": task_id}) logger.error(f"role_agent_run llm failed: task={task_id} err={e}") return {"status": "failed", "task_id": task_id, "error": str(e)[:200]} parsed = _parse_result(raw) # 缺信息 → 提问回路:任务置 waiting,等主agent/客户回答 if parsed.get("status") == "need_info" and parsed.get("question"): from .questions import agent_ask qid = await agent_ask(project_id, task_id, role, parsed["question"], context={"partial": parsed.get("partial", ""), "title": title}) return {"status": "need_info", "task_id": task_id, "question_id": qid, "question": parsed["question"]} # 完成 → 写交付件到文件 + DB from appPublic.uniqueID import getID did = getID() result_text = parsed.get("result") or raw deliverable_type = parsed.get("deliverable_type") or role summary = parsed.get("summary", "") # 写文件 file_path = await _write_deliverable_file( workspace_dir, role, task_id, deliverable_type, result_text) # 写DB 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, "quality_score": 80, "review_status": "pending", "created_by": agent_id or role, }) # 置为 review 状态,等待PM审核 await sor.sqlExe( "UPDATE pipeline_tasks SET state=${st}$ WHERE id=${tid}$", {"st": TASK_REVIEW, "tid": task_id}) logger.info(f"role_agent_run completed(review): task={task_id} role={role} deliverable={did} file={file_path}") return {"status": "completed", "task_id": task_id, "deliverable_id": did, "next_state": TASK_REVIEW, "file_path": file_path} async def role_agent_loop(project_id: str, role: str, agent_id: str = None, model_name: str = None, max_iterations: int = 10) -> list: """角色agent循环:连续认领执行,直到没有任务、需要提问或达到上限。""" results = [] for _ in range(max_iterations): r = await role_agent_run(project_id, role, agent_id, model_name) results.append(r) if r["status"] in ("idle", "need_info", "failed"): break await asyncio.sleep(1) return results # ── PM 审核 ── async def pm_review_run(project_id: str, agent_id: str = None, model_name: str = None) -> dict: """PM审核单次迭代:认领一条 review 状态的任务 → 审核交付件 → 批准/驳回。 Returns: {"status": "idle"} 没有待审核任务 {"status": "approved", "task_id", "next_task_id", "next_role"} 审核通过,已创建下一阶段任务 {"status": "rejected", "task_id"} 驳回,任务回到 submitted {"status": "completed", "task_id"} 项目完成(链末端) """ db = _get_db() async with db.sqlorContext("pipeline") as sor: # 认领 review 状态的任务(不限角色,PM审核所有角色产出) task = await _claim_task(sor, project_id, 'pm', state=TASK_REVIEW) if not task: return {"status": "idle", "message": "没有待审核任务"} task_id = task.id title = getattr(task, "title", "") or "" task_role = _normalize_role(getattr(task, "role", "") or "") # 获取交付件内容 deliverable_content, deliverable_type = await _get_deliverable_content(sor, task_id) if not deliverable_content: # 没有交付件 → 驳回 await sor.sqlExe( "UPDATE pipeline_tasks SET state='submitted' WHERE id=${tid}$", {"tid": task_id}) return {"status": "rejected", "task_id": task_id, "reason": "没有找到交付件内容,任务已退回"} # 截断过长内容(LLM有token上限) content_preview = deliverable_content[:6000] if len(deliverable_content) > 6000: content_preview += "\n\n...(内容过长已截断)" prompt = (PM_PROMPT .replace('__TITLE__', title) .replace('__ROLE__', task_role) .replace('__DELIVERABLE__', content_preview)) from .llm_bridge import llm_call try: raw = await llm_call(prompt, model=model_name, temperature=0.3) except Exception as e: logger.error(f"pm_review_run llm failed: task={task_id} err={e}") await sor.sqlExe( "UPDATE pipeline_tasks SET state='failed' WHERE id=${tid}$", {"tid": task_id}) return {"status": "failed", "task_id": task_id, "error": str(e)[:200]} parsed = _parse_result(raw) decision = parsed.get("status", "rejected") comment = parsed.get("comment", "") if decision == "approved": # 获取工作目录 workspace_dir = await _get_workspace_dir(sor, project_id) # 写PM审核记录到交付件 from appPublic.uniqueID import getID pm_did = getID() await sor.C("pipeline_deliverables", { "id": pm_did, "project_id": project_id, "task_id": task_id, "deliverable_type": "pm_review", "title": f"PM审核:{title}", "content": json.dumps(parsed, ensure_ascii=False), "file_path": os.path.join(workspace_dir, 'deliverables', 'pm', f"{task_id}_review.md"), "quality_score": 100, "review_status": "approved", "created_by": agent_id or "pm", }) # 更新原交付件审核状态 await sor.sqlExe( "UPDATE pipeline_deliverables SET review_status='approved', " "review_comment=${cm}$ WHERE task_id=${tid}$", {"cm": comment, "tid": task_id}) # 任务置为 approved await sor.sqlExe( "UPDATE pipeline_tasks SET state=${st}$ WHERE id=${tid}$", {"st": TASK_APPROVED, "tid": task_id}) # 检查是否有下一阶段 next_role = await _get_next_role(task_role) if next_role: next_tid, next_title = await _create_next_task(sor, project_id, task, next_role, comment) logger.info(f"pm_review: approved task={task_id}, created next={next_tid} role={next_role}") return {"status": "approved", "task_id": task_id, "next_task_id": next_tid, "next_role": next_role, "next_title": next_title, "comment": comment} else: # 链末端(deploy审核通过 = 项目完成) logger.info(f"pm_review: project completed, task={task_id} role={task_role}") return {"status": "completed", "task_id": task_id, "comment": comment or "项目所有阶段已完成"} elif decision == "rejected": # 驳回:任务回到 submitted,附带PM驳回原因作为question from .questions import agent_ask rejection_question = parsed.get("questions") or comment or "交付件不满足要求,请修改后重新提交" await agent_ask(project_id, task_id, "pm", rejection_question, context={"pm_comment": comment, "deliverable_type": deliverable_type}) # 任务回到 submitted(通过回答问题的resume机制) # 但agent_ask已经把任务置waiting,需要让主agent/客户确认后才能回到submitted # 简化为:直接回 submitted await sor.sqlExe( "UPDATE pipeline_tasks SET state='submitted' WHERE id=${tid}$", {"tid": task_id}) logger.info(f"pm_review: rejected task={task_id}") return {"status": "rejected", "task_id": task_id, "comment": comment, "question": rejection_question} else: # completed(项目完成) await sor.sqlExe( "UPDATE pipeline_tasks SET state='completed' WHERE id=${tid}$", {"tid": task_id}) return {"status": "completed", "task_id": task_id, "comment": comment or "项目完成"} async def pm_review_loop(project_id: str, agent_id: str = None, model_name: str = None, max_iterations: int = 20) -> list: """PM审核循环:连续审核处于 review 状态的任务。""" results = [] for _ in range(max_iterations): r = await pm_review_run(project_id, agent_id, model_name) results.append(r) if r["status"] in ("idle", "failed"): break await asyncio.sleep(1) return results # ── 向后兼容别名(旧前端按 run_agent_loop/agent_loop 调用)── async def run_agent_loop(project_id, role_name, model_name=None): """旧接口:单次迭代。role_name 为空时按 'dev' 处理。""" role = _normalize_role(role_name or "develop") if role == 'pm': return await pm_review_run(project_id, model_name=model_name) return await role_agent_run(project_id, role_name or "develop", model_name=model_name) async def agent_loop(project_id, role_name, model_name=None, max_iterations=10): """旧接口:循环执行。role_name 为空时按 'dev' 处理。""" role = _normalize_role(role_name or "develop") if role == 'pm': return await pm_review_loop(project_id, model_name=model_name, max_iterations=max_iterations) return await role_agent_loop(project_id, role_name or "develop", model_name=model_name, max_iterations=max_iterations)