feat: 能力工具按 capability 动态注入(根治纸面落库)

- 新增 capability_tools.py:TOOL_SCHEMAS 工具 schema + 从产线状态机规范 frontmatter
  读取 capability→tools 映射(可配置)+ exec_capability_tool 分发
- agent_loop 加 _collect_capability_tools:角色技能(scope=role)声明 capability,
  匹配产线状态机规范(scope=pipeline)的 tools 声明,注入对应能力工具
- role_agent_run 注入能力工具(AGENT_TOOLS+capability_tools) + capability_ctx
  (project_id/iteration_id/who/agent_id 自动注入,LLM 不可见)
- _exec_agent_tool 分发能力工具到对应 capability 函数
- 根治:requirement 无法 propose_feature、test 无法 create_case/report_bug
  只能写文档假装落库(sd_features 表=0)的问题
This commit is contained in:
ymq 2026-08-22 01:19:27 +08:00
parent b31cb6c816
commit 6e82493c20
2 changed files with 305 additions and 6 deletions

View File

@ -904,7 +904,7 @@ def _agent_tools_to_openai_schema(agent_tools):
k: {"type": "string", "description": v}
for k, v in (t.get("params") or {}).items()
},
"required": list((t.get("params") or {}).keys()),
"required": list((t.get("required")) or (t.get("params") or {}).keys()),
},
},
}
@ -912,7 +912,7 @@ def _agent_tools_to_openai_schema(agent_tools):
]
async def _exec_agent_tool(tool, params, workspace_dir):
async def _exec_agent_tool(tool, params, workspace_dir, ctx=None):
p = params or {}
try:
if tool == 'read_file':
@ -968,6 +968,10 @@ async def _exec_agent_tool(tool, params, workspace_dir):
target = _resolve_repo_target(workspace_dir, p.get('repo_dir', ''))
r = await _git_commit_push(target, msg)
return f"rc={r['rc']} {r['message']}"
# 能力工具(propose_feature/create_case/report_bug 等,按角色 capability 注入)
from .capability_tools import exec_capability_tool, TOOL_SCHEMAS
if tool in TOOL_SCHEMAS:
return await exec_capability_tool(tool, p, ctx or {})
return f'未实现: {tool}'
except Exception as e:
return f'ERROR: {str(e)[:300]}'
@ -1054,6 +1058,38 @@ async def _build_role_skills_block(sor, project_id, role, org_id=""):
return ""
async def _collect_capability_tools(sor, project_id, role, org_id=""):
"""收集角色声明的 capability 对应的能力工具。
角色技能(scope=role)frontmatter 声明 `capability: feature_capability`(角色需要哪些能力),
产线状态机规范(scope=pipeline)frontmatter 声明 `capability + tools`(能力含哪些工具),
这里匹配两者,返回要注入的工具定义列表(name/description/params/required)。
"""
try:
pid = await _resolve_pipeline_id(project_id)
from pipeline_core.skill_loader import get_skill_loader
from pipeline_core.skill_pack import get_skills_base
from .capability_tools import resolve_capability_tools
skills_dir = get_skills_base()
loader = get_skill_loader(skills_dir)
merged = loader.get_merged(pipeline_id=pid, role=role,
project_id=project_id, org_id=org_id or '0')
if not merged:
return []
role_caps = set()
for skill in merged.values():
if getattr(skill, 'scope', '') == 'role':
cap = getattr(skill, 'capability', '') or ''
for c in str(cap).split(','):
c = c.strip()
if c:
role_caps.add(c)
return resolve_capability_tools(role_caps, merged)
except Exception as e:
logger.warning(f"collect capability tools failed: {e}")
return []
async def _load_skill_by_name(sor, project_id, role, org_id, name, file_path=None):
"""按需加载技能全文或子文件(分层导入第二层,对应 load_skill 工具)。
@ -1121,12 +1157,28 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
role_specific = _role_specific
qna_section = await _build_qna_section(sor, task_id, role, agent_id)
tools_text = json.dumps(AGENT_TOOLS, ensure_ascii=False)
# 能力工具:角色技能 capability 声明 → 产线状态机规范 tools 声明 → 注入对应工具
capability_tools = await _collect_capability_tools(sor, project_id, role, org_id)
all_tools = AGENT_TOOLS + capability_tools
tools_text = json.dumps(all_tools, ensure_ascii=False)
# 注入角色技能(角色专属技能全量 + 其余 scope 目录层,优先级 角色>项目>产线>组织>通用)
role_skills = await _build_role_skills_block(sor, project_id, role, org_id)
# 项目名(用于 prompt 里 {项目名}_pc 占位符替换,角色产出路径以 project-directory-spec 为准)
project_name = await _get_project_name(sor, project_id)
# 能力工具上下文(project_id/iteration_id/who/agent_id 自动注入,LLM 不可见)
_iter = None
try:
from .iteration_capability import get_current_iteration
_iter = await get_current_iteration(sor, project_id)
except Exception:
_iter = None
capability_ctx = {
"project_id": project_id,
"iteration_id": getattr(_iter, 'id', '') if _iter else '',
"who": role,
"agent_id": agent_id,
}
system = (AGENT_SYSTEM_PROMPT
.replace('__ROLE__', role)
@ -1142,7 +1194,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
msgs.append({"role": "user", "content": f"执行任务:{title}\n参数:{params_str}"})
from .llm_bridge import llm_call_msgs, llm_call_msgs_native
tools_schema = _agent_tools_to_openai_schema(AGENT_TOOLS)
tools_schema = _agent_tools_to_openai_schema(all_tools)
deliverable = None
ask_question = None
@ -1199,7 +1251,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
if tool == "load_skill":
result = await _load_skill_by_name(sor, project_id, role, org_id, params.get("name", ""), params.get("file_path") or None)
else:
result = await _exec_agent_tool(tool, params, workspace_dir)
result = await _exec_agent_tool(tool, params, workspace_dir, capability_ctx)
if tool == "write_file" and params.get("path"):
written_files.append(os.path.join(workspace_dir, params["path"]))
msgs.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": str(result)})
@ -1223,7 +1275,7 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
if tool == 'load_skill':
result = await _load_skill_by_name(sor, project_id, role, org_id, params.get('name', ''), params.get('file_path') or None)
else:
result = await _exec_agent_tool(tool, params, workspace_dir)
result = await _exec_agent_tool(tool, params, workspace_dir, capability_ctx)
if tool == 'write_file' and params.get('path'):
written_files.append(os.path.join(workspace_dir, params['path']))
msgs.append({"role": "assistant", "content": raw})

View File

@ -0,0 +1,247 @@
# -*- coding: utf-8 -*-
"""能力工具注入:capability → 工具列表 从产线状态机规范 frontmatter 读取(可配置)。
三层:
1. 产线状态机规范(common/feature 等)frontmatter 声明 `capability: feature_capability` + `tools: [propose_feature, ...]`
—— 这是「配置」:哪个 capability 含哪些工具,改 frontmatter 即可增删,不用改代码。
2. 角色技能(roles/{role}/role)frontmatter 声明 `capability: feature_capability`
—— 角色声明「我需要这个能力」,系统据此注入该能力下的全部工具。
3. 本模块:工具 schema(分发模块/描述/参数)硬编码在此(代码不可避免),
但 capability→工具列表 由第 1 层 frontmatter 决定(可配置)。
系统上下文(project_id/iteration_id/who/agent_id)由分发函数自动注入,不暴露给 LLM。
"""
import inspect
import importlib
import logging
logger = logging.getLogger("pipeline")
# 工具 schema(分发模块 + 描述 + 参数)——代码层,不可避免。
# 每个工具名对应某个 capability 模块里的同名函数。参数只暴露 LLM 可见的业务参数,
# project_id/iteration_id/who/agent_id 由 exec 自动注入。
TOOL_SCHEMAS = {
# ── feature_capability ──
"propose_feature": {
"module": "feature_capability",
"description": "提出功能点(拆解需求提功能),落库 sd_features,status=proposed。每个业务域的功能都要提",
"params": {"feature_name": "功能名称", "description": "功能描述(输入/处理/输出/验收标准)",
"priority": "优先级 P0/P1/P2(默认 P2)", "feature_type": "功能类型(默认 new_feature)"},
"required": ["feature_name"],
},
"list_features": {
"module": "feature_capability",
"description": "列出功能清单(sd_features),可按状态/迭代过滤",
"params": {"status": "状态(可选 proposed/approved/...)", "iteration_id": "迭代ID(可选)"},
"required": [],
},
"approve_feature": {
"module": "feature_capability",
"description": "审批功能:proposed → approved",
"params": {"feature_id": "功能ID", "comment": "审批意见(可选)"},
"required": ["feature_id"],
},
"reject_feature": {
"module": "feature_capability",
"description": "驳回功能:proposed/approved → rejected",
"params": {"feature_id": "功能ID", "comment": "驳回原因"},
"required": ["feature_id"],
},
"start_feature": {
"module": "feature_capability",
"description": "开始开发功能:approved → in_progress",
"params": {"feature_id": "功能ID"},
"required": ["feature_id"],
},
"deliver_feature": {
"module": "feature_capability",
"description": "交付功能:in_progress → delivered",
"params": {"feature_id": "功能ID"},
"required": ["feature_id"],
},
"verify_feature": {
"module": "feature_capability",
"description": "验收功能:delivered → verified",
"params": {"feature_id": "功能ID"},
"required": ["feature_id"],
},
# ── test_plan_capability ──
"create_test_plan": {
"module": "test_plan_capability",
"description": "创建测试计划,落库 sd_test_plans,status=draft",
"params": {"plan_name": "计划名称", "plan_type": "类型(默认 functional)", "scope": "测试范围",
"environment": "测试环境", "entry_criteria": "准入标准", "exit_criteria": "准出标准"},
"required": ["plan_name"],
},
"list_plans": {
"module": "test_plan_capability",
"description": "列出测试计划(sd_test_plans)",
"params": {"status": "状态(可选)"},
"required": [],
},
# ── test_case_capability ──
"create_case": {
"module": "test_case_capability",
"description": "创建测试用例,落库 sd_test_cases,status=pending",
"params": {"plan_id": "测试计划ID", "case_name": "用例名称", "case_type": "类型(默认 functional)",
"priority": "优先级(默认 P2)", "precondition": "前置条件", "steps": "执行步骤",
"expected_result": "预期结果"},
"required": ["plan_id", "case_name"],
},
"pass_case": {
"module": "test_case_capability",
"description": "用例通过:pending/fail → pass",
"params": {"case_id": "用例ID", "plan_id": "计划ID", "actual_result": "实际结果"},
"required": ["case_id", "plan_id"],
},
"fail_case": {
"module": "test_case_capability",
"description": "用例失败:pending/pass → fail(失败后应 report_bug 建 Bug)",
"params": {"case_id": "用例ID", "plan_id": "计划ID", "actual_result": "实际结果(与预期不符处)"},
"required": ["case_id", "plan_id"],
},
"skip_case": {
"module": "test_case_capability",
"description": "用例跳过:pending → skipped(注明原因)",
"params": {"case_id": "用例ID", "plan_id": "计划ID", "actual_result": "跳过原因"},
"required": ["case_id", "plan_id"],
},
"block_case": {
"module": "test_case_capability",
"description": "用例阻塞:pending → blocked(环境/依赖无法执行)",
"params": {"case_id": "用例ID", "plan_id": "计划ID", "actual_result": "阻塞原因"},
"required": ["case_id", "plan_id"],
},
"list_cases": {
"module": "test_case_capability",
"description": "列出测试用例(sd_test_cases)",
"params": {"plan_id": "计划ID", "status": "状态(可选)"},
"required": ["plan_id"],
},
# ── bug_capability ──
"report_bug": {
"module": "bug_capability",
"description": "上报 Bug,落库 sd_bugs,status=open(用例 fail 后应调用)",
"params": {"title": "Bug 标题", "description": "Bug 描述", "severity": "严重度 critical/major/minor(默认 major)",
"priority": "优先级(默认 P1)", "case_id": "关联用例ID(可选)", "step_name": "发现步骤(可选)"},
"required": ["title"],
},
"list_bugs": {
"module": "bug_capability",
"description": "列出 Bug(sd_bugs)",
"params": {"status": "状态(可选)", "severity": "严重度(可选)"},
"required": [],
},
# ── deploy_capability ──
"configure_env": {
"module": "deploy_capability",
"description": "配置部署环境,落库 sd_deploy_envs,status=configured",
"params": {"env_type": "环境类型 test/prod", "host": "目标机主机", "user": "SSH 用户",
"deploy_path": "部署路径", "port": "SSH 端口(默认 22)", "ssh_key_path": "SSH 密钥路径(可选)",
"sudo_enabled": "是否 sudo(可选)", "python_path": "Python 路径(可选)", "db_host": "DB 主机(可选)",
"db_port": "DB 端口(可选)", "db_name": "DB 名(可选)", "db_user": "DB 用户(可选)",
"db_password": "DB 密码(可选)"},
"required": ["env_type", "host", "user", "deploy_path"],
},
"verify_env": {
"module": "deploy_capability",
"description": "验证部署环境可用:configured → verified",
"params": {"env_id": "环境ID"},
"required": ["env_id"],
},
"fail_env": {
"module": "deploy_capability",
"description": "标记部署环境不可用(部署前检查失败时调用)",
"params": {"env_id": "环境ID", "reason": "不可用原因"},
"required": ["env_id"],
},
"list_envs": {
"module": "deploy_capability",
"description": "列出部署环境(sd_deploy_envs)",
"params": {"env_type": "类型(可选 test/prod)", "status": "状态(可选)"},
"required": [],
},
}
def _capability_to_tools_map(merged_skills):
"""从技能 frontmatter 的 capability + tools 声明,建立 capability → 工具名列表。
「可配置」的核心:产线状态机规范(common/feature 等)frontmatter 写
`capability: feature_capability` + `tools: [propose_feature, ...]`,
这里读取该声明。改 frontmatter 即可增删 capability 下的工具,不用改代码。
"""
m = {}
for skill in merged_skills.values():
cap = getattr(skill, "capability", "") or ""
tools = getattr(skill, "tools", []) or []
if not cap:
continue
m.setdefault(cap, [])
for t in tools:
if t not in m[cap]:
m[cap].append(t)
return m
def resolve_capability_tools(capabilities, merged_skills):
"""根据角色声明的 capability 集合 + 技能 frontmatter 映射,返回工具定义列表。
capabilities: 角色技能 frontmatter 声明的 capability 集合
merged_skills: 角色可见技能(get_merged 结果)
返回 [{name, description, params, required}, ...](去重)
"""
cap_map = _capability_to_tools_map(merged_skills)
tools = []
seen = set()
for cap in (capabilities or []):
for tool_name in cap_map.get(cap, []):
schema = TOOL_SCHEMAS.get(tool_name)
if not schema or tool_name in seen:
continue
seen.add(tool_name)
tools.append({
"name": tool_name,
"description": schema["description"],
"params": schema["params"],
"required": schema["required"],
})
return tools
async def exec_capability_tool(tool_name, params, ctx):
"""分发能力工具。ctx: {project_id, iteration_id, who, agent_id}。"""
schema = TOOL_SCHEMAS.get(tool_name)
if not schema:
return f"FAIL: 未注册的能力工具 {tool_name}"
p = dict(params or {})
p.setdefault("project_id", ctx.get("project_id", ""))
p.setdefault("iteration_id", ctx.get("iteration_id", ""))
p.setdefault("who", ctx.get("who", ""))
p.setdefault("agent_id", ctx.get("agent_id", ""))
try:
mod = importlib.import_module(f"pipeline_service.{schema['module']}")
fn = getattr(mod, tool_name)
sig = inspect.signature(fn)
kwargs = {k: v for k, v in p.items() if k in sig.parameters}
result = await fn(**kwargs)
if isinstance(result, tuple) and len(result) == 2:
ok, msg = result
return f"{'OK' if ok else 'FAIL'}: {msg}"
if isinstance(result, list):
if not result:
return "(空)"
lines = []
for item in result[:50]:
if hasattr(item, "keys"):
lines.append(str({k: getattr(item, k, "") for k in item.keys()}))
else:
lines.append(str(item))
return "\n".join(lines)
return str(result)
except Exception as e:
logger.exception(f"exec_capability_tool {tool_name} failed")
return f"ERROR: {str(e)[:300]}"