refactor(agent_loop): 状态机迁移走 task 能力 + 角色规范化 agent.{role}

- 角色规范: agent 角色 agent.{role}(_normalize_role 补前缀+ROLE_ALIASES/ROLE_CHAIN/SDL_ROLES 改规范名),
  人角色 {orgtype}.{role}(默认 owner.superuser)
- role_agent_run/pm_review_run/handle_failed_task 硬编码状态 SQL 改 task_capability 工具
  (submit/approve/reject/complete/mark_failed/retry,CAS+审计)
- communication/questions/sdlc_ability 默认 handler 角色规范化(main_agent→agent.main_agent, customer→owner.superuser)
- 通用助手路径(AgentExecutor)不动,保持 hermes 式自由对话
This commit is contained in:
ymq 2026-08-16 23:51:55 +08:00
parent ec75ee0aad
commit bf819f3b79
4 changed files with 71 additions and 64 deletions

View File

@ -20,21 +20,21 @@ import logging
logger = logging.getLogger("pipeline.agent_loop")
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',
'developer': 'agent.develop', 'dev': 'agent.develop', 'development': 'agent.develop', 'coding': 'agent.develop',
'requirement': 'agent.requirement', 'requirements': 'agent.requirement', 'requirement_analysis': 'agent.requirement',
'designer': 'agent.design', 'ui': 'agent.design', 'ux': 'agent.design',
'testing': 'agent.test', 'qa': 'agent.test', 'tester': 'agent.test',
'deployment': 'agent.deploy', 'release': 'agent.deploy',
'operation': 'agent.ops', 'operations': 'agent.ops', 'maintenance': 'agent.ops', '运维': 'agent.ops',
'pm': 'agent.pm', 'project_manager': 'agent.pm', '项目经理': 'agent.pm', 'manager': 'agent.pm',
}
ROLE_CHAIN = {
'requirement': 'design',
'design': 'develop',
'develop': 'test',
'test': 'deploy',
'deploy': None,
'agent.requirement': 'agent.design',
'agent.design': 'agent.develop',
'agent.develop': 'agent.test',
'agent.test': 'agent.deploy',
'agent.deploy': None,
}
TASK_REVIEW = 'review'
@ -50,8 +50,12 @@ _ALLOWED_WORKDIRS = [
def _normalize_role(role):
"""角色规范化:别名映射 + 补 agent. 前缀(人角色 {orgtype}.{role} 保留原样)。"""
r = (role or '').strip().lower()
return ROLE_ALIASES.get(r, r)
r = ROLE_ALIASES.get(r, r)
if r and '.' not in r:
r = f"agent.{r}"
return r
async def _resolve_pipeline_id(project_id):
@ -455,7 +459,7 @@ async def _build_qna_section(sor, task_id, role, agent_id=None):
if qna:
lines.append("历史问答:")
for item in qna:
src = "客户" if item.get('answer_source') == "customer" else "主agent"
src = "业主" if item.get('answer_source') == "owner.superuser" else "主agent"
lines.append(f"问:{item.get('question','')}")
lines.append(f"答({src}){item.get('answer','')}")
return "\n".join(lines)
@ -756,7 +760,8 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
resp = await llm_call_msgs_native(msgs, tools=tools_schema, model=model_name, temperature=0.4)
except Exception as e:
err_msg = f"{type(e).__name__}: {str(e)[:400]}"
await sor.sqlExe("UPDATE pipeline_tasks SET state='failed', last_error=${e}$ WHERE id=${tid}$", {"e": err_msg, "tid": task_id})
from .task_capability import mark_failed
await mark_failed(task_id, project_id, who=role, agent_id=agent_id, error=err_msg)
logger.error(f"role_agent_run llm failed: task={task_id} err={e}")
return {"status": "failed", "task_id": task_id, "error": str(e)[:200]}
@ -813,7 +818,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
qid = await raise_problem("need_info", ask_question, role,
from_agentid=agent_id,
tenant_id=project_id, task_id=task_id,
first_handler_role="main_agent",
first_handler_role="agent.main_agent",
context={"title": title})
return {"status": "need_info", "task_id": task_id, "question_id": qid, "question": ask_question}
@ -879,11 +884,10 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
logger.info(f"git_commit_push {repo_name}: rc={r.get('rc', -1)} {r.get('message', '')[:100]}")
git_result = {"rc": 0, "message": "; ".join(git_results) if git_results else "无仓库可提交"}
await sor.sqlExe(
"UPDATE pipeline_tasks SET state=${st}$, claimed_by=NULL WHERE id=${tid}$",
{"st": TASK_REVIEW, "tid": task_id})
from .task_capability import submit_task
ok, _ = await submit_task(task_id, project_id, who=role, agent_id=agent_id)
logger.info(f"role_agent_run completed: task={task_id} role={role} "
f"deliverable={did} files={len(files_written)} git={git_result.get('rc',-1)}")
f"deliverable={did} files={len(files_written)} git={git_result.get('rc',-1)} submit={ok}")
return {"status": "completed", "task_id": task_id, "deliverable_id": did,
"files_written": len(files_written), "git_result": git_result}
@ -913,7 +917,8 @@ async def pm_review_run(project_id, agent_id=None, model_name=None):
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})
from .task_capability import reject_task
await reject_task(task_id, project_id, who="agent.pm", agent_id=agent_id, comment="没有交付件")
return {"status": "rejected", "task_id": task_id, "reason": "没有交付件"}
repo_state = await _get_repo_state(workspace_dir)
@ -949,7 +954,8 @@ async def pm_review_run(project_id, agent_id=None, model_name=None):
raw = await llm_call_msgs(msgs, model=model_name, temperature=0.3)
except Exception as e:
err_msg = f"{type(e).__name__}: {str(e)[:400]}"
await sor.sqlExe("UPDATE pipeline_tasks SET state='failed', last_error=${e}$ WHERE id=${tid}$", {"e": err_msg, "tid": task_id})
from .task_capability import mark_failed
await mark_failed(task_id, project_id, who="agent.pm", agent_id=agent_id, error=err_msg)
return {"status": "failed", "task_id": task_id, "error": str(e)[:200]}
act = _parse_agent_action(raw)
@ -999,7 +1005,8 @@ async def pm_review_run(project_id, agent_id=None, model_name=None):
"AND (problem_type='review_reject' "
" OR ((problem_type IS NULL OR problem_type='') AND from_role IN ('pm','cockpit')))",
{"a": "角色已响应PM审核通过", "role": task_role or "role_agent", "tid": task_id})
await sor.sqlExe("UPDATE pipeline_tasks SET state=${st}$, claimed_by=NULL WHERE id=${tid}$", {"st": TASK_APPROVED, "tid": task_id})
from .task_capability import approve_task
await approve_task(task_id, project_id, who="agent.pm", agent_id=agent_id, comment=comment)
next_role = await _get_next_role(task_role, project_id)
if next_role:
next_tid, next_title = await _create_next_task(sor, project_id, task, next_role, comment)
@ -1012,12 +1019,13 @@ async def pm_review_run(project_id, agent_id=None, model_name=None):
rejection_q = decision.get("questions") or comment or "交付件不满足要求"
# 审核退回:首处理方=被退角色(该角色任意 agent 重新认领响应)。
# suspend_task=False —— 任务回 submitted重新认领而非 waiting挂起等回答
await raise_problem("review_reject", rejection_q, "pm",
await raise_problem("review_reject", rejection_q, "agent.pm",
tenant_id=project_id, task_id=task_id,
first_handler_role=task_role,
context={"pm_comment": comment, "deliverable_type": deliverable_type},
suspend_task=False)
await sor.sqlExe("UPDATE pipeline_tasks SET state='submitted', claimed_by=NULL WHERE id=${tid}$", {"tid": task_id})
from .task_capability import reject_task
await reject_task(task_id, project_id, who="agent.pm", agent_id=agent_id, comment=comment)
return {"status": "rejected", "task_id": task_id, "comment": comment, "question": rejection_q}
else:
@ -1027,7 +1035,8 @@ async def pm_review_run(project_id, agent_id=None, model_name=None):
"UPDATE pipeline_deliverables SET review_status='approved', review_comment=${cm}$ "
"WHERE task_id=${tid}$ AND review_status='pending'",
{"cm": comment, "tid": task_id})
await sor.sqlExe("UPDATE pipeline_tasks SET state='completed', claimed_by=NULL WHERE id=${tid}$", {"tid": task_id})
from .task_capability import complete_task
await complete_task(task_id, project_id, who="agent.pm", agent_id=agent_id)
return {"status": "completed", "task_id": task_id, "comment": comment or "项目完成"}
@ -1089,22 +1098,19 @@ async def handle_failed_task(task_id: str, project_id: str) -> dict:
decision = _classify_failure(last_error)
if decision == "retry":
await sor.sqlExe(
"UPDATE pipeline_tasks SET retry_count=retry_count+1, state='submitted', "
"claimed_by=NULL, last_error=NULL, updated_at=NOW() "
"WHERE id=${tid}$ AND state='failed'",
{"tid": task_id})
logger.info("failed task retry: task=%s role=%s attempt=%d", task_id, role, retry_count + 1)
from .task_capability import retry_task
ok, _ = await retry_task(task_id, project_id, who="agent.pm")
logger.info("failed task retry: task=%s role=%s attempt=%d ok=%s", task_id, role, retry_count + 1, ok)
return {"status": "retry", "task_id": task_id, "attempt": retry_count + 1}
# 报故障:建问题通知用户,任务置 waiting 等待人工介入
from .communication import raise_problem
reporter = "cockpit" if role == "pm" else "pm"
reporter = "agent.main_agent" if role == "agent.pm" else "agent.pm"
qid = await raise_problem(
"fault_report",
f"任务「{title}」已失败 {retry_count} 次,自动重试无法解决,请人工介入。失败原因:{last_error or '未知'}",
reporter, tenant_id=project_id, task_id=task_id,
first_handler_role="main_agent",
first_handler_role="agent.main_agent",
context={"fault": True, "last_error": last_error, "retry_count": retry_count})
logger.info("failed task fault: task=%s role=%s qid=%s", task_id, role, qid)
return {"status": "fault", "task_id": task_id, "question_id": qid}

View File

@ -56,7 +56,7 @@ async def raise_problem(problem_type, question, from_role, from_agentid="",
"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
hr = first_handler_role or "main_agent"
hr = first_handler_role or "agent.main_agent"
ha = first_handler_agentid or ""
qid = getID()
ctx_json = json.dumps(context, ensure_ascii=False, default=str) if context else None
@ -83,7 +83,7 @@ async def raise_problem(problem_type, question, from_role, from_agentid="",
return qid
async def resolve_problem(question_id, answer, answered_by="", answer_source="main_agent",
async def resolve_problem(question_id, answer, answered_by="", answer_source="agent.main_agent",
resume_task=True):
"""当前处理方解决了问题 → 停止冒泡(status=answered),任务恢复 submitted。"""
db, dbname = _get_db()
@ -98,7 +98,7 @@ async def resolve_problem(question_id, answer, answered_by="", answer_source="ma
'id': question_id,
'answer': answer or '',
'answered_by': answered_by or '',
'answer_source': answer_source or 'main_agent',
'answer_source': answer_source or 'agent.main_agent',
'status': S_ANSWERED,
})

View File

@ -38,29 +38,30 @@ async def agent_ask(tenant_id: str, task_id: str, from_role: str, question: str,
新代码请直接调 communication.raise_problem 显式指定首处理方
"""
if from_role == "pm":
ptype, hr = "review_reject", "develop"
elif from_role == "cockpit":
ptype, hr = "fault_report", "main_agent"
r = (from_role or "").lower()
if "pm" in r:
ptype, hr = "review_reject", "agent.develop"
elif "main_agent" in r or "cockpit" in r:
ptype, hr = "fault_report", "agent.main_agent"
else:
ptype, hr = "need_info", "main_agent"
ptype, hr = "need_info", "agent.main_agent"
return await raise_problem(ptype, question, from_role, "",
tenant_id=tenant_id, task_id=task_id,
context=context, first_handler_role=hr)
async def answer_question(question_id: str, answer: str, answered_by: str = "",
answer_source: str = "main_agent", resume: bool = True):
answer_source: str = "agent.main_agent", resume: bool = True):
"""回答问题(解决 → 停止冒泡,任务恢复 submitted"""
return await resolve_problem(question_id, answer, answered_by, answer_source,
resume_task=resume)
async def forward_question(question_id: str, next_handler_role: str = "customer",
async def forward_question(question_id: str, next_handler_role: str = "owner.superuser",
next_handler_agentid: str = ""):
"""答不了 → 沿冒泡路径转给下一个处理方(默认客户)。"""
"""答不了 → 沿冒泡路径转给下一个处理方(默认业主/客户)。"""
return await escalate_problem(question_id, next_handler_role, next_handler_agentid,
by_role="main_agent")
by_role="agent.main_agent")
async def get_question(question_id: str):
@ -87,7 +88,7 @@ async def get_question(question_id: str):
async def list_questions(tenant_id: str = None, task_id: str = None,
status: str = None, limit: int = 50) -> list:
"""客户视角:列出当前该「客户(人)」处理的问题。"""
rows = await list_problems_for("customer", tenant_id=tenant_id,
rows = await list_problems_for("owner.superuser", tenant_id=tenant_id,
task_id=task_id, limit=limit)
if status:
rows = [r for r in rows if (r.get('status') or '') == status]

View File

@ -142,7 +142,7 @@ SDL_PROMPT = """你是「开发产线」的驾驶舱 agent负责软件项目
SDL_ROLES = [
RoleSpec(
name="requirement",
name="agent.requirement",
description="需求分析师",
aliases=["requirements", "requirement_analysis"],
system_prompt="""你是需求分析师。按 SDLC 仓库标准产出文档。
@ -153,10 +153,10 @@ SDL_ROLES = [
- modules/<模块名>.md: 模块功能仓库URL(先填写规划地址)技术栈依赖
- 每个应用至少关联一个模块
内容: 项目概述用户角色及权限功能列表(每个功能:输入/处理/输出/验收标准)非功能需求业务流程""",
next_role="design",
next_role="agent.design",
),
RoleSpec(
name="design",
name="agent.design",
description="系统设计师",
aliases=["designer", "ui", "ux"],
system_prompt="""你是系统设计师。按 SDLC 仓库标准产出文档。
@ -167,10 +167,10 @@ SDL_ROLES = [
- api-design.md: 接口列表(method/path/request/response)
- ui-design.md: 页面结构组件树(如适用)
产出后用 result 输出文档files 列出所有文件路径""",
next_role="develop",
next_role="agent.develop",
),
RoleSpec(
name="develop",
name="agent.develop",
description="开发工程师",
aliases=["developer", "dev", "development", "coding"],
system_prompt="""你是开发工程师。源码写入模块独立仓库,不在项目仓库。
@ -187,10 +187,10 @@ SDL_ROLES = [
8. write_file docs/02-develop/dev-notes.md 记录开发内容
9. deliver 交付files 列出所有产出文件路径
PM审核: git_status 检查模块仓库有提交记录""",
next_role="test",
next_role="agent.test",
),
RoleSpec(
name="test",
name="agent.test",
description="测试工程师",
aliases=["testing", "qa", "tester"],
system_prompt="""你是测试工程师。按 SDLC 仓库标准产出文档。
@ -200,10 +200,10 @@ PM审核: git_status 检查模块仓库有提交记录。""",
- test-cases.md: 用例清单(编号/前置条件/步骤/预期结果)
- test-report.md: 执行结果Bug清单覆盖率
测试脚本放到 files""",
next_role="deploy",
next_role="agent.deploy",
),
RoleSpec(
name="deploy",
name="agent.deploy",
description="部署运维工程师",
aliases=["deployment", "release"],
system_prompt="""你是部署运维工程师。按 SDLC 仓库标准产出文档和配置。
@ -227,7 +227,7 @@ async def _h_create_task(sor, p, ctx):
from appPublic.uniqueID import getID
title = p.get("title", "").strip()
role = p.get("role", "develop")
role = p.get("role", "agent.develop")
desc = p.get("description", "")
if not title:
return "需要任务标题"
@ -315,7 +315,7 @@ async def _h_start_agents(sor, p, ctx):
results = []
for r in recs:
tid = getattr(r, "id", "")
role = getattr(r, "role", "develop")
role = getattr(r, "role", "agent.develop")
try:
from pipeline_service.agent_loop import role_agent_run
result = await role_agent_run(pid, role)
@ -345,7 +345,7 @@ async def _h_diagnose_project(sor, p, ctx):
try:
from .communication import list_problems_for
# 待回答问题 = 当前该「主 agent」处理的问题不混入 PM 退回给角色 agent 的)
mine = await list_problems_for("main_agent", tenant_id=pid, limit=200)
mine = await list_problems_for("agent.main_agent", tenant_id=pid, limit=200)
qc = len(mine)
except Exception:
pass
@ -432,7 +432,7 @@ async def _h_list_questions(sor, p, ctx):
return "请先切换到项目"
from .communication import list_problems_for
# 只列「当前该主 agent 处理」的问题(不混入 PM 退回给角色 agent 的退回意见)
recs = await list_problems_for("main_agent", tenant_id=pid, limit=10)
recs = await list_problems_for("agent.main_agent", tenant_id=pid, limit=10)
if not recs:
return "没有待回答问题"
lines = []
@ -459,10 +459,10 @@ async def _h_escalate_question(sor, p, ctx):
if recs:
full_qid = getattr(recs[0], "id", qid)
# 主 agent 答不了 → 沿冒泡路径转给客户(下一个处理方由 team-communication skill 决定)
r = await escalate_problem(full_qid, "customer", by_role="main_agent")
r = await escalate_problem(full_qid, "owner.superuser", by_role="agent.main_agent")
if r is None:
return f"问题不存在: {qid}"
return f"OK: 已沿冒泡路径转给 {r.get('current_handler_role', 'customer')}"
return f"OK: 已沿冒泡路径转给 {r.get('current_handler_role', 'owner.superuser')}"
async def _h_answer_question(sor, p, ctx):
@ -482,8 +482,8 @@ async def _h_answer_question(sor, p, ctx):
task_id = getattr(recs[0], "task_id", "") or ""
await sor.sqlExe(
"UPDATE pipeline_agent_questions SET answer=${a}$, answer_source='main_agent', "
"answered_by='main_agent', status='answered' WHERE id=${qid}$",
"UPDATE pipeline_agent_questions SET answer=${a}$, answer_source='agent.main_agent', "
"answered_by='agent.main_agent', status='answered' WHERE id=${qid}$",
{"a": answer, "qid": full_qid})
resumed = False