Import agent-squad as a standalone repository.
Pin Agent-Native as a submodule so the module builds without the multi-repo sibling path. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。`
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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>"):]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user