refactor: LLM-driven agent — list_projects/create_project/clone_repo/run_command tools, exact-match only

This commit is contained in:
ymq 2026-08-09 17:24:33 +08:00
parent 053b71ae95
commit ae1aaadd91

View File

@ -14,7 +14,14 @@ if action != 'send_message':
action = 'send_message'
dbname = get_module_dbname('pipeline-sdlc')
AGENT_PROMPT = """你是开发产线驾驶舱 Agent。需要数据时调工具最终用 reply 回复。
AGENT_PROMPT = """你是开发产线驾驶舱 Agent。用工具获取数据用 reply 回复。
工作流:
- 切换项目:先调 list_projects 看有哪些项目,再调 switch_project
- 创建项目:如果需要的项目不存在,调 create_project
- 提交任务前确保已切换到正确项目
- clone_repo 用于克隆 git 仓库到工作空间
- run_command 用于在工作空间中执行 shell 命令
输出格式二选一:
{"action":"tool_call","tool":"工具名","params":{}}
@ -24,13 +31,17 @@ AGENT_PROMPT = """你是开发产线驾驶舱 Agent。需要数据时调工具
环境: __ENV__"""
TOOLS = [
{"name":"switch_project","description":"切换项目","params":{"project":"名称"}},
{"name":"list_projects","description":"列出所有可用项目","params":{}},
{"name":"switch_project","description":"切换到指定项目需先list_projects确认项目名","params":{"project":"项目名称"}},
{"name":"create_project","description":"创建新项目","params":{"name":"项目名称","description":"项目描述(可选)"}},
{"name":"create_task","description":"提交任务","params":{"title":"标题","description":"描述","role":"requirement/design/develop/test/deploy"}},
{"name":"list_tasks","description":"列出当前项目任务","params":{"state":"状态(可选)"}},
{"name":"check_progress","description":"查进度","params":{"task_id":"任务ID(可选)"}},
{"name":"list_tasks","description":"列出任务","params":{"state":"状态(可选)"}},
{"name":"add_repo","description":"关联仓库","params":{"url":"git地址","name":"名称(可选)"}},
{"name":"list_repos","description":"列出仓库","params":{}},
{"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)
@ -47,23 +58,10 @@ def _guess_role(title):
return 'develop'
async def _find_project(sor, name):
"""Exact match only — LLM should use list_projects first to discover names."""
if not name: return None
# Exact match first
recs = await sor.sqlExe("SELECT id,name FROM sd_projects WHERE name=${n}$",{"n":name})
if recs: return recs[0]
# Fuzzy match — require keyword >= 2 chars, prefer prefix match
kw = name.lower().strip()
if len(kw) < 2: return None
allp = await sor.sqlExe("SELECT id,name FROM sd_projects ORDER BY created_at DESC",{})
# First pass: name starts with kw
for r in (allp or []):
rn = (getattr(r,'name','') or '').lower()
if rn.startswith(kw): return r
# Second pass: kw is a word in name (split by common delimiters)
for r in (allp or []):
rn = (getattr(r,'name','') or '').lower()
if kw in rn: return r
return None
return recs[0] if recs else None
async def _load_ctx(sor, uid):
recs = await sor.sqlExe("SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$",{"u":uid})
@ -124,12 +122,33 @@ def _w_card(title, body, kind="success"):
]}
def _w_progress(text): return {"widgettype":"Text","options":{"text":text,"style":{"color":"#f59e0b","fontSize":"12px","padding":"2px 8px"}}}
_tool_labels = {'switch_project':'切换项目','create_task':'创建任务','check_progress':'检查进度','list_tasks':'查询任务','add_repo':'关联仓库','list_repos':'查看仓库','add_bug':'报告Bug'}
_tool_labels = {'list_projects':'列出项目','switch_project':'切换项目','create_project':'创建项目','create_task':'创建任务','check_progress':'检查进度','list_tasks':'查询任务','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 == 'switch_project':
if tool == 'list_projects':
allp = await sor.sqlExe("SELECT id,name,description,workspace_dir FROM sd_projects ORDER BY created_at DESC LIMIT 30",{})
if not allp: return '无项目'
lines = []
for r in allp:
marker = '★' if getattr(r,'id','') == ctx['pid'] else ' '
lines.append(f"{marker} {getattr(r,'name','')} | {getattr(r,'description','') or ''} | ws:{getattr(r,'workspace_dir','') or ''}")
return '\n'.join(lines)
elif 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,'name':'默认迭代','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 == 'switch_project':
proj = await _find_project(sor, p.get('project',''))
if proj:
await _save_ctx(sor, uid, proj.id)
@ -184,6 +203,39 @@ async def _exec_tool(sor, tool, params, ctx, uid, org_id):
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 == 'add_bug':
if not ctx['pid']: return 'FAIL: 请先切换到项目'
title = p.get('title','')[:100]
@ -230,7 +282,7 @@ if action == 'send_message':
return
repos_str = ', '.join([r['n'] for r in ctx['repos']]) or '无'
env_text = f"项目: {ctx['pname'] or '未选择'}\n仓库: {repos_str}"
env_text = f"当前项目: {ctx['pname'] or '未选择'}\n工作空间: {ctx['ws'] or '未设置'}\n仓库: {repos_str}"
system = AGENT_PROMPT.replace('__TOOLS__',TOOLS_TEXT).replace('__ENV__',env_text)
msgs = [{"role":"system","content":system}]
for h in (await sor.sqlExe("SELECT role,content FROM pipeline_conversations WHERE created_by=${u}$ ORDER BY created_at ASC LIMIT 20",{"u":uid}) or []):