package squad
import (
"context"
"encoding/json"
"fmt"
"strings"
"agent-native/runtime/llm"
)
// LLMPlanner LLM 规划器:用模型理解目标 → 拆解任务 → 选/派角色 → 生成步骤。
// 模型客户端与默认模型在构造时注入(角色级模型在派生角色时由模型自行指定)。
// 输出为严格 JSON;解析失败时重试一次,仍失败则 fail-closed 返回错误。
type LLMPlanner struct {
Client *llm.Client
Model string
APIBase string
APIKey string
MaxTokens int
// Fallback 在模型调用或解析失败时接手。空则保持 fail-closed。
Fallback Planner
// MaxSteps 计划步骤硬上限(默认 32)。
MaxSteps int
}
// NewLLMPlanner 创建 LLM 规划器。
func NewLLMPlanner(client *llm.Client, model string) *LLMPlanner {
return &LLMPlanner{Client: client, Model: model}
}
func (p *LLMPlanner) Name() string { return "llm" }
// llmPlanSchema 期望模型输出的 JSON 结构(宽松解析:仅取必要字段)。
type llmPlanSchema struct {
Clarify []llmQuestion `json:"clarify,omitempty"`
Confirm bool `json:"confirm,omitempty"`
Reason string `json:"confirm_reason,omitempty"`
Agents []llmAgent `json:"agents"`
Steps []llmStep `json:"steps"`
Template string `json:"template,omitempty"`
}
type llmQuestion struct {
ID string `json:"id,omitempty"`
Text string `json:"text"`
Options []HumanOption `json:"options,omitempty"`
}
type llmAgent struct {
Action string `json:"action"` // use | derive
Role string `json:"role"`
Name string `json:"name,omitempty"`
BaseRole string `json:"base_role,omitempty"`
Model string `json:"model,omitempty"`
Backend string `json:"backend,omitempty"`
ToolProfile string `json:"tool_profile,omitempty"` // workspace | reasoning
Tools []string `json:"tools,omitempty"` // 工具白名单(可选)
Skills []string `json:"skills,omitempty"` // 技能标识(须已安装)
BashPermission string `json:"bash_permission,omitempty"` // allow | ask | deny
DomainTopic string `json:"domain_topic,omitempty"`
Responsibilities []string `json:"responsibilities,omitempty"`
}
type llmStep struct {
ID string `json:"id"`
Role string `json:"role"`
Title string `json:"title"`
Parallel bool `json:"parallel,omitempty"`
Targets []string `json:"targets,omitempty"`
RequiresApproval bool `json:"requires_approval,omitempty"`
IntentID string `json:"intent_id,omitempty"`
IntentSlots map[string]string `json:"intent_slots,omitempty"`
}
const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标、已注册角色清单和可选的任务模板,输出一个可执行的小队计划。
规则:
1. 只输出一个 JSON 对象,不要输出任何解释文字或代码围栏。
2. 目标信息不足时必须输出 {"clarify":[{"id":"q1","text":"问题","options":[{"value":"continue","label":"…"}]}]} 并停止,不要替用户假设。判定标准(任一命中即 clarify):目标少于 15 个字;没有明确的对象/主题(如"帮我写个东西");没有可辨识的交付物;关键约束互相矛盾。
3. 正常情况输出 {"agents":[...],"steps":[...],"confirm":bool,"confirm_reason":"...","template":"markdown 骨架"}。
4. agents: 优先复用已注册角色({"action":"use","role":"已注册key"});确无合适角色才派生({"action":"derive","role":"新key","base_role":"基线key","name":"中文名","domain_topic":"领域","responsibilities":[...]})。派生数量尽量少。派生角色可附执行配置:tool_profile(workspace|reasoning,默认 workspace)、tools(工具白名单,如 ["read_file","grep"] 或命名空间通配 ["mcp__github__*"])、skills(技能标识,须已安装)、bash_permission(allow|ask|deny,默认 deny=禁用命令执行;ask=先问人)。仅在角色确需该能力时声明,默认不展开。
5. steps: 每步 {"id":"step_01_x","role":"角色key","title":"步骤职责"};可并行的相邻步骤 parallel=true;高风险/破坏性/对外发布类步骤 requires_approval=true;步骤通常 2~6 个,含梳理、专业执行、汇总成文、终审把关。
6. 涉及写入、删除、对外提交或超出常规预算的计划 confirm=true。
7. 通用守则:缺失的信息若会实质改变结果、执行路径、权限或安全,必须 clarify 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。
8. 目标下方若给出"系统能力目录":子任务命中目录能力时,该步输出 {"role":"intent","intent_id":"目录id","intent_slots":{...},"title":"..."},槽位必须按目录填齐;必填槽位信息不足时禁止编造,改为 clarify 并使用目录的提问建议。未命中目录的子任务照常派给角色。没有目录时不得输出 intent_id 步骤。`
func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
if p.Client == nil || p.Model == "" {
if p.Fallback != nil {
return p.Fallback.Plan(ctx, input)
}
return nil, fmt.Errorf("llm planner requires client and model")
}
userMsg := p.buildPrompt(input)
var parsed *llmPlanSchema
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
sys := llmPlannerSystem
if attempt > 0 {
sys += "\n注意:上一次输出无法解析为 JSON。这次只输出 JSON 对象本身。"
}
raw, err := p.complete(ctx, sys, userMsg)
if err != nil {
lastErr = err
continue
}
schema, err := parseLLMPlan(raw)
if err != nil {
lastErr = err
continue
}
parsed = schema
break
}
if parsed == nil {
if p.Fallback != nil {
return p.Fallback.Plan(ctx, input)
}
return nil, fmt.Errorf("llm planner output unparsable after retry: %w", lastErr)
}
if len(parsed.Clarify) > 0 && len(parsed.Agents) == 0 {
cl := &Clarification{Reason: "模型判定目标信息不足"}
for _, q := range parsed.Clarify {
cl.Questions = append(cl.Questions, Question{ID: q.ID, Text: q.Text, Options: q.Options})
}
return &PlanOutput{Clarification: cl}, nil
}
if len(parsed.Steps) == 0 {
return nil, fmt.Errorf("llm planner produced no steps")
}
return p.materialize(input, parsed)
}
// materialize 把模型输出落成 RoleSpec/StepSpec:use 校验存在,derive 走注册表派生。
func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanOutput, error) {
var agents []RoleSpec
addAgent := func(spec RoleSpec) {
for _, a := range agents {
if a.Role == spec.Role {
return
}
}
agents = append(agents, spec)
}
for _, la := range parsed.Agents {
switch strings.ToLower(la.Action) {
case "derive":
derived, err := input.Registry.Derive(DeriveRequest{
RoleKey: la.Role,
Name: la.Name,
BaseRole: la.BaseRole,
Backend: la.Backend,
Model: la.Model,
ToolProfile: la.ToolProfile,
Tools: la.Tools,
Skills: la.Skills,
BashPermission: la.BashPermission,
DomainTopic: la.DomainTopic,
Responsibilities: la.Responsibilities,
})
if err != nil {
return nil, fmt.Errorf("derive role %s: %w", la.Role, err)
}
addAgent(derived)
default: // use
spec, ok := input.Registry.Get(la.Role)
if !ok {
return nil, fmt.Errorf("plan references unregistered role %q (use derive action for new roles)", la.Role)
}
addAgent(spec)
}
}
steps := make([]StepSpec, 0, len(parsed.Steps))
for i, ls := range parsed.Steps {
if ls.IntentID == "" && ls.Role == "" {
return nil, fmt.Errorf("step %d missing role", i)
}
if ls.IntentID == "" {
if _, ok := input.Registry.Get(ls.Role); !ok {
return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role)
}
} else if ls.Role == "" {
ls.Role = "intent"
}
if _, inSquad := findAgent(agents, ls.Role); !inSquad {
if spec, ok := input.Registry.Get(ls.Role); ok {
addAgent(spec)
}
}
id := ls.ID
if id == "" {
id = fmt.Sprintf("step_%02d", i+1)
}
targets := ls.Targets
if len(targets) == 0 {
targets = []string{"output.md"}
}
steps = append(steps, StepSpec{
ID: id, Role: ls.Role, Title: ls.Title, Parallel: ls.Parallel,
Targets: targets, RequiresApproval: ls.RequiresApproval, Status: "pending",
IntentID: ls.IntentID, IntentSlots: ls.IntentSlots,
})
}
maxSteps := p.MaxSteps
if maxSteps <= 0 {
maxSteps = 32
}
if len(steps) > maxSteps {
return nil, fmt.Errorf("plan has %d steps, max %d", len(steps), maxSteps)
}
plan := &SquadPlan{
Squad: SquadConfig{SquadName: truncate(input.Goal.Text, 40), Version: "1", Agents: agents},
Workflow: WorkflowPlan{
WorkflowID: NewPlanID(),
Title: truncate(input.Goal.Text, 80),
PlanningMode: PlanModeAutonomous,
Status: "pending",
Steps: steps,
},
Template: parsed.Template,
}
return &PlanOutput{Plan: plan, NeedsConfirmation: parsed.Confirm, ConfirmReason: parsed.Reason}, nil
}
func (p *LLMPlanner) buildPrompt(input PlanInput) string {
var b strings.Builder
b.WriteString("## 用户目标\n")
b.WriteString(segmentGoal(input.Goal.Text))
b.WriteString("\n")
if len(input.Goal.Constraints) > 0 {
b.WriteString("\n## 约束\n")
for k, v := range input.Goal.Constraints {
fmt.Fprintf(&b, "- %s: %s\n", k, v)
}
}
if len(input.Goal.Materials) > 0 {
b.WriteString("\n## 输入材料(文件名)\n")
for _, m := range input.Goal.Materials {
fmt.Fprintf(&b, "- %s\n", m.Filename)
}
}
b.WriteString("\n## 已注册角色\n")
for _, r := range input.Registry.List() {
fmt.Fprintf(&b, "- key=%s name=%s backend=%s keywords=%v responsibilities=%v\n",
r.Role, r.Name, r.Backend, r.Keywords, r.Responsibilities)
}
if input.Blueprint != nil {
b.WriteString("\n## 命中任务模板(可作为基线微调)\n")
enc, _ := json.Marshal(input.Blueprint)
b.Write(enc)
b.WriteString("\n")
}
if input.IntentCatalog != nil && input.IntentCatalog.Markdown != "" {
b.WriteString("\n## 系统能力目录(可直接调用,不必派智能体)\n")
b.WriteString(input.IntentCatalog.Markdown)
b.WriteString("\n")
}
return b.String()
}
const goalSegmentRunes = 2000
// segmentGoal 把长目标按段标出,避免整段原文无界注入提示词。
func segmentGoal(text string) string {
runes := []rune(text)
if len(runes) <= goalSegmentRunes {
return text + "\n"
}
var b strings.Builder
for i := 0; i < len(runes); i += goalSegmentRunes {
end := i + goalSegmentRunes
if end > len(runes) {
end = len(runes)
}
fmt.Fprintf(&b, "\n### 目标第 %d 段\n%s\n", i/goalSegmentRunes+1, string(runes[i:end]))
}
return b.String()
}
// complete 调一次模型并拼接完整文本。
func (p *LLMPlanner) complete(ctx context.Context, system, user string) (string, error) {
msgs := []llm.Message{
{Role: llm.RoleSystem, Content: system},
{Role: llm.RoleUser, Content: user},
}
opts := llm.StreamOptions{MaxTokens: p.MaxTokens, APIBase: p.APIBase, APIKey: p.APIKey}
var sb strings.Builder
for ev := range p.Client.Stream(ctx, p.Model, msgs, nil, opts) {
if ev.Kind == llm.KindError {
return "", fmt.Errorf("%s", ev.ErrorMessage)
}
if ev.Kind == llm.KindTextDelta {
sb.WriteString(ev.Text)
}
if ev.Kind == llm.KindMessageEnd {
if ev.Message.IsError {
return "", fmt.Errorf("assistant error: %s", ev.Message.Content)
}
if sb.Len() == 0 {
sb.WriteString(ev.Message.Content)
}
break
}
}
if ctx.Err() != nil {
return "", ctx.Err()
}
return sb.String(), nil
}
// parseLLMPlan 从模型输出稳健解析 JSON(剥离 think 块/代码围栏/前后杂文)。
func parseLLMPlan(raw string) (*llmPlanSchema, error) {
s := stripThinkBlocks(strings.TrimSpace(raw))
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
// 截取首个 { 到最后一个 },容忍模型前后夹杂说明文字。
if i := strings.IndexByte(s, '{'); i >= 0 {
if j := strings.LastIndexByte(s, '}'); j > i {
s = s[i : j+1]
}
}
var out llmPlanSchema
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return &out, nil
}
// stripThinkBlocks 移除 MiniMax 等模型内嵌在 content 中的 … 块。
func stripThinkBlocks(s string) string {
for {
i := strings.Index(s, "")
if i < 0 {
return s
}
j := strings.Index(s[i:], "")
if j < 0 {
return s[:i] // 未闭合:丢弃思考余段
}
s = s[:i] + s[i+j+len(""):]
}
}