Files
Agent-Squad/squad/planner_llm.go
T
weichen b6ff0ab55f
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Failing after 2s
Add catalog-driven orchestration: intent catalog injection
Details (original message):
Add catalog-driven orchestration: intent catalog injection, deterministic plan validation, and system-function steps.
2026-09-29 16:37:46 +00:00

299 lines
11 KiB
Go
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.
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
}
// 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"`
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":[...]})。派生数量尽量少。
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 == "" {
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 {
return nil, fmt.Errorf("llm planner call failed: %w", err)
}
schema, err := parseLLMPlan(raw)
if err != nil {
lastErr = err
continue
}
parsed = schema
break
}
if parsed == nil {
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,
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.Role == "" {
return nil, fmt.Errorf("step %d missing role", i)
}
if _, ok := input.Registry.Get(ls.Role); !ok {
return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role)
}
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,
})
}
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(input.Goal.Text + "\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()
}
// 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 中的 <think>…</think> 块。
func stripThinkBlocks(s string) string {
for {
i := strings.Index(s, "<think>")
if i < 0 {
return s
}
j := strings.Index(s[i:], "</think>")
if j < 0 {
return s[:i] // 未闭合:丢弃思考余段
}
s = s[:i] + s[i+j+len("</think>"):]
}
}