fischer-agentkit/src/agentkit/core/goal_planner.py

595 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""GoalPlanner — 目标分析与计划生成
用户给定自然语言目标后,自动生成结构化执行计划,包含任务拆解、
依赖关系、并行度识别。作为 Orchestrator._decompose_task() 的前置增强层。
执行流程:
1. 通过结构化目标分解(规则/模板)生成初始方案
2. 如果初始方案有效则跳过 LLM 调用
3. 否则将初始方案作为上下文注入 LLM promptLLM 细化调整
4. 识别能力缺口,请求人工介入
5. 通过 AskHumanTool 请求确认/修改
"""
from __future__ import annotations
import logging
import re
import uuid
from typing import Any
from agentkit.core.plan_schema import (
ExecutionPlan,
PlanStep,
PlanStepStatus,
SkillGap,
SkillGapLevel,
)
logger = logging.getLogger(__name__)
class GoalPlanner:
"""目标分析与计划生成器
将自然语言目标分解为结构化执行计划,包含任务拆解、
依赖关系和并行度识别。
使用方式:
planner = GoalPlanner()
plan = await planner.generate_plan(
goal="调研 3 个竞品 SEO 策略并生成对比报告",
context={},
available_skills=["web_search", "seo_analyzer", "report_generator"],
)
"""
def __init__(self, llm_gateway: Any = None, max_parallel: int = 5):
"""
Args:
llm_gateway: LLM Gateway用于细化计划可选
max_parallel: 最大并行步骤数
"""
self._llm_gateway = llm_gateway
self._max_parallel = max_parallel
async def generate_plan(
self,
goal: str,
context: dict[str, Any] | None = None,
available_skills: list[str] | None = None,
) -> ExecutionPlan:
"""生成结构化执行计划
Args:
goal: 自然语言目标
context: 上下文信息(如已有数据、约束条件等)
available_skills: 可用 Skill 列表
Returns:
ExecutionPlan: 结构化执行计划
"""
context = context or {}
available_skills = available_skills or []
# 1. 通过规则/模板生成初始方案
plan = self._rule_based_decompose(goal, context, available_skills)
# 2. 识别能力缺口
plan.skill_gaps = self._identify_skill_gaps(plan, available_skills)
# 3. 如果有 LLM Gateway 且初始方案不够精确,让 LLM 细化
if self._llm_gateway and self._should_refine_with_llm(plan):
plan = await self._llm_refine_plan(goal, plan, context, available_skills)
# 细化后重新识别能力缺口
plan.skill_gaps = self._identify_skill_gaps(plan, available_skills)
# 4. 构建并行组
plan.parallel_groups = self._build_parallel_groups(plan.steps)
return plan
def _rule_based_decompose(
self,
goal: str,
context: dict[str, Any],
available_skills: list[str],
) -> ExecutionPlan:
"""基于规则/模板的目标分解
使用启发式规则识别目标中的并列结构和顺序依赖,
生成初始执行计划。
"""
steps: list[PlanStep] = []
# 识别并列结构:如"3 个竞品"、"3个方案"、"A、B、C"
parallel_items = self._extract_parallel_items(goal)
if parallel_items and len(parallel_items) > 1:
# 有并列结构:每个并列项生成一个并行步骤 + 汇总步骤
steps = self._decompose_parallel_goal(goal, parallel_items, available_skills)
else:
# 无明显并列结构:尝试识别顺序步骤
sequential_parts = self._extract_sequential_parts(goal)
if len(sequential_parts) > 1:
steps = self._decompose_sequential_goal(goal, sequential_parts, available_skills)
else:
# 单步任务
steps = self._decompose_simple_goal(goal, available_skills)
return ExecutionPlan(
goal=goal,
steps=steps,
)
def _extract_parallel_items(self, goal: str) -> list[str]:
"""从目标中提取并列项
识别模式:
- "N 个 X":如"3 个竞品""5 个方案"
- "A、B、C":顿号分隔的并列项
- "A, B, C":逗号分隔的并列项
"""
items: list[str] = []
# 模式1"N 个 X" — 识别数量+类别
count_match = re.search(r"(\d+)\s*个\s*(.+?)(?:的|和|并|以及||,|$)", goal)
if count_match:
count = int(count_match.group(1))
category = count_match.group(2).strip()
# 生成 N 个并列项
for i in range(1, count + 1):
items.append(f"{category} {i}")
return items
# 模式2顿号分隔 — "竞品A、竞品B、竞品C"
if "" in goal:
# 提取顿号分隔的片段
parts = re.split(r"[、]", goal)
# 过滤掉太短的片段(可能是标点噪声)
meaningful = [p.strip() for p in parts if len(p.strip()) > 1]
if len(meaningful) >= 2:
items = meaningful
return items
# 模式3英文逗号分隔 — "A, B, C"
if "," in goal:
parts = goal.split(",")
meaningful = [p.strip() for p in parts if len(p.strip()) > 1]
if len(meaningful) >= 2:
items = meaningful
return items
return items
def _extract_sequential_parts(self, goal: str) -> list[str]:
"""从目标中提取顺序步骤
识别模式:
- "":如"调研并生成报告"
- "然后"/"接着"/"":顺序连接词
- ""/"->":箭头分隔
"""
parts: list[str] = []
# 模式1箭头分隔
if "" in goal or "->" in goal:
separator = "" if "" in goal else "->"
parts = [p.strip() for p in goal.split(separator) if p.strip()]
return parts
# 模式2顺序连接词
sequential_patterns = [
r"(.+?)然后(.+)",
r"(.+?)接着(.+)",
r"(.+?)之后再(.+)",
]
for pattern in sequential_patterns:
match = re.search(pattern, goal)
if match:
parts = [g.strip() for g in match.groups() if g.strip()]
return parts
# 模式3"并" 连接 — 如"调研并生成报告"
if "" in goal:
match = re.search(r"(.+?)并(.+)", goal)
if match:
parts = [g.strip() for g in match.groups() if g.strip()]
return parts
return parts
def _decompose_parallel_goal(
self,
goal: str,
parallel_items: list[str],
available_skills: list[str],
) -> list[PlanStep]:
"""分解包含并列结构的目标
生成 N 个并行步骤 + 1 个汇总步骤。
"""
steps: list[PlanStep] = []
parallel_step_ids: list[str] = []
# 为每个并列项生成一个并行步骤
for i, item in enumerate(parallel_items):
step_id = f"step-{i}"
required_skills = self._infer_required_skills(item, available_skills)
steps.append(PlanStep(
step_id=step_id,
name=f"处理: {item}",
description=f"对「{item}」执行相关操作",
dependencies=[],
parallel_group=0,
required_skills=required_skills,
))
parallel_step_ids.append(step_id)
# 汇总步骤:依赖所有并行步骤
summary_skills = self._infer_required_skills("汇总 生成 报告", available_skills)
steps.append(PlanStep(
step_id=f"step-{len(parallel_items)}",
name="汇总结果",
description="汇总所有并行步骤的结果,生成最终输出",
dependencies=parallel_step_ids,
parallel_group=1,
required_skills=summary_skills,
))
return steps
def _decompose_sequential_goal(
self,
goal: str,
sequential_parts: list[str],
available_skills: list[str],
) -> list[PlanStep]:
"""分解包含顺序步骤的目标"""
steps: list[PlanStep] = []
for i, part in enumerate(sequential_parts):
step_id = f"step-{i}"
dependencies = [f"step-{i - 1}"] if i > 0 else []
required_skills = self._infer_required_skills(part, available_skills)
steps.append(PlanStep(
step_id=step_id,
name=part[:50], # 截取前 50 字符作为名称
description=part,
dependencies=dependencies,
parallel_group=i,
required_skills=required_skills,
))
return steps
def _decompose_simple_goal(
self,
goal: str,
available_skills: list[str],
) -> list[PlanStep]:
"""分解简单目标为单步计划"""
required_skills = self._infer_required_skills(goal, available_skills)
return [
PlanStep(
step_id="step-0",
name=goal[:50],
description=goal,
dependencies=[],
parallel_group=0,
required_skills=required_skills,
)
]
def _infer_required_skills(self, text: str, available_skills: list[str]) -> list[str]:
"""根据文本推断所需的 Skill
基于关键词匹配,将文本中的意图映射到可用 Skill。
"""
skill_keywords: dict[str, list[str]] = {
"web_search": ["搜索", "查询", "查找", "调研", "search", "find", "lookup"],
"seo_analyzer": ["seo", "搜索引擎优化", "关键词", "排名"],
"report_generator": ["报告", "汇总", "总结", "生成", "对比", "report", "summary"],
"data_analyzer": ["分析", "统计", "数据", "analyze", "data"],
"document_writer": ["", "撰写", "文档", "write", "document"],
"code_generator": ["代码", "编程", "开发", "code", "develop"],
}
text_lower = text.lower()
matched: list[str] = []
for skill, keywords in skill_keywords.items():
if skill not in available_skills:
continue
if any(kw in text_lower for kw in keywords):
matched.append(skill)
return matched
def _identify_skill_gaps(
self, plan: ExecutionPlan, available_skills: list[str]
) -> list[SkillGap]:
"""识别能力缺口
检查每个步骤所需的 Skill 是否可用,标注缺口。
"""
gaps: list[SkillGap] = []
available_set = set(available_skills)
for step in plan.steps:
for skill in step.required_skills:
if skill not in available_set:
gaps.append(SkillGap(
step_name=step.name,
required_skill=skill,
level=SkillGapLevel.HIGH,
suggestion=f"请安装或注册 '{skill}' Skill或手动完成该步骤",
))
# 如果步骤没有匹配到任何 Skill标注缺口
if not step.required_skills:
if not available_skills:
# 无可用 Skill 时标注为 HIGH
gaps.append(SkillGap(
step_name=step.name,
required_skill="(无可用 Skill)",
level=SkillGapLevel.HIGH,
suggestion="当前无可用 Skill请注册所需 Skill 或手动完成该步骤",
))
else:
# 有 Skill 但未匹配到时标注为 MEDIUM
gaps.append(SkillGap(
step_name=step.name,
required_skill="(未匹配)",
level=SkillGapLevel.MEDIUM,
suggestion=f"无法自动匹配 Skill可用 Skill: {', '.join(available_skills[:5])}",
))
return gaps
def _should_refine_with_llm(self, plan: ExecutionPlan) -> bool:
"""判断是否需要 LLM 细化
当初始方案步骤描述过于简单、能力缺口较多、或所有步骤
都没有匹配到 Skill 时,需要 LLM 细化。
"""
# 如果所有步骤都没有匹配到任何 Skill让 LLM 重新评估
if plan.steps and all(not s.required_skills for s in plan.steps):
return True
# 如果有较多能力缺口,让 LLM 重新评估
if len(plan.skill_gaps) > len(plan.steps):
return True
return False
async def _llm_refine_plan(
self,
goal: str,
initial_plan: ExecutionPlan,
context: dict[str, Any],
available_skills: list[str],
) -> ExecutionPlan:
"""使用 LLM 细化执行计划
将初始方案作为上下文注入 LLM prompt让 LLM 细化调整。
"""
import json
# 构建初始方案摘要
initial_summary = json.dumps(
[s.to_dict() for s in initial_plan.steps],
ensure_ascii=False,
indent=2,
)
skills_str = ", ".join(available_skills) if available_skills else ""
prompt = (
f"Refine the following execution plan for the given goal.\n\n"
f"Goal: {goal}\n\n"
f"Initial Plan (generated by rules):\n{initial_summary}\n\n"
f"Available Skills: {skills_str}\n\n"
f"Context: {json.dumps(context, ensure_ascii=False) if context else 'None'}\n\n"
'Respond ONLY with a JSON array of steps: '
'[{"name": "...", "description": "...", "dependencies": [], '
'"required_skills": [...]}]\n'
"The dependencies field lists step indices (0-based) that must complete first.\n"
"Each step should have a clear, specific description (at least 20 characters).\n"
"Do not include any other text."
)
try:
response = await self._llm_gateway.chat(
messages=[{"role": "user", "content": prompt}],
model="default",
)
step_defs = json.loads(response.content)
if not isinstance(step_defs, list) or not step_defs:
return initial_plan
steps: list[PlanStep] = []
for i, defn in enumerate(step_defs):
depends_on = [f"step-{j}" for j in defn.get("dependencies", [])]
steps.append(PlanStep(
step_id=f"step-{i}",
name=defn.get("name", f"Step {i}"),
description=defn.get("description", ""),
dependencies=depends_on,
parallel_group=0, # 后续由 _build_parallel_groups 重新计算
required_skills=defn.get("required_skills", []),
))
return ExecutionPlan(
goal=goal,
steps=steps,
metadata={"refined_by_llm": True},
)
except Exception as e:
logger.warning(f"LLM plan refinement failed, using initial plan: {e}")
return initial_plan
def _build_parallel_groups(self, steps: list[PlanStep]) -> list[list[str]]:
"""构建并行执行组
基于依赖关系拓扑排序,无依赖的步骤分到同一组并行执行。
复用 Orchestrator._build_parallel_groups() 的拓扑排序逻辑。
"""
step_map = {s.step_id: s for s in steps}
completed: set[str] = set()
groups: list[list[str]] = []
remaining = set(s.step_id for s in steps)
while remaining:
# 找到所有依赖已满足的步骤
ready = []
for sid in remaining:
step = step_map[sid]
if all(dep in completed for dep in step.dependencies):
ready.append(sid)
if not ready:
# 循环依赖 — 将剩余步骤放入一组
groups.append(list(remaining))
break
# 限制组大小
group = ready[: self._max_parallel]
groups.append(group)
for sid in group:
completed.add(sid)
remaining.discard(sid)
# 更新步骤的 parallel_group 字段
for group_idx, group in enumerate(groups):
for sid in group:
step = step_map.get(sid)
if step:
step.parallel_group = group_idx
return groups
def update_plan_from_feedback(
self,
plan: ExecutionPlan,
modifications: dict[str, Any],
) -> ExecutionPlan:
"""根据用户反馈更新计划
Args:
plan: 原始执行计划
modifications: 修改内容,可包含:
- add_steps: 新增步骤列表
- remove_steps: 要移除的步骤 ID 列表
- update_steps: 要更新的步骤 {step_id: {field: value}}
- reorder: 是否重新排序
Returns:
更新后的 ExecutionPlan
"""
steps = list(plan.steps)
# 移除步骤
remove_ids = set(modifications.get("remove_steps", []))
if remove_ids:
steps = [s for s in steps if s.step_id not in remove_ids]
# 清理依赖引用
for step in steps:
step.dependencies = [d for d in step.dependencies if d not in remove_ids]
# 更新步骤
update_map: dict[str, dict] = modifications.get("update_steps", {})
for step in steps:
if step.step_id in update_map:
updates = update_map[step.step_id]
for field_name, value in updates.items():
if hasattr(step, field_name):
setattr(step, field_name, value)
# 新增步骤
add_steps = modifications.get("add_steps", [])
for new_step_def in add_steps:
step_id = new_step_def.get("step_id", f"step-{len(steps)}")
# 确保唯一性
existing_ids = {s.step_id for s in steps}
while step_id in existing_ids:
step_id = f"step-{uuid.uuid4().hex[:4]}"
steps.append(PlanStep(
step_id=step_id,
name=new_step_def.get("name", "New Step"),
description=new_step_def.get("description", ""),
dependencies=new_step_def.get("dependencies", []),
required_skills=new_step_def.get("required_skills", []),
))
# 重新构建并行组
parallel_groups = self._build_parallel_groups(steps)
return ExecutionPlan(
plan_id=plan.plan_id,
goal=plan.goal,
steps=steps,
parallel_groups=parallel_groups,
skill_gaps=plan.skill_gaps, # 保留原有缺口信息
confirmed=False, # 修改后需要重新确认
metadata=plan.metadata,
)
def validate_plan(self, plan: ExecutionPlan) -> list[str]:
"""验证执行计划的合法性
Returns:
错误信息列表,空列表表示验证通过
"""
errors: list[str] = []
step_ids = {s.step_id for s in plan.steps}
# 检查依赖引用是否存在
for step in plan.steps:
for dep in step.dependencies:
if dep not in step_ids:
errors.append(f"步骤 '{step.step_id}' 依赖不存在的步骤 '{dep}'")
# 检查循环依赖
visited: set[str] = set()
in_stack: set[str] = set()
def has_cycle(sid: str) -> bool:
if sid in in_stack:
return True
if sid in visited:
return False
visited.add(sid)
in_stack.add(sid)
step = plan.get_step(sid)
if step:
for dep in step.dependencies:
if has_cycle(dep):
return True
in_stack.discard(sid)
return False
for step in plan.steps:
if has_cycle(step.step_id):
errors.append(f"检测到循环依赖,涉及步骤 '{step.step_id}'")
break
# 检查并行组与步骤的一致性
grouped_ids: set[str] = set()
for group in plan.parallel_groups:
for sid in group:
if sid not in step_ids:
errors.append(f"并行组包含不存在的步骤 '{sid}'")
if sid in grouped_ids:
errors.append(f"步骤 '{sid}' 出现在多个并行组中")
grouped_ids.add(sid)
ungrouped = step_ids - grouped_ids
if ungrouped:
errors.append(f"步骤未分配到并行组: {', '.join(ungrouped)}")
return errors