598 lines
37 KiB
Plaintext
598 lines
37 KiB
Plaintext
# cockpit_chat.dspy - SDLC Agent Loop v3.1 (streaming NDJSON)
|
||
import aiohttp
|
||
|
||
action = (params_kw or {}).get('action', 'send_message')
|
||
# AgentIO sends JSON body without action field — detect from message_text presence
|
||
if action != 'send_message':
|
||
p = params_kw or {}
|
||
msg = (p.get('message_text') or '').strip()
|
||
if not msg:
|
||
inner = p.get('params', {})
|
||
if isinstance(inner, dict):
|
||
msg = (inner.get('prompt') or inner.get('message_text') or '').strip()
|
||
if msg:
|
||
action = 'send_message'
|
||
dbname = get_module_dbname('pipeline-sdlc')
|
||
|
||
AGENT_PROMPT = """你是开发产线驾驶舱 Agent。用工具获取数据,用 reply 回复。
|
||
|
||
## 回复格式
|
||
- 用换行分段,不要一大段文字
|
||
- 列表项前加 - 或数字
|
||
- 重要信息用 **粗体**
|
||
- 代码/命令用 `` 包裹
|
||
- 简洁直接,先说结论再展开
|
||
|
||
## 核心原则
|
||
- 环境信息中已显示当前项目。所有操作在当前项目内完成
|
||
- 用户输入为主要依据,历史记录仅作参考
|
||
- **reply 只用于汇报已完成的结果,不要用 reply 描述"我将要做XX"——想做就直接调 tool_call 去做**
|
||
- 诊断 → 调 diagnose_project。回答 → 调 answer_question。启动 → 调 start_agents。执行,不描述
|
||
|
||
## 工作流
|
||
- 切换项目:用户说"切换到XXX项目" → switch_project
|
||
- 删除项目:delete_project → 看到确认提示后,用户回复「确认删除 xxx」→ 再调 _sys_delete_project
|
||
- 查看进度:list_tasks / task_detail / list_deliverables
|
||
- 提交任务:先确保在正确项目,再 create_task
|
||
- 诊断项目:diagnose_project 找出卡点/失败任务/待回答问题
|
||
- 启动Agent:任务提交后调 start_agents 让角色Agent执行
|
||
- 回答问题:list_questions 看问题 → answer_question 回答。待回答问题在环境信息中已列出,主动提示用户
|
||
- clone_repo 用于克隆 git 仓库到工作空间
|
||
- run_command 用于在工作空间中执行 shell 命令
|
||
|
||
输出格式二选一:
|
||
{"action":"tool_call","tool":"工具名","params":{}}
|
||
{"action":"reply","message":"中文回复"}
|
||
|
||
示例——错误做法(用reply描述计划):
|
||
{"action":"reply","message":"我来逐一回答这三个问题"} ← 错!应该直接调工具
|
||
|
||
示例——正确做法(用tool_call执行,最后用reply汇报):
|
||
{"action":"tool_call","tool":"answer_question","params":{"question_id":"sx728Vhq","answer":"..."}}
|
||
{"action":"tool_call","tool":"answer_question","params":{"question_id":"C0ujsOUj","answer":"..."}}
|
||
{"action":"tool_call","tool":"start_agents","params":{}}
|
||
{"action":"reply","message":"三个问题已回答,Agent已启动"}
|
||
|
||
工具: __TOOLS__
|
||
环境: __ENV__"""
|
||
|
||
TOOLS = [
|
||
{"name":"switch_project","description":"切换到指定项目","params":{"project":"项目名称"}},
|
||
{"name":"create_project","description":"创建新项目","params":{"name":"项目名称","description":"项目描述(可选)"}},
|
||
{"name":"delete_project","description":"删除项目(返回确认提示,用户确认后再调用_sys_delete_project执行)","params":{"project":"项目名称"}},
|
||
{"name":"list_tasks","description":"列出当前项目任务","params":{"state":"状态(可选)"}},
|
||
{"name":"task_detail","description":"查看任务详情(含交付件和问答)","params":{"task_id":"任务ID"}},
|
||
{"name":"list_deliverables","description":"列出当前项目交付件","params":{"task_id":"任务ID(可选)"}},
|
||
{"name":"view_deliverable","description":"查看交付件内容","params":{"deliverable_id":"交付件ID"}},
|
||
{"name":"list_questions","description":"列出待回答的问题","params":{}},
|
||
{"name":"answer_question","description":"回答agent提出的问题","params":{"question_id":"问题ID","answer":"回答内容"}},
|
||
{"name":"create_task","description":"提交开发任务到指定角色","params":{"title":"标题","description":"描述","role":"requirement/design/develop/test/deploy"}},
|
||
{"name":"start_agents","description":"启动项目角色agents执行待办任务","params":{}},
|
||
{"name":"diagnose_project","description":"诊断项目状态:找出卡点、失败任务、待回答问题,给出推进建议","params":{}},
|
||
{"name":"check_progress","description":"查看项目整体进度","params":{"task_id":"任务ID(可选)"}},
|
||
{"name":"add_repo","description":"关联仓库到当前项目","params":{"url":"git地址","name":"名称(可选)"}},
|
||
{"name":"list_repos","description":"列出当前项目仓库","params":{}},
|
||
{"name":"clone_repo","description":"克隆仓库到工作空间","params":{"url":"git地址","branch":"分支名(可选,默认main)"}},
|
||
{"name":"add_bug","description":"报告Bug","params":{"title":"标题","description":"描述"}},
|
||
{"name":"run_command","description":"在工作空间中执行shell命令","params":{"cmd":"命令"}},
|
||
]
|
||
TOOLS_TEXT = json.dumps(TOOLS, ensure_ascii=False)
|
||
|
||
def _security_scan(text):
|
||
t = (text or '').lower()
|
||
for kw in ('drop table','truncate','rm -rf','密钥','api_key'):
|
||
if kw in t: return True, '危险操作'
|
||
return False, ''
|
||
|
||
def _guess_role(title):
|
||
t = (title or '').lower()
|
||
for kw,r in [('需求','requirement'),('设计','design'),('开发','develop'),('实现','develop'),('测试','test'),('部署','deploy')]:
|
||
if kw in t: return r
|
||
return 'develop'
|
||
|
||
async def _find_project(sor, name):
|
||
"""Exact match only — LLM has full project list in env and should pick exact name."""
|
||
if not name: return None
|
||
recs = await sor.sqlExe("SELECT id,name FROM sd_projects WHERE name=${n}$",{"n":name})
|
||
return recs[0] if recs else None
|
||
|
||
async def _load_ctx(sor, uid):
|
||
if not uid:
|
||
return {'pid':'','pname':'','ws':'','skills_dir':'','repos':[]}
|
||
recs = await sor.sqlExe("SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$",{"u":uid})
|
||
ctx = {'pid':'','pname':'','ws':'','skills_dir':'','repos':[]}
|
||
if not recs: return ctx
|
||
ctx['pid'] = getattr(recs[0],'current_project_id','') or ''
|
||
if not ctx['pid']:
|
||
# Fallback: recover from most recent conversation's iteration_id (which stores project_id)
|
||
last = await sor.sqlExe("SELECT iteration_id FROM pipeline_conversations WHERE created_by=${u}$ AND iteration_id != '' ORDER BY created_at DESC LIMIT 1",{"u":uid})
|
||
if last:
|
||
ctx['pid'] = getattr(last[0],'iteration_id','') or ''
|
||
if ctx['pid']:
|
||
projs = await sor.sqlExe("SELECT name,workspace_dir,org_id FROM sd_projects WHERE id=${p}$",{"p":ctx['pid']})
|
||
if projs:
|
||
ctx['pname'] = getattr(projs[0],'name','')
|
||
ctx['ws'] = getattr(projs[0],'workspace_dir','') or ''
|
||
oid = getattr(projs[0],'org_id','') or '0'
|
||
orgs = await sor.sqlExe("SELECT skills_dir FROM sd_org_settings WHERE org_id=${o}$",{"o":oid})
|
||
if orgs: ctx['skills_dir'] = getattr(orgs[0],'skills_dir','') or ''
|
||
repos = await sor.sqlExe("SELECT repo_name,repo_url FROM sd_project_repos WHERE project_id=${p}$",{"p":ctx['pid']})
|
||
ctx['repos'] = [{'n':r.repo_name,'u':r.repo_url} for r in (repos or [])]
|
||
return ctx
|
||
|
||
async def _save_ctx(sor, uid, pid):
|
||
if not uid:
|
||
return
|
||
try:
|
||
await sor.sqlExe("UPDATE pipeline_agent_settings SET current_project_id=${p}$ WHERE user_id=${u}$",{"p":pid or '','u':uid})
|
||
ex = await sor.sqlExe("SELECT id FROM pipeline_agent_settings WHERE user_id=${u}$",{"u":uid})
|
||
if not ex:
|
||
await sor.C('pipeline_agent_settings',{'id':getID(),'user_id':uid,'default_llm_id':'','current_project_id':pid or '','current_iteration_id':''})
|
||
except:
|
||
# Duplicate key race — update instead
|
||
try:
|
||
await sor.sqlExe("UPDATE pipeline_agent_settings SET current_project_id=${p}$ WHERE user_id=${u}$",{"p":pid or '','u':uid})
|
||
except:
|
||
pass
|
||
|
||
async def _sel_model(sor, pref):
|
||
if pref:
|
||
recs = await sor.sqlExe("SELECT id,name,provider,model_id,api_base,api_key FROM llm WHERE (id=${l}$ OR name=${l}$) AND status='active'",{"l":pref})
|
||
if recs: return recs[0]
|
||
recs = await sor.sqlExe("SELECT id,name,provider,model_id,api_base,api_key FROM llm WHERE status='active' LIMIT 1",{})
|
||
return recs[0] if recs else None
|
||
|
||
async def _call_llm_raw(prompt, temp=0.0):
|
||
"""Lightweight LLM call for classification tasks. Uses first available model."""
|
||
to = aiohttp.ClientTimeout(total=30)
|
||
async with DBPools().sqlorContext(dbname) as sor:
|
||
model = await _sel_model(sor, None)
|
||
if not model:
|
||
return "不存在"
|
||
hdrs = {"Authorization": f"Bearer {model.api_key or ''}", "Content-Type": "application/json"}
|
||
payload = {"model": model.model_id, "messages": [{"role":"user","content":prompt}], "temperature": temp}
|
||
async with aiohttp.ClientSession(timeout=to) as s:
|
||
async with s.post(f"{model.api_base.rstrip('/')}/chat/completions", headers=hdrs, json=payload) as r:
|
||
if r.status != 200: raise ValueError(f"LLM {r.status}")
|
||
return (await r.json())["choices"][0]["message"]["content"]
|
||
|
||
async def _call_llm(model, msgs, temp):
|
||
hdrs = {"Authorization": f"Bearer {model.api_key or ''}", "Content-Type": "application/json"}
|
||
payload = {"model": model.model_id, "messages": msgs, "temperature": temp}
|
||
to = aiohttp.ClientTimeout(total=180)
|
||
async with aiohttp.ClientSession(timeout=to) as s:
|
||
async with s.post(f"{model.api_base.rstrip('/')}/chat/completions", headers=hdrs, json=payload) as r:
|
||
if r.status != 200: raise ValueError(f"LLM {r.status}")
|
||
return (await r.json())["choices"][0]["message"]["content"]
|
||
|
||
def _parse(raw):
|
||
raw = (raw or "").strip()
|
||
if raw.startswith("```"): raw = raw.split("\n",1)[1].rsplit("```",1)[0].strip()
|
||
try:
|
||
d = json.loads(raw)
|
||
if isinstance(d,dict) and 'action' in d: return d
|
||
except: pass
|
||
# Strip any {"action":"tool_call"...} JSON fragments from reply text
|
||
import re as _re2
|
||
clean = _re2.sub(r'\s*\{[^}]*"action"\s*:\s*"tool_call"[^}]*\}\s*', ' ', raw).strip()
|
||
# Remove stray trailing braces from malformed JSON attempts
|
||
clean = clean.rstrip().rstrip('}').strip()
|
||
return {"action":"reply","message":clean or raw}
|
||
|
||
def _w_text(t): return {"widgettype":"Text","options":{"text":t,"css":"agent-text","halign":"left"}}
|
||
def _w_md(t): return {"widgettype":"MdWidget","options":{"mdtext":t,"css":"resp-content","width":"100%"}}
|
||
def _w_card(title, body, kind="success"):
|
||
colors = {"success":"#10b981","error":"#ef4444","reply":"#6366f1","progress":"#f59e0b"}
|
||
c = colors.get(kind,"#6b7280")
|
||
return {"widgettype":"VBox","options":{"style":{"borderLeft":f"3px solid {c}","padding":"8px 12px","margin":"4px 0"}},"subwidgets":[
|
||
{"widgettype":"Text","options":{"text":title,"style":{"fontWeight":"bold","fontSize":"13px","color":c}}},
|
||
body
|
||
]}
|
||
def _w_progress(text): return {"widgettype":"Text","options":{"text":text,"style":{"color":"#f59e0b","fontSize":"12px","padding":"2px 8px"},"halign":"left"}}
|
||
|
||
_tool_labels = {'switch_project':'切换项目','create_project':'创建项目','delete_project':'删除项目','list_tasks':'查询任务','task_detail':'任务详情','list_deliverables':'查看交付件','view_deliverable':'交付件内容','list_questions':'查看问题','answer_question':'回答问题','create_task':'创建任务','start_agents':'启动角色Agent','diagnose_project':'项目诊断','check_progress':'检查进度','add_repo':'关联仓库','list_repos':'查看仓库','clone_repo':'克隆仓库','add_bug':'报告Bug','run_command':'执行命令'}
|
||
|
||
async def _exec_tool(sor, tool, params, ctx, uid, org_id):
|
||
p = params or {}
|
||
try:
|
||
if tool == 'create_project':
|
||
name = (p.get('name','') or '').strip()
|
||
if not name: return 'FAIL: 需要项目名称'
|
||
ex = await sor.sqlExe("SELECT id FROM sd_projects WHERE name=${n}$",{"n":name})
|
||
if ex: return f'FAIL: 项目「{name}」已存在'
|
||
pid = getID()
|
||
wid = getID()
|
||
ws = f'/d/pipeline/workspaces/{org_id}/{name}'
|
||
await sor.C('sd_projects',{'id':pid,'name':name,'description':p.get('description',''),'workspace_dir':ws,'org_id':org_id,'created_by':uid})
|
||
await sor.C('sd_iterations',{'id':wid,'project_id':pid,'iteration_name':'默认迭代','iteration_type':'default','status':'active','priority':1,'org_id':org_id})
|
||
await _save_ctx(sor, uid, pid)
|
||
ctx['pid'] = pid; ctx['pname'] = name; ctx['ws'] = ws
|
||
return f'OK: 已创建并切换到「{name}」'
|
||
elif tool == 'delete_project':
|
||
name = (p.get('project','') or '').strip()
|
||
if not name: return 'FAIL: 需要项目名称'
|
||
proj = await _find_project(sor, name)
|
||
if not proj: return f'FAIL: 项目「{name}」不存在'
|
||
return f'CONFIRM: 确定要删除项目「{name}」吗?此操作不可撤销,将清除所有关联数据。请回复「确认删除 {name}」继续,回复其他内容取消。'
|
||
elif tool == '_sys_delete_project':
|
||
name = (p.get('project','') or '').strip()
|
||
if not name: return 'FAIL: 缺少项目名称'
|
||
proj = await _find_project(sor, name)
|
||
if not proj: return f'FAIL: 项目「{name}」不存在'
|
||
pid = proj.id
|
||
# 级联删除关联数据(表名及对应的project关联字段)
|
||
tables = [
|
||
('pipeline_deliverables', 'project_id'),
|
||
('pipeline_tasks', 'tenant_id'),
|
||
('pipeline_agent_questions', 'tenant_id'),
|
||
('sd_project_repos', 'project_id'),
|
||
('sd_iterations', 'project_id'),
|
||
('sd_bugs', 'iteration_id IN (SELECT id FROM sd_iterations WHERE project_id=${p}$)'),
|
||
]
|
||
count = 0
|
||
for tbl, cond in tables:
|
||
if cond == '1=0': continue
|
||
sql = f"DELETE FROM {tbl} WHERE {cond}=${{p}}$"
|
||
await sor.sqlExe(sql, {"p": pid})
|
||
count += 1
|
||
await sor.sqlExe("DELETE FROM pipeline_agent_settings WHERE current_project_id=${p}$", {"p": pid})
|
||
await sor.D('sd_projects', {'id': pid})
|
||
if ctx['pid'] == pid:
|
||
ctx['pid'] = ''; ctx['pname'] = ''; ctx['ws'] = ''; ctx['repos'] = []
|
||
return f'OK: 已删除「{name}」及其所有关联数据'
|
||
elif tool == 'switch_project':
|
||
name = (p.get('project','') or '').strip()
|
||
if not name:
|
||
# No project specified — return available projects
|
||
allp = await sor.sqlExe("SELECT id,name FROM sd_projects ORDER BY created_at DESC LIMIT 20",{})
|
||
if not allp: return '无可用项目'
|
||
lines = []
|
||
for r in allp:
|
||
marker = '★' if getattr(r,'id','') == ctx['pid'] else ' '
|
||
lines.append(f"{marker} {getattr(r,'name','')}")
|
||
return '可用项目:\n'+'\n'.join(lines)
|
||
proj = await _find_project(sor, name)
|
||
if not proj:
|
||
# LLM classification: ask LLM to match user input against project list
|
||
allp = await sor.sqlExe("SELECT id,name FROM sd_projects ORDER BY created_at DESC LIMIT 20",{})
|
||
pnames = [getattr(r,'name','') for r in (allp or [])]
|
||
classify_prompt = f"用户输入: {name}\n项目列表: {', '.join(pnames)}\n\n判断用户想要哪个项目。只回复项目名或\"不存在\"。"
|
||
try:
|
||
raw = await _call_llm_raw(classify_prompt, 0.0)
|
||
matched = raw.strip().strip('"').strip("'")
|
||
for r in (allp or []):
|
||
if getattr(r,'name','') == matched:
|
||
proj = r; break
|
||
except:
|
||
pass
|
||
if not proj:
|
||
allp2 = await sor.sqlExe("SELECT name FROM sd_projects ORDER BY created_at DESC LIMIT 15",{})
|
||
return 'FAIL: 未找到。可用: '+', '.join([getattr(r,'name','') for r in (allp2 or [])])
|
||
if proj.id == ctx['pid']:
|
||
return f'已在「{proj.name}」项目中,无需切换'
|
||
await _save_ctx(sor, uid, proj.id)
|
||
ctx['pid'] = proj.id; ctx['pname'] = proj.name
|
||
return 'OK: 已切换到「'+proj.name+'」'
|
||
elif tool == 'create_task':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
title = p.get('title','')[:100]
|
||
if not title: return 'FAIL: 缺少标题'
|
||
role = p.get('role','') or _guess_role(title)
|
||
tp = {'description':p.get('description',''),'project_id':ctx['pid']}
|
||
r = await pipeline_role_submit(ctx['pid'],'role_task',uid,title,tp,role)
|
||
rd = json.loads(r)
|
||
if rd.get('success'): return f"OK: 任务「{title}」已创建(角色:{role})"
|
||
return f"FAIL: {rd.get('message','')}"
|
||
elif tool == 'check_progress':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
tid = p.get('task_id','')
|
||
if tid:
|
||
ts = await sor.sqlExe("SELECT title,state,role FROM pipeline_tasks WHERE id=${t}$",{"t":tid})
|
||
if not ts: return 'FAIL: 任务不存在'
|
||
t = ts[0]; return f"[{t.state}][{t.role}] {t.title}"
|
||
ts = await sor.sqlExe("SELECT state,count(*)c FROM pipeline_tasks WHERE tenant_id=${p}$ GROUP BY state",{"p":ctx['pid']})
|
||
summary = ', '.join([f"{r.state}:{r.c}" for r in (ts or [])]) or '无'
|
||
ags = await sor.sqlExe("SELECT role,count(*)c FROM pipeline_tasks WHERE tenant_id=${p}$ AND state IN ('submitted','running') GROUP BY role",{"p":ctx['pid']})
|
||
al = ', '.join([f"{getattr(r,'role','')}:{getattr(r,'c','')}" for r in (ags or [])]) or '无活跃任务'
|
||
ds = await sor.sqlExe("SELECT deliverable_type,title,review_status FROM pipeline_deliverables WHERE project_id=${p}$ ORDER BY created_at DESC LIMIT 3",{"p":ctx['pid']})
|
||
dl = '\n'.join([str(d.deliverable_type)+' '+str(d.title)[:40] for d in (ds or [])]) or '无'
|
||
return f"任务: {summary}\nAgent: {al}\n交付:\n{dl}"
|
||
elif tool == 'list_tasks':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
sf = p.get('state','')
|
||
sql = "SELECT title,state,role FROM pipeline_tasks WHERE tenant_id=${p}$"
|
||
if sf: sql += " AND state=${s}$"
|
||
sql += " ORDER BY created_at DESC LIMIT 20"
|
||
ts = await sor.sqlExe(sql, {"p":ctx['pid'],"s":sf} if sf else {"p":ctx['pid']})
|
||
em = {'submitted':'⏳','running':'🔄','review':'👀','approved':'✅','completed':'🏁','failed':'❌'}
|
||
return '\n'.join([f"{em.get(t.state,'?')}[{t.state}][{t.role}]{t.title}" for t in ts]) if ts else '无任务'
|
||
elif tool == 'add_repo':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
url = p.get('url','')
|
||
if not url: return 'FAIL: 需要仓库地址'
|
||
name = p.get('name','') or url.rstrip('/').split('/')[-1].replace('.git','')
|
||
ex = await sor.sqlExe("SELECT id FROM sd_project_repos WHERE project_id=${p}$ AND repo_url=${u}$",{"p":ctx['pid'],"u":url})
|
||
if ex: return '仓库已关联'
|
||
await sor.C('sd_project_repos',{'id':getID(),'project_id':ctx['pid'],'repo_name':name,'repo_url':url,'default_branch':'main','local_path':'','org_id':org_id})
|
||
ctx['repos'].append({'n':name,'u':url})
|
||
return f"OK: 已关联 {name}"
|
||
elif tool == 'list_repos':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
rs = await sor.sqlExe("SELECT repo_name,repo_url FROM sd_project_repos WHERE project_id=${p}$",{"p":ctx['pid']})
|
||
return '\n'.join([f"{r.repo_name}: {r.repo_url}" for r in rs]) if rs else '无仓库'
|
||
elif tool == 'clone_repo':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
url = p.get('url','')
|
||
if not url: return 'FAIL: 需要仓库地址'
|
||
branch = p.get('branch','main')
|
||
ws = ctx.get('ws','')
|
||
if not ws: return 'FAIL: 项目无工作空间路径'
|
||
import subprocess, os
|
||
repo_name = url.rstrip('/').split('/')[-1].replace('.git','')
|
||
target = os.path.join(ws, repo_name)
|
||
if os.path.exists(target): return f'FAIL: 目录已存在 {target}'
|
||
os.makedirs(ws, exist_ok=True)
|
||
cmd = f'git clone -b {branch} {url} {target}'
|
||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120, cwd=ws)
|
||
if r.returncode != 0: return f'FAIL: git clone失败: {r.stderr[:200]}'
|
||
# Auto-add repo to project
|
||
ex = await sor.sqlExe("SELECT id FROM sd_project_repos WHERE project_id=${p}$ AND repo_url=${u}$",{"p":ctx['pid'],"u":url})
|
||
if not ex:
|
||
await sor.C('sd_project_repos',{'id':getID(),'project_id':ctx['pid'],'repo_name':repo_name,'repo_url':url,'default_branch':branch,'local_path':target,'org_id':org_id})
|
||
ctx['repos'].append({'n':repo_name,'u':url})
|
||
return f'OK: 已克隆到 {target}'
|
||
elif tool == 'run_command':
|
||
import subprocess
|
||
ws = ctx.get('ws','') or '/tmp'
|
||
cmd = p.get('cmd','')
|
||
if not cmd: return 'FAIL: 需要命令'
|
||
# Safety: block destructive commands
|
||
blocked = ['rm -rf /','mkfs.','dd if=',':(){','chmod 777 /']
|
||
for b in blocked:
|
||
if b in cmd: return 'FAIL: 危险命令被拦截'
|
||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60, cwd=ws)
|
||
out = (r.stdout + r.stderr)[:2000]
|
||
return f'OK (rc={r.returncode}):\n{out}' if out else f'OK (rc={r.returncode})'
|
||
elif tool == 'task_detail':
|
||
tid = p.get('task_id','')
|
||
if not tid:
|
||
# No task_id provided — show first few pending/running tasks for context
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目并指定任务ID'
|
||
ts = await sor.sqlExe("SELECT id,title,state,role FROM pipeline_tasks WHERE tenant_id=${p}$ AND state IN ('submitted','running','waiting') ORDER BY created_at ASC LIMIT 5",{"p":ctx['pid']})
|
||
if not ts: return '无活跃任务,请用 list_tasks 查看全部'
|
||
return '当前活跃任务(请指定 task_id 查看详情):\n'+'\n'.join([f" [{getattr(t,'state','')}] {getattr(t,'id','')[:12]} [{getattr(t,'role','')}] {getattr(t,'title','')[:60]}" for t in ts])
|
||
ts = await sor.sqlExe("SELECT * FROM pipeline_tasks WHERE id=${t}$",{"t":tid})
|
||
if not ts: return 'FAIL: 任务不存在'
|
||
t = ts[0]
|
||
ds = await sor.sqlExe("SELECT deliverable_type,title,review_status,quality_score FROM pipeline_deliverables WHERE task_id=${t}$",{"t":tid})
|
||
dl = '\n'.join([f" [{getattr(d,'review_status','')}] {getattr(d,'deliverable_type','')}: {getattr(d,'title','')[:60]}" for d in (ds or [])]) or ' 无'
|
||
qs = await sor.sqlExe("SELECT question,answer,status FROM pipeline_agent_questions WHERE task_id=${t}$ ORDER BY created_at DESC LIMIT 5",{"t":tid})
|
||
ql = '\n'.join([f" [{getattr(q,'status','')}] Q:{getattr(q,'question','')[:60]}\n A:{getattr(q,'answer','') or '待回答'}" for q in (qs or [])]) or ' 无'
|
||
return f"任务: {getattr(t,'title','')}\n状态: {getattr(t,'state','')}\n角色: {getattr(t,'role','')}\n交付件:\n{dl}\n问答:\n{ql}"
|
||
elif tool == 'list_deliverables':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
tid = p.get('task_id','')
|
||
if tid:
|
||
ds = await sor.sqlExe("SELECT id,deliverable_type,title,review_status,quality_score FROM pipeline_deliverables WHERE task_id=${t}$ ORDER BY created_at DESC",{"t":tid})
|
||
else:
|
||
ds = await sor.sqlExe("SELECT id,deliverable_type,title,review_status,quality_score FROM pipeline_deliverables WHERE project_id=${p}$ ORDER BY created_at DESC LIMIT 20",{"p":ctx['pid']})
|
||
if not ds: return '无交付件'
|
||
return '\n'.join([f"[{getattr(d,'review_status','?')}] {getattr(d,'id','')[:8]} {getattr(d,'deliverable_type','')}: {getattr(d,'title','')[:60]} ({getattr(d,'quality_score','-')}分)" for d in ds])
|
||
elif tool == 'view_deliverable':
|
||
did = p.get('deliverable_id','')
|
||
if not did: return 'FAIL: 需要交付件ID'
|
||
ds = await sor.sqlExe("SELECT deliverable_type,title,content,review_status,quality_score FROM pipeline_deliverables WHERE id=${d}$",{"d":did})
|
||
if not ds: return 'FAIL: 交付件不存在'
|
||
d = ds[0]
|
||
return f"类型: {getattr(d,'deliverable_type','')}\n标题: {getattr(d,'title','')}\n状态: {getattr(d,'review_status','')} | 评分: {getattr(d,'quality_score','')}\n\n{getattr(d,'content','')[:3000]}"
|
||
elif tool == 'list_questions':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
qs = await sor.sqlExe("SELECT id,task_id,question,answer,status,created_at FROM pipeline_agent_questions WHERE tenant_id=${p}$ AND status='pending' ORDER BY created_at DESC LIMIT 10",{"p":ctx['pid']})
|
||
if not qs: return '无待回答问题'
|
||
return '\n'.join([f"[{getattr(q,'id','')[:8]}] 任务:{getattr(q,'task_id','')[:8]} Q:{getattr(q,'question','')[:80]}" for q in qs])
|
||
elif tool == 'answer_question':
|
||
qid = p.get('question_id','')
|
||
ans = p.get('answer','')
|
||
if not qid: return 'FAIL: 需要问题ID'
|
||
if not ans: return 'FAIL: 需要回答内容'
|
||
qs = await sor.sqlExe("SELECT id,status FROM pipeline_agent_questions WHERE id=${q}$",{"q":qid})
|
||
if not qs: return 'FAIL: 问题不存在'
|
||
await sor.sqlExe("UPDATE pipeline_agent_questions SET answer=${a}$, status='answered', answered_at=NOW() WHERE id=${q}$",{"a":ans,"q":qid})
|
||
return f'OK: 已回答'
|
||
elif tool == 'start_agents':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
from pipeline_service.agent_loop import role_agent_run, pm_review_run
|
||
import asyncio as _asyncio
|
||
results = []
|
||
for role in ['requirement','design','develop','test','deploy']:
|
||
r = await role_agent_run(ctx['pid'], role)
|
||
if r['status'] != 'idle':
|
||
results.append(f"{role}: {r['status']} task={r.get('task_id','')[:8]}")
|
||
pm_r = await pm_review_run(ctx['pid'])
|
||
if pm_r['status'] != 'idle':
|
||
results.append(f"pm_review: {pm_r['status']} task={pm_r.get('task_id','')[:8]}")
|
||
return '\n'.join(results) if results else '无待执行任务'
|
||
elif tool == 'diagnose_project':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
pid = ctx['pid']
|
||
# 1) Stuck submitted tasks
|
||
stuck = await sor.sqlExe("SELECT id,title,role,state,created_at FROM pipeline_tasks WHERE tenant_id=${p}$ AND state IN ('submitted','waiting') ORDER BY created_at ASC LIMIT 10",{"p":pid})
|
||
# 2) Failed tasks
|
||
failed = await sor.sqlExe("SELECT id,title,role,created_at FROM pipeline_tasks WHERE tenant_id=${p}$ AND state='failed' ORDER BY created_at DESC LIMIT 5",{"p":pid})
|
||
# 3) Pending questions
|
||
qs = await sor.sqlExe("SELECT id,question,status,task_id FROM pipeline_agent_questions WHERE tenant_id=${p}$ AND status='pending' LIMIT 5",{"p":pid})
|
||
# 4) Running tasks
|
||
running = await sor.sqlExe("SELECT id,title,role FROM pipeline_tasks WHERE tenant_id=${p}$ AND state='running' LIMIT 5",{"p":pid})
|
||
# 5) Review tasks
|
||
review = await sor.sqlExe("SELECT id,title,role FROM pipeline_tasks WHERE tenant_id=${p}$ AND state='review' LIMIT 5",{"p":pid})
|
||
lines = [f"项目诊断: {ctx['pname']}"]
|
||
if stuck:
|
||
lines.append(f"\n📌 卡住的任务({len(stuck)}个):")
|
||
for t in stuck:
|
||
lines.append(f" [{t.state}] [{t.role}] {t.title[:60]}")
|
||
if failed:
|
||
lines.append(f"\n❌ 失败任务({len(failed)}个):")
|
||
for t in failed:
|
||
lines.append(f" [{t.role}] {t.title[:60]}")
|
||
if running:
|
||
lines.append(f"\n🔄 运行中({len(running)}个):")
|
||
for t in running:
|
||
lines.append(f" [{t.role}] {t.title[:60]}")
|
||
if review:
|
||
lines.append(f"\n👀 待审核({len(review)}个):")
|
||
for t in review:
|
||
lines.append(f" [{t.role}] {t.title[:60]}")
|
||
if qs:
|
||
lines.append(f"\n❓ 待回答问题({len(qs)}个):")
|
||
for q in qs:
|
||
lines.append(f" [{q.id[:8]}] {q.question[:80]}")
|
||
if not stuck and not failed and not qs:
|
||
lines.append("\n✅ 项目运行正常,无卡点")
|
||
else:
|
||
lines.append(f"\n💡 建议:")
|
||
if stuck:
|
||
lines.append(f" - {len(stuck)}个任务待执行 → 调用 start_agents 启动角色Agent")
|
||
if failed:
|
||
lines.append(f" - {len(failed)}个任务失败 → 检查失败原因,考虑重新提交")
|
||
if qs:
|
||
lines.append(f" - {len(qs)}个问题待回答 → 调用 list_questions + answer_question")
|
||
if review:
|
||
lines.append(f" - {len(review)}个交付件待PM审核 → PM agent会自动处理")
|
||
return '\n'.join(lines)
|
||
elif tool == 'add_bug':
|
||
if not ctx['pid']: return 'FAIL: 请先切换到项目'
|
||
title = p.get('title','')[:100]
|
||
if not title: return 'FAIL: 缺少标题'
|
||
await sor.C('sd_bugs',{'id':getID(),'iteration_id':'','title':title,'description':p.get('description',''),'severity':'major','priority':'P1','status':'open','reporter_type':'human','reporter_id':uid})
|
||
return f"OK: Bug已记录 {title}"
|
||
return f'未实现: {tool}'
|
||
except Exception as e:
|
||
return f'ERROR: {str(e)[:300]}'
|
||
|
||
|
||
if action == 'send_message':
|
||
uid = await get_user()
|
||
if not uid:
|
||
return json.dumps({"error":"请先登录"},ensure_ascii=False)
|
||
org_id = await get_userorgid() or '0'
|
||
p = params_kw or {}
|
||
message_text = (p.get('message_text') or '').strip()
|
||
user_model_id = p.get('model_id', '')
|
||
# Try prompt at top level (bricks AgentIO)
|
||
if not message_text:
|
||
message_text = (p.get('prompt') or '').strip()
|
||
# Try JSON body from AgentIO
|
||
if not message_text:
|
||
inner = p.get('params', {})
|
||
if isinstance(inner, dict):
|
||
message_text = (inner.get('prompt') or inner.get('message_text') or '').strip()
|
||
user_model_id = inner.get('model_id') or inner.get('llmid') or user_model_id
|
||
if not message_text: return json.dumps({"error":"message_text is required"},ensure_ascii=False)
|
||
|
||
blocked, reason = _security_scan(message_text)
|
||
if blocked: return json.dumps({"success":True,"agent_reply":f"⚠️ {reason}"},ensure_ascii=False)
|
||
|
||
debug(f"AUTH: uid={uid}, org_id={org_id}")
|
||
|
||
async def agent_stream():
|
||
async with DBPools().sqlorContext(dbname) as sor:
|
||
ctx = await _load_ctx(sor, uid)
|
||
model = await _sel_model(sor, user_model_id)
|
||
if not model:
|
||
d = json.dumps({"error": "没有可用的LLM模型配置"}, ensure_ascii=False)+'\n'
|
||
debug(f"YIELD error: {d[:80]}")
|
||
yield d
|
||
return
|
||
|
||
repos_str = ', '.join([r['n'] for r in ctx['repos']]) or '无'
|
||
# Check for pending questions from role agents
|
||
pending_qs = []
|
||
if ctx['pid']:
|
||
qs = await sor.sqlExe("SELECT id,question,task_id FROM pipeline_agent_questions WHERE tenant_id=${p}$ AND status='pending' ORDER BY created_at ASC LIMIT 5",{"p":ctx['pid']})
|
||
pending_qs = [f" [{getattr(q,'id','')[:8]}] {getattr(q,'question','')[:100]}" for q in (qs or [])]
|
||
qinfo = '\n'.join(pending_qs) if pending_qs else '无'
|
||
env_text = f"【当前项目: {ctx['pname'] or '未选择'}】— 所有操作在当前项目内完成\n工作空间: {ctx['ws'] or '未设置'} | 仓库: {repos_str}\n待回答问题: {qinfo}"
|
||
system = AGENT_PROMPT.replace('__TOOLS__',TOOLS_TEXT).replace('__ENV__',env_text)
|
||
debug(f"SYSTEM PROMPT env: {env_text}")
|
||
msgs = [{"role":"system","content":system}]
|
||
# Aggregate history as reference, not as independent messages
|
||
hist_parts = []
|
||
if uid:
|
||
sql = "SELECT role,content FROM pipeline_conversations WHERE created_by=${u}$ ORDER BY created_at ASC LIMIT 10"
|
||
params = {"u":uid}
|
||
if ctx['pid']:
|
||
sql = "SELECT role,content FROM pipeline_conversations WHERE created_by=${u}$ AND iteration_id=${p}$ ORDER BY created_at ASC LIMIT 10"
|
||
params = {"u":uid,"p":ctx['pid']}
|
||
hist_rows = await sor.sqlExe(sql, params)
|
||
debug(f"HISTORY: loaded {len(hist_rows or [])} rows, project={ctx['pid'][:8] if ctx['pid'] else 'none'}")
|
||
for h in (hist_rows or []):
|
||
c = getattr(h,'content','') or ''
|
||
r = getattr(h,'role','') or ''
|
||
if c.startswith('{"action":"tool_call"') or c.startswith('{"action": "tool_call"'):
|
||
continue
|
||
prefix = "用户" if r == 'user' else "助手"
|
||
hist_parts.append(f"{prefix}: {c[:300]}")
|
||
if hist_parts:
|
||
history_text = "参考历史记录:\n" + '\n'.join(hist_parts[-6:]) # last 6 exchanges only
|
||
msgs.append({"role":"system","content":history_text})
|
||
msgs.append({"role":"user","content":message_text})
|
||
if uid:
|
||
await sor.C('pipeline_conversations',{'id':getID(),'role':'user','content':message_text,'msg_type':'text','org_id':org_id,'created_by':uid,'iteration_id':ctx['pid'] or ''})
|
||
|
||
agent_reply = ''
|
||
# Push active notifications before LLM processing
|
||
if pending_qs:
|
||
d = json.dumps(_w_md(f"📬 **待回答问题({len(pending_qs)}个)**:\n" + '\n'.join(pending_qs)), ensure_ascii=False)+'\n'
|
||
yield d
|
||
for turn in range(10):
|
||
raw = await _call_llm(model, msgs, 0.4)
|
||
act = _parse(raw)
|
||
debug(f"LLM turn {turn}: raw={raw[:200]}, action={act.get('action','?')}, tool={act.get('tool','?')}")
|
||
if act.get('action') == 'reply':
|
||
agent_reply = act.get('message', raw); break
|
||
if act.get('action') == 'tool_call':
|
||
tool = act.get('tool','')
|
||
label = _tool_labels.get(tool,tool)
|
||
debug(f"TOOL CALL: {tool} params={act.get('params',{})}")
|
||
d = json.dumps(_w_progress(f"🔄 {label}..."), ensure_ascii=False)+'\n'
|
||
debug(f"YIELD progress: {d[:60]}")
|
||
yield d
|
||
result = await _exec_tool(sor, tool, act.get('params',{}), ctx, uid, org_id)
|
||
# If result is a raw widget descriptor, yield it directly and break loop
|
||
if result.startswith('{"widgettype":'):
|
||
d = result + '\n'
|
||
debug(f"YIELD widget: {d[:60]}")
|
||
yield d
|
||
agent_reply = '__widget_handled__'
|
||
break
|
||
else:
|
||
ok = result.startswith("OK:")
|
||
d = json.dumps(_w_card(label, _w_text(result), "success" if ok else "error"), ensure_ascii=False)+'\n'
|
||
debug(f"YIELD result: {d[:60]}")
|
||
yield d
|
||
msgs.append({"role":"assistant","content":f"已调用 {tool}"})
|
||
msgs.append({"role":"user","content":f"工具 {tool} 结果:\n{result}"})
|
||
continue
|
||
agent_reply = raw; break
|
||
|
||
if not agent_reply: agent_reply = "处理超时"
|
||
d = json.dumps(_w_md(agent_reply), ensure_ascii=False)+'\n'
|
||
debug(f"YIELD final: {d[:60]}")
|
||
yield d
|
||
if uid:
|
||
await sor.C('pipeline_conversations',{'id':getID(),'role':'agent','content':agent_reply,'msg_type':'text','org_id':org_id,'created_by':'system','iteration_id':ctx['pid'] or ''})
|
||
|
||
return await stream_response(request, agent_stream, 'text/plain; charset=utf-8')
|
||
|
||
elif action == 'list_messages':
|
||
uid = await get_user()
|
||
if not uid:
|
||
return json.dumps({"error":"请先登录"},ensure_ascii=False)
|
||
async with DBPools().sqlorContext(dbname) as sor:
|
||
ms = await sor.sqlExe("SELECT role,content,created_at FROM pipeline_conversations ORDER BY created_at ASC LIMIT 100",{})
|
||
result = [{"role":getattr(m,'role',''),"content":getattr(m,'content','')} for m in (ms or [])]
|
||
return json.dumps({"success":True,"messages":result},ensure_ascii=False,default=str)
|
||
|
||
else:
|
||
return json.dumps({"error":f"Unknown: {action}"},ensure_ascii=False)
|