refactor: role agent to LLM tool-loop with read/write/shell/git tools + llm_call_msgs
This commit is contained in:
parent
3b1718783a
commit
afa6972903
@ -162,47 +162,48 @@ async def _git_clone(repo_url, target_dir, branch='main'):
|
||||
|
||||
# ── Prompts ──
|
||||
|
||||
ROLE_PROMPT = """你是软件开发产线项目中的「__ROLE__」角色agent。
|
||||
AGENT_TOOLS = [
|
||||
{"name":"read_file","description":"读取工作空间中的文件","params":{"path":"相对路径"}},
|
||||
{"name":"write_file","description":"写入文件(自动创建父目录)","params":{"path":"相对路径","content":"文件内容"}},
|
||||
{"name":"list_files","description":"列出目录内容","params":{"path":"相对路径(可选,默认工作空间根)"}},
|
||||
{"name":"run_shell","description":"在工作空间中执行shell命令","params":{"command":"命令"}},
|
||||
{"name":"git_status","description":"查看git仓库状态","params":{"repo_dir":"仓库子目录(可选,默认repos下第一个)"}},
|
||||
{"name":"git_commit_push","description":"git add + commit + push","params":{"message":"提交信息","repo_dir":"仓库子目录(可选)"}},
|
||||
{"name":"ask_question","description":"向用户提问(缺少信息时使用)","params":{"question":"问题"}},
|
||||
]
|
||||
|
||||
当前任务:__TITLE__
|
||||
参数:__PARAMS__
|
||||
AGENT_SYSTEM_PROMPT = """你是软件开发产线中的「__ROLE__」角色Agent。
|
||||
|
||||
## 任务
|
||||
__TITLE__
|
||||
__QNA__
|
||||
|
||||
## 工作环境
|
||||
- 项目根目录:__WORKSPACE__
|
||||
- 代码仓库位于:__WORKSPACE__/repos/ 下各子目录
|
||||
- 你有能力读写文件、执行git操作
|
||||
工作空间:__WORKSPACE__
|
||||
产出要求:__ROLE_SPECIFIC__
|
||||
|
||||
## 产出要求
|
||||
__ROLE_SPECIFIC__
|
||||
## 工具
|
||||
你可以使用以下工具完成工作:
|
||||
__TOOLS__
|
||||
|
||||
## 开发规范(必须严格遵循!)
|
||||
__SKILLS__
|
||||
## 工作流
|
||||
1. 先用 read_file/list_files 了解现有代码
|
||||
2. 用 write_file 产出代码文件到 repos/ 下
|
||||
3. 用 run_shell 验证(编译/测试)
|
||||
4. 用 git_commit_push 提交到远端
|
||||
5. 用 deliver 提交最终交付件
|
||||
|
||||
## 输出格式(严格JSON,不要markdown包裹)
|
||||
{
|
||||
"status": "done",
|
||||
"deliverable_type": "类型(requirement_doc/design_doc/code_files/test_report/deploy_doc)",
|
||||
"summary": "一段话概述产出",
|
||||
"result": "当产出是文档时,这里是完整文档内容",
|
||||
"files": [
|
||||
{"path": "repos/仓库名/src/相对路径/文件.java", "content": "文件完整内容"},
|
||||
{"path": "repos/仓库名/pom.xml", "content": "..."}
|
||||
],
|
||||
"git_commit_message": "feat: 简短描述本次变更",
|
||||
"need_more_info": false,
|
||||
"question": ""
|
||||
}
|
||||
## 输出格式(每次只输出一个JSON对象)
|
||||
调工具:
|
||||
{"action":"tool_call","tool":"工具名","params":{}}
|
||||
|
||||
- 如果是文档类产出(requirement/design),用 result 字段输出文档正文
|
||||
- 如果是代码产出(develop),必须用 files 数组输出每个源码文件,每个元素含 path 和 content
|
||||
- 测试产出用 result 输出测试报告,如有测试脚本则用 files
|
||||
- 部署产出用 files 输出部署配置/脚本,用 result 输出部署说明
|
||||
- 缺少关键信息时:{"status":"need_info","question":"问题"}
|
||||
提交交付件:
|
||||
{"action":"deliver","deliverable_type":"code_files","summary":"概述","result":"文档内容","files":[{"path":"repos/仓库/src/file.py","content":"代码"}],"git_commit_message":"feat: 描述"}
|
||||
|
||||
## Git 操作说明
|
||||
- 产出代码文件将自动写入 files 中指定的路径并 git commit + push
|
||||
- 你只需要在 files 中指定正确的仓库路径即可"""
|
||||
提问:
|
||||
{"action":"ask","question":"问题"}
|
||||
|
||||
注意:每次只输出一个JSON!收到工具结果后再决定下一步。"""
|
||||
|
||||
ROLE_SPECIFICS = {
|
||||
'requirement': """你是需求分析师。输出完整的需求规格文档(Markdown),用 result 字段。
|
||||
@ -391,6 +392,87 @@ async def _create_next_task(sor, project_id, task, next_role, pm_comment=''):
|
||||
return new_task_id, new_title
|
||||
|
||||
|
||||
# ── Agent 工具执行 ──
|
||||
|
||||
def _parse_agent_action(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 (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
return {"action": "deliver", "result": raw}
|
||||
|
||||
|
||||
async def _exec_agent_tool(tool, params, workspace_dir):
|
||||
p = params or {}
|
||||
try:
|
||||
if tool == 'read_file':
|
||||
path = p.get('path', '')
|
||||
if not path: return 'FAIL: 需要文件路径'
|
||||
full = os.path.join(workspace_dir, path)
|
||||
if not _is_safe_workdir(full): return 'FAIL: 路径不在允许范围'
|
||||
if not os.path.isfile(full): return f'FAIL: 文件不存在 {path}'
|
||||
with open(full, encoding='utf-8') as f:
|
||||
return f.read()[:8000]
|
||||
elif tool == 'write_file':
|
||||
path = p.get('path', '')
|
||||
content = p.get('content', '')
|
||||
if not path: return 'FAIL: 需要文件路径'
|
||||
full = os.path.join(workspace_dir, path)
|
||||
if not _is_safe_workdir(full): return 'FAIL: 路径不在允许范围'
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return f'OK: 已写入 {path} ({len(content)} 字符)'
|
||||
elif tool == 'list_files':
|
||||
path = p.get('path', '') or '.'
|
||||
full = os.path.join(workspace_dir, path)
|
||||
if not _is_safe_workdir(full): return 'FAIL: 路径不在允许范围'
|
||||
if not os.path.isdir(full): return f'FAIL: 目录不存在 {path}'
|
||||
items = os.listdir(full)[:50]
|
||||
lines = []
|
||||
for name in sorted(items):
|
||||
fp = os.path.join(full, name)
|
||||
t = 'DIR' if os.path.isdir(fp) else 'FILE'
|
||||
size = os.path.getsize(fp) if os.path.isfile(fp) else 0
|
||||
lines.append(f"[{t}] {name} ({size}B)")
|
||||
return '\n'.join(lines) if lines else '(空目录)'
|
||||
elif tool == 'run_shell':
|
||||
cmd = p.get('command', '')
|
||||
if not cmd: return 'FAIL: 需要命令'
|
||||
r = await _run_shell(cmd, workspace_dir, timeout=120)
|
||||
return f"rc={r['rc']}\nSTDOUT:\n{r['stdout'][:2000]}\nSTDERR:\n{r['stderr'][:1000]}"
|
||||
elif tool == 'git_status':
|
||||
repo = p.get('repo_dir', '')
|
||||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||||
if repo:
|
||||
target = os.path.join(workspace_dir, repo)
|
||||
else:
|
||||
dirs = [d for d in os.listdir(repos_dir) if os.path.isdir(os.path.join(repos_dir, d, '.git'))] if os.path.isdir(repos_dir) else []
|
||||
target = os.path.join(repos_dir, dirs[0]) if dirs else workspace_dir
|
||||
r = await _run_shell('git status --short', target, 10)
|
||||
r2 = await _run_shell('git log --oneline -3', target, 10)
|
||||
return f"Status:\n{r['stdout'][:1000] or '(clean)'}\nRecent:\n{r2['stdout'][:500]}"
|
||||
elif tool == 'git_commit_push':
|
||||
msg = p.get('message', '') or 'agent update'
|
||||
repo = p.get('repo_dir', '')
|
||||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||||
if repo:
|
||||
target = os.path.join(workspace_dir, repo)
|
||||
else:
|
||||
dirs = [d for d in os.listdir(repos_dir) if os.path.isdir(os.path.join(repos_dir, d, '.git'))] if os.path.isdir(repos_dir) else []
|
||||
target = os.path.join(repos_dir, dirs[0]) if dirs else workspace_dir
|
||||
r = await _git_commit_push(target, msg)
|
||||
return f"rc={r['rc']} {r['message']}"
|
||||
return f'未实现: {tool}'
|
||||
except Exception as e:
|
||||
return f'ERROR: {str(e)[:300]}'
|
||||
|
||||
|
||||
# ── 角色 Agent ──
|
||||
|
||||
async def role_agent_run(project_id, role, agent_id=None, model_name=None):
|
||||
@ -409,64 +491,81 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
|
||||
params_str = getattr(task, "params", "{}") or "{}"
|
||||
workspace_dir = await _get_workspace_dir(sor, project_id)
|
||||
|
||||
# 提交 claim 事务,释放行锁
|
||||
try:
|
||||
await sor.sqlExe("COMMIT", {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 确保 repos 目录存在
|
||||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||||
if not os.path.isdir(repos_dir):
|
||||
os.makedirs(repos_dir, exist_ok=True)
|
||||
os.makedirs(repos_dir, exist_ok=True)
|
||||
|
||||
role_specific = ROLE_SPECIFICS.get(role, ROLE_SPECIFICS.get('develop', ''))
|
||||
qna_section = await _build_qna_section(sor, task_id)
|
||||
tools_text = json.dumps(AGENT_TOOLS, ensure_ascii=False)
|
||||
|
||||
# 从 params 中提取 skills 文本
|
||||
skills_text = "无特定规范要求,请按行业最佳实践执行。"
|
||||
try:
|
||||
params_obj = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||||
if isinstance(params_obj, dict) and params_obj.get('skills'):
|
||||
skills_text = str(params_obj['skills']).strip()
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
prompt = (ROLE_PROMPT
|
||||
system = (AGENT_SYSTEM_PROMPT
|
||||
.replace('__ROLE__', role)
|
||||
.replace('__TITLE__', title)
|
||||
.replace('__PARAMS__', params_str)
|
||||
.replace('__QNA__', qna_section)
|
||||
.replace('__WORKSPACE__', workspace_dir)
|
||||
.replace('__ROLE_SPECIFIC__', role_specific)
|
||||
.replace('__SKILLS__', skills_text))
|
||||
.replace('__TOOLS__', tools_text))
|
||||
|
||||
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]}
|
||||
msgs = [{"role": "system", "content": system}]
|
||||
msgs.append({"role": "user", "content": f"执行任务:{title}\n参数:{params_str}"})
|
||||
|
||||
parsed = _parse_result(raw)
|
||||
from .llm_bridge import llm_call_msgs
|
||||
|
||||
if parsed.get("status") == "need_info" and parsed.get("question"):
|
||||
deliverable = None
|
||||
ask_question = None
|
||||
|
||||
# ── Tool Loop ──
|
||||
for turn in range(15):
|
||||
try:
|
||||
raw = await llm_call_msgs(msgs, 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]}
|
||||
|
||||
act = _parse_agent_action(raw)
|
||||
|
||||
if act.get('action') == 'deliver':
|
||||
deliverable = act
|
||||
break
|
||||
elif act.get('action') == 'ask':
|
||||
ask_question = act.get('question', '')
|
||||
break
|
||||
elif act.get('action') == 'tool_call':
|
||||
tool = act.get('tool', '')
|
||||
params = act.get('params', {})
|
||||
result = await _exec_agent_tool(tool, params, workspace_dir)
|
||||
msgs.append({"role": "assistant", "content": raw})
|
||||
msgs.append({"role": "user", "content": f"工具 {tool} 结果:\n{result}"})
|
||||
logger.info(f"role_agent tool_call: {tool} -> {result[:100]}")
|
||||
else:
|
||||
deliverable = {"result": raw}
|
||||
break
|
||||
|
||||
if ask_question:
|
||||
from .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"]}
|
||||
qid = await agent_ask(project_id, task_id, role, ask_question,
|
||||
context={"title": title})
|
||||
return {"status": "need_info", "task_id": task_id, "question_id": qid, "question": ask_question}
|
||||
|
||||
if not deliverable:
|
||||
deliverable = {"result": "Agent未产出交付件"}
|
||||
|
||||
# ── 处理产出 ──
|
||||
from appPublic.uniqueID import getID
|
||||
did = getID()
|
||||
result_text = parsed.get("result") or raw
|
||||
deliverable_type = parsed.get("deliverable_type") or role
|
||||
summary = parsed.get("summary", "")
|
||||
result_text = deliverable.get("result") or ""
|
||||
deliverable_type = deliverable.get("deliverable_type") or role
|
||||
summary = deliverable.get("summary", "")
|
||||
|
||||
# 写入实际代码文件(如果有 files 数组)
|
||||
# 写入代码文件
|
||||
files_written = []
|
||||
code_files = parsed.get("files") or []
|
||||
code_files = deliverable.get("files") or []
|
||||
if isinstance(code_files, list):
|
||||
for f in code_files:
|
||||
if isinstance(f, dict) and f.get("path") and f.get("content"):
|
||||
@ -488,12 +587,10 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(result_text or '')
|
||||
logger.info(f"deliverable written: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"deliverable write failed: {file_path} err={e}")
|
||||
file_path = ''
|
||||
|
||||
# 写DB
|
||||
await sor.C("pipeline_deliverables", {
|
||||
"id": did, "project_id": project_id, "task_id": task_id,
|
||||
"deliverable_type": deliverable_type, "title": title,
|
||||
@ -502,17 +599,15 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
|
||||
"created_by": agent_id or role,
|
||||
})
|
||||
|
||||
# Git commit + push(如果有产出文件)
|
||||
commit_msg = parsed.get("git_commit_message") or f"{role}: {title[:80]}"
|
||||
# Git commit + push
|
||||
commit_msg = deliverable.get("git_commit_message") or f"{role}: {title[:80]}"
|
||||
git_result = {"rc": 0, "message": "无git操作"}
|
||||
if files_written:
|
||||
# 在 repos 目录下的每个 git 仓库各自 commit
|
||||
for repo_name in os.listdir(repos_dir):
|
||||
repo_path = os.path.join(repos_dir, repo_name)
|
||||
if os.path.isdir(os.path.join(repo_path, '.git')):
|
||||
git_result = await _git_commit_push(repo_path, commit_msg)
|
||||
|
||||
# 置为 review 状态
|
||||
await sor.sqlExe(
|
||||
"UPDATE pipeline_tasks SET state=${st}$, claimed_by=NULL WHERE id=${tid}$",
|
||||
{"st": TASK_REVIEW, "tid": task_id})
|
||||
|
||||
@ -129,3 +129,34 @@ async def llm_call(prompt: str, model: str = None, temperature: float = 0.7) ->
|
||||
async def call_llm(tenant_id: str, prompt: str, model: str = None, temperature: float = 0.7) -> str:
|
||||
"""SDLC handler interface — delegates to llm_call."""
|
||||
return await llm_call(prompt, model=model, temperature=temperature)
|
||||
|
||||
|
||||
async def llm_call_msgs(messages: list, model: str = None, temperature: float = 0.7) -> str:
|
||||
"""Call LLM with full message array (system/user/assistant)."""
|
||||
import aiohttp
|
||||
|
||||
cfg = await _get_model_config(model)
|
||||
if cfg.get("api_key") and cfg.get("api_base"):
|
||||
api_base = cfg["api_base"]
|
||||
api_key = cfg["api_key"]
|
||||
model_id = cfg.get("model_id") or model or "default"
|
||||
else:
|
||||
api_base = os.environ.get("LLM_API_BASE", "https://api.openai.com/v1")
|
||||
api_key = os.environ.get("LLM_API_KEY", "")
|
||||
model_id = model or os.environ.get("LLM_MODEL", "gpt-4o-mini")
|
||||
|
||||
if not api_key:
|
||||
raise ValueError("No LLM API configured")
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
payload = {"model": model_id, "messages": messages, "temperature": temperature}
|
||||
|
||||
url = api_base.rstrip("/") + "/chat/completions"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=180)) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise ValueError(f"LLM API error {resp.status}: {text[:300]}")
|
||||
data = await resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user