yumoqing 4cb406003e feat(v3.2.0): 主agent+角色agent架构 — role任务认领/问题回路
- pipeline_tasks 增加 role/claimed_by 字段+索引(存量库需ALTER)
- agent_loop 重写为角色循环:原子认领(role匹配+令牌校验)→LLM执行→交付/need_info提问
- 角色别名归一化(developer→develop等),ROLE_PROMPT改replace防花括号崩溃
- questions.py 新增问题回路:agent_ask/answer_question/forward_question/list_questions
- init.py 注册 pipeline_role_submit 与问题回路 env 接口
2026-08-05 12:58:52 +08:00

217 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Agent loop — 角色agent循环认领执行任务v3.2.0)。
架构:
- 主agentcockpit_chat理解客户意图开发类意图写入 pipeline_tasks带 role
- 各角色agent需求/设计/开发/测试/运维)调 role_agent_run/role_agent_loop
循环认领自己角色的任务并执行。
- 认领是原子的UPDATE ... WHERE state='submitted' + claimed_by 令牌校验,
多agent并发不会重复认领。
- 角色agent缺信息时返回 need_info → 写入 pipeline_agent_questions
任务置 waiting等主agent/客户回答后回到 submitted 重新认领继续。
- 只认领没有步骤记录的任务(带步骤的 DAG 任务归 executor 管)。
"""
import asyncio
import json
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',
}
def _normalize_role(role):
r = (role or '').strip().lower()
return ROLE_ALIASES.get(r, r)
ROLE_PROMPT = """你是软件开发产线项目中的「__ROLE__」角色agent。
当前认领到的任务:
标题__TITLE__
参数__PARAMS__
__QNA__
请站在你的角色立场完成这项工作。
输出要求——返回纯 JSON不要 markdown 包裹):
- 能完成时:{"status":"done","deliverable_type":"...","result":"完整交付内容"}
- 缺少关键信息无法继续时:{"status":"need_info","question":"要向主agent/客户提出的具体问题","partial":"已完成的部分"}"""
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
async def _claim_task(sor, tenant_id: str, role: str):
"""原子认领一条符合角色的 submitted 任务。返回 task record 或 None。
认领策略:
1. 选出最早一条 state='submitted' 且 role 匹配(或 role 为空)的任务,
且排除有步骤记录的 DAG 任务(那些归 executor
2. UPDATE ... SET state='running', claimed_by=令牌 WHERE id=? AND state='submitted'
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='submitted' 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, "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='submitted'",
{"cb": claim_token, "tid": task_id})
# 令牌校验确认是本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 _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 role_agent_run(project_id: str, role: str, agent_id: str = None, model_name: str = None) -> dict:
"""角色agent单次迭代认领一条任务 → 执行 → 交付或提问。
Returns:
{"status": "idle"} 没有待办任务
{"status": "completed", "task_id", "deliverable_id"} 完成交付
{"status": "need_info", "task_id", "question_id", "question"} 缺信息已提问
{"status": "failed", "task_id", "error"} 执行失败
"""
role = _normalize_role(role)
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 "{}"
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))
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 pipeline_service.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"]}
# 完成 → 写交付件
from appPublic.uniqueID import getID
did = getID()
result_text = parsed.get("result") or raw
await sor.C("pipeline_deliverables", {
"id": did, "project_id": project_id, "task_id": task_id,
"deliverable_type": parsed.get("deliverable_type") or role,
"title": title, "content": result_text,
"quality_score": 80, "review_status": "pending",
"created_by": agent_id or role,
})
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='completed' WHERE id=${tid}$",
{"tid": task_id})
logger.info(f"role_agent_run completed: task={task_id} role={role} deliverable={did}")
return {"status": "completed", "task_id": task_id, "deliverable_id": did}
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
# ── 向后兼容别名(旧前端按 run_agent_loop/agent_loop 调用)──
async def run_agent_loop(project_id, role_name, model_name=None):
"""旧接口单次迭代。role_name 为空时按 'dev' 处理。"""
return await role_agent_run(project_id, role_name or "dev", model_name=model_name)
async def agent_loop(project_id, role_name, model_name=None, max_iterations=10):
"""旧接口循环执行。role_name 为空时按 'dev' 处理。"""
return await role_agent_loop(project_id, role_name or "dev",
model_name=model_name, max_iterations=max_iterations)