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 接口
This commit is contained in:
yumoqing 2026-08-05 12:58:52 +08:00
parent 20896b6fa0
commit 4cb406003e
6 changed files with 470 additions and 46 deletions

View File

@ -0,0 +1,32 @@
{
"summary": [
{
"name": "pipeline_agent_questions",
"title": "角色agent问题回路表",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "tenant_id", "title": "租户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "task_id", "title": "任务ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "from_role", "title": "提问角色", "type": "str", "length": 32, "nullable": "no"},
{"name": "question", "title": "问题内容", "type": "text"},
{"name": "context", "title": "问题上下文(JSON)", "type": "text"},
{"name": "answer", "title": "回答内容", "type": "text"},
{"name": "answered_by", "title": "回答人", "type": "str", "length": 64, "nullable": "yes"},
{"name": "answer_source", "title": "回答来源", "type": "str", "length": 16, "nullable": "yes"},
{"name": "status", "title": "问题状态", "type": "str", "length": 32, "nullable": "no", "default": "pending"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_paq_tenant", "idxtype": "index", "idxfields": ["tenant_id"]},
{"name": "idx_paq_task", "idxtype": "index", "idxfields": ["task_id"]},
{"name": "idx_paq_status", "idxtype": "index", "idxfields": ["status"]}
],
"codes": [
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='question_state'"}
]
}

View File

@ -16,6 +16,8 @@
{"name": "state", "title": "任务状态", "type": "str", "length": 32, "nullable": "no", "default": "submitted"},
{"name": "current_version", "title": "当前版本号", "type": "int", "nullable": "no", "default": "1"},
{"name": "params", "title": "提交参数", "type": "text"},
{"name": "role", "title": "目标角色", "type": "str", "length": 32, "nullable": "no", "default": ""},
{"name": "claimed_by", "title": "认领agent标识", "type": "str", "length": 64, "nullable": "yes"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
],
@ -23,7 +25,8 @@
{"name": "idx_pt_tenant", "idxtype": "index", "idxfields": ["tenant_id"]},
{"name": "idx_pt_pipeline", "idxtype": "index", "idxfields": ["pipeline_id"]},
{"name": "idx_pt_owner", "idxtype": "index", "idxfields": ["owner_id"]},
{"name": "idx_pt_state", "idxtype": "index", "idxfields": ["state"]}
{"name": "idx_pt_state", "idxtype": "index", "idxfields": ["state"]},
{"name": "idx_pt_role", "idxtype": "index", "idxfields": ["role"]}
],
"codes": [
{"field": "state", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='task_state'"}

View File

@ -1,74 +1,216 @@
"""Agent loop — fetches tasks, executes with LLM, delivers results."""
"""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',
}
async def run_agent_loop(project_id, role_name, model_name=None):
"""Single iteration: fetch pending task, execute, deliver."""
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:
recs = await sor.sqlExe(
"SELECT id, title, params, pipeline_id FROM pipeline_tasks "
"WHERE state='submitted' AND tenant_id=${pid}$ LIMIT 1",
{"pid": project_id},
)
if not recs:
return {"status": "idle", "message": "No pending tasks"}
task = await _claim_task(sor, project_id, role)
if not task:
return {"status": "idle", "message": "没有待办任务"}
task = recs[0]
task_id = task.id
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='running' WHERE id=${tid}$",
{"tid": task_id},
)
title = getattr(task, "title", "") or ""
params_str = getattr(task, "params", "{}") or "{}"
from pipeline_service.llm_bridge import llm_call
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))
title = getattr(task, "title", "")
params_str = getattr(task, "params", "{}")
prompt = (
f"Execute this development task:\n"
f"Title: {title}\n"
f"Params: {params_str}\n\n"
f"Provide the result."
)
from .llm_bridge import llm_call
try:
result = await llm_call(prompt, model=model_name)
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},
)
{"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]}
from appPublic.uniqueID import getID
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": "code", "title": title, "content": result,
"deliverable_type": parsed.get("deliverable_type") or role,
"title": title, "content": result_text,
"quality_score": 80, "review_status": "pending",
"created_by": role_name,
"created_by": agent_id or role,
})
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='completed' WHERE id=${tid}$",
{"tid": task_id},
)
{"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 agent_loop(project_id, role_name, model_name=None, max_iterations=10):
"""Run agent loop until idle or max reached."""
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 run_agent_loop(project_id, role_name, model_name)
r = await role_agent_run(project_id, role, agent_id, model_name)
results.append(r)
if r["status"] == "idle":
if r["status"] in ("idle", "need_info", "failed"):
break
await asyncio.sleep(2)
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)

View File

@ -30,9 +30,14 @@ from .step_registry import (
unregister_step_type, load_builtin_types,
)
from .human import human_complete, approval_approve, approval_reject, human_list
from .questions import (
agent_ask, answer_question, forward_question,
get_question, list_questions, get_task_qna,
)
from .agent_loop import role_agent_run, role_agent_loop, agent_loop, run_agent_loop
MODULE_NAME = "pipeline_service"
MODULE_VERSION = "3.1.0"
MODULE_VERSION = "3.2.0"
async def pipeline_submit(tenant_id, pipeline_id, owner_id, title, params=None):
@ -77,6 +82,40 @@ async def pipeline_submit(tenant_id, pipeline_id, owner_id, title, params=None):
return json.dumps(result, ensure_ascii=False)
async def pipeline_role_submit(tenant_id, pipeline_id, owner_id, title, params=None, role=""):
"""提交角色任务主agent → 角色agent 模式v3.2.0)。
pipeline_submit 的区别
- role 字段由角色agent循环role_agent_loop认领执行
- 不创建步骤记录不启动 DAG executor
Args:
role: 目标角色requirement/design/dev/test/ops 必填
Returns:
JSON string with success, task_id
"""
result = {"success": False}
try:
if not tenant_id:
result["message"] = "缺少 tenant_id"
return json.dumps(result, ensure_ascii=False)
if not role:
result["message"] = "缺少 role角色任务必须指定目标角色"
return json.dumps(result, ensure_ascii=False)
params = params or {}
task_id = await create_task(tenant_id, pipeline_id or "", owner_id, title, params, role=role)
result["success"] = True
result["task_id"] = task_id
result["role"] = role
result["message"] = f"角色任务已提交role={role}等待角色agent认领执行"
except Exception as e:
result["message"] = str(e)
return json.dumps(result, ensure_ascii=False)
async def pipeline_list(tenant_id, pipeline_id=None, limit=100):
"""查询租户的任务列表。"""
result = {"success": False}
@ -337,6 +376,7 @@ def load_pipeline_service():
# Task lifecycle
env.pipeline_submit = pipeline_submit
env.pipeline_role_submit = pipeline_role_submit
env.pipeline_list = pipeline_list
env.pipeline_detail = pipeline_detail
env.pipeline_node = pipeline_node
@ -370,11 +410,22 @@ def load_pipeline_service():
# Register intent classifier + LLM bridge (shared across all pipelines)
from .intent_classifier import intent_classify
from .llm_bridge import llm_call
from .agent_loop import agent_loop, run_agent_loop
env.intent_classify = intent_classify
env.pipeline_llm_call = llm_call
env.agent_loop = agent_loop
env.run_agent_loop = run_agent_loop
debug(f"[{MODULE_NAME}] v{MODULE_VERSION} loaded — pipeline engine with human-in-the-loop support")
# Role-agent loop (v3.2.0: 主agent + 角色agent 认领执行)
env.role_agent_run = role_agent_run
env.role_agent_loop = role_agent_loop
env.agent_loop = agent_loop # 向后兼容
env.run_agent_loop = run_agent_loop # 向后兼容
# Question loop (角色agent ↔ 主agent ↔ 客户)
env.agent_ask = agent_ask
env.question_answer = answer_question
env.question_forward = forward_question
env.question_detail = get_question
env.question_list = list_questions
env.question_qna = get_task_qna
debug(f"[{MODULE_NAME}] v{MODULE_VERSION} loaded — pipeline engine with role-agent + question loop")
return True

View File

@ -0,0 +1,190 @@
"""问题回路 — 角色agent缺信息时提问主agent路由回答。
流程
1. 角色agent执行任务时发现缺关键信息 agent_ask() 写入 pipeline_agent_questions
(status=pending)任务置 waiting退出本轮执行
2. 主agent轮询 pending 问题
- 能直接回答 answer_question(answer_source='main_agent')任务回 submitted
角色agent下轮重新认领并带着问答历史继续
- 答不了 forward_question() 转客户 (status=forwarded)
客户在会话中回答后主agent调 answer_question(answer_source='customer')
"""
import json
import logging
from sqlor.dbpools import DBPools
from appPublic.uniqueID import getID
DBNAME = "pipeline"
logger = logging.getLogger("pipeline.questions")
# 问题状态
Q_PENDING = "pending" # 待主agent回答
Q_FORWARDED = "forwarded" # 主agent已转客户等客户回复
Q_ANSWERED = "answered" # 已回答
def _get_db():
db = DBPools()
if not db.databases:
from appPublic.jsonConfig import getConfig
config = getConfig()
if config.databases:
db.databases = config.databases
return db, DBNAME
async def agent_ask(tenant_id: str, task_id: str, from_role: str, question: str, context: dict = None) -> str:
"""角色agent提问。任务置 waiting等待主agent/客户回答。返回 question_id。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
qid = getID()
ctx_json = json.dumps(context, ensure_ascii=False, default=str) if context else None
await sor.C('pipeline_agent_questions', {
"id": qid,
"tenant_id": tenant_id or "",
"task_id": task_id or "",
"from_role": from_role or "",
"question": question or "",
"context": ctx_json,
"status": Q_PENDING,
})
if task_id:
await sor.U('pipeline_tasks', {"id": task_id, "state": "waiting"})
logger.info(f"agent_ask: task={task_id} role={from_role} qid={qid}")
return qid
async def answer_question(question_id: str, answer: str, answered_by: str = "",
answer_source: str = "main_agent", resume: bool = True):
"""回答问题。任务从 waiting 回 submitted让角色agent重新认领继续执行。
Args:
answer_source: 'main_agent' 'customer'
resume: 是否把任务恢复为 submitted
Returns:
更新后的问题记录 dict问题不存在返回 None
"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('pipeline_agent_questions', {'id': question_id})
if not recs:
return None
rec = recs[0]
task_id = rec.task_id if hasattr(rec, 'task_id') else rec['task_id']
await sor.U('pipeline_agent_questions', {
"id": question_id,
"answer": answer or "",
"answered_by": answered_by or "",
"answer_source": answer_source or "main_agent",
"status": Q_ANSWERED,
})
resumed = False
if resume and task_id:
trecs = await sor.R('pipeline_tasks', {'id': task_id})
if trecs:
t = trecs[0]
tstate = t.state if hasattr(t, 'state') else t['state']
if tstate == "waiting":
await sor.U('pipeline_tasks', {"id": task_id, "state": "submitted"})
resumed = True
logger.info(f"answer_question: qid={question_id} task={task_id} source={answer_source} resumed={resumed}")
return {
"id": question_id,
"task_id": task_id,
"status": Q_ANSWERED,
"answer_source": answer_source,
"resumed": resumed,
}
async def forward_question(question_id: str):
"""主agent答不了 → 转给客户。status: pending → forwarded。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
await sor.U('pipeline_agent_questions', {"id": question_id, "status": Q_FORWARDED})
logger.info(f"forward_question: qid={question_id}")
async def get_question(question_id: str):
"""取单条问题记录(含 context 解析)。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R('pipeline_agent_questions', {'id': question_id})
if not recs:
return None
rec = recs[0]
if hasattr(rec, '__dict__'):
d = {k: getattr(rec, k) for k in dir(rec) if not k.startswith('_')}
else:
d = dict(rec)
ctx = d.get('context')
if ctx and isinstance(ctx, str):
try:
d['context'] = json.loads(ctx)
except (json.JSONDecodeError, TypeError):
pass
return d
async def list_questions(tenant_id: str = None, task_id: str = None,
status: str = None, limit: int = 50) -> list:
"""问题列表,支持租户/任务/状态过滤。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
conditions = []
params = {}
if tenant_id:
conditions.append("tenant_id=${tenant_id}$")
params["tenant_id"] = tenant_id
if task_id:
conditions.append("task_id=${task_id}$")
params["task_id"] = task_id
if status:
conditions.append("status=${status}$")
params["status"] = status
where = " AND ".join(conditions) if conditions else "1=1"
try:
limit = int(limit)
except (TypeError, ValueError):
limit = 50
sql = f"SELECT * FROM pipeline_agent_questions WHERE {where} ORDER BY created_at DESC LIMIT {limit}"
recs = await sor.sqlExe(sql, params)
result = []
for rec in (recs or []):
if hasattr(rec, '__dict__'):
d = {k: getattr(rec, k) for k in dir(rec)
if not k.startswith('_') and not callable(getattr(rec, k))}
else:
d = dict(rec)
ctx = d.get('context')
if ctx and isinstance(ctx, str):
try:
d['context'] = json.loads(ctx)
except (json.JSONDecodeError, TypeError):
pass
result.append(d)
return result
async def get_task_qna(task_id: str) -> list:
"""取任务的全部已回答问答按时间序用于角色agent继续执行时注入prompt。"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT question, answer, answer_source, from_role FROM pipeline_agent_questions "
"WHERE task_id=${tid}$ AND status='answered' ORDER BY created_at ASC",
{"tid": task_id})
result = []
for rec in (recs or []):
result.append({
"question": getattr(rec, 'question', '') or '',
"answer": getattr(rec, 'answer', '') or '',
"answer_source": getattr(rec, 'answer_source', '') or '',
"from_role": getattr(rec, 'from_role', '') or '',
})
return result

View File

@ -47,8 +47,13 @@ async def get_pipeline_steps(pipeline_id: str) -> list:
return result
async def create_task(tenant_id: str, pipeline_id: str, owner_id: str, title: str, params: dict) -> str:
"""Create a new pipeline task. Returns task_id."""
async def create_task(tenant_id: str, pipeline_id: str, owner_id: str, title: str, params: dict, role: str = "") -> str:
"""Create a new pipeline task. Returns task_id.
Args:
role: 目标角色需求/设计/开发/测试/运维空串=不限角色兼容旧任务
带角色的任务由角色agent循环认领执行不带步骤记录
"""
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
task_id = getID()
@ -61,6 +66,7 @@ async def create_task(tenant_id: str, pipeline_id: str, owner_id: str, title: st
"state": "submitted",
"current_version": 1,
"params": json.dumps(params, ensure_ascii=False, default=str),
"role": role or "",
}
await sor.C('pipeline_tasks', data)
return task_id