feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -0,0 +1,127 @@
|
||||
// Package skills 实现 SKILL.md 技能加载(借鉴 ccb docs/extensibility/skills 与
|
||||
// picoclaw pkg/skills):skill = 目录下的 SKILL.md(YAML frontmatter + 正文指令),
|
||||
// 不是代码。系统提示只注入 name+description 列表(带预算截断),正文由模型
|
||||
// 通过 load_skill 工具按需拉取,节省上下文。
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.gopher.ink/mirrors2/AutoZero/textx"
|
||||
)
|
||||
|
||||
// Skill 是一个已解析的技能。
|
||||
type Skill struct {
|
||||
Name string // frontmatter name,缺省用目录名
|
||||
Description string // 何时该用这个技能
|
||||
AllowedTools []string // 可选:激活后允许使用的工具白名单
|
||||
Body string // SKILL.md 正文(Markdown 指令)
|
||||
Dir string // 技能目录绝对路径
|
||||
}
|
||||
|
||||
// Load 依次扫描多个技能根目录(每个目录下是 <skill-name>/SKILL.md)。
|
||||
// 同名技能先到先得——调用方把高优先级目录放前面(项目级覆盖用户级)。
|
||||
// 单个坏文件只跳过,不影响其余。
|
||||
func Load(dirs ...string) []Skill {
|
||||
var out []Skill
|
||||
seen := map[string]bool{}
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name(), "SKILL.md")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
meta, body := parseFrontmatter(string(data))
|
||||
name := meta["name"]
|
||||
if name == "" {
|
||||
name = e.Name()
|
||||
}
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
s := Skill{
|
||||
Name: name,
|
||||
Description: meta["description"],
|
||||
Body: strings.TrimSpace(body),
|
||||
Dir: filepath.Join(dir, e.Name()),
|
||||
}
|
||||
if v := meta["allowed-tools"]; v != "" {
|
||||
for _, t := range strings.Split(v, ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
s.AllowedTools = append(s.AllowedTools, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// maxPromptChars 是技能列表注入系统提示的预算(借鉴 ccb 的 1% 上下文预算思路)。
|
||||
const maxPromptChars = 4000
|
||||
|
||||
// PromptSection 生成注入系统提示的技能列表段;空列表返回空串。
|
||||
func PromptSection(sk []Skill) string {
|
||||
if len(sk) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n# Skills\nThe following skills are available. Before doing a task that matches a skill, call the load_skill tool with its name and follow the returned instructions.\n")
|
||||
for _, s := range sk {
|
||||
line := "- " + s.Name
|
||||
if s.Description != "" {
|
||||
line += ": " + s.Description
|
||||
}
|
||||
if len(s.AllowedTools) > 0 {
|
||||
line += fmt.Sprintf(" (allowed-tools: %s)", strings.Join(s.AllowedTools, ", "))
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
out := b.String()
|
||||
if len(out) > maxPromptChars {
|
||||
// 技能描述大量是中文:按字节切会在尾部留下半个字,而这段是 system prompt。
|
||||
out = textx.ClipBytes(out, maxPromptChars) + "\n...[skill list truncated]\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseFrontmatter 解析开头的 `---` YAML 块,只支持顶层 `key: value` 行
|
||||
// (足够覆盖 name/description/allowed-tools,避免引入 YAML 依赖)。
|
||||
// 返回 (meta, 正文)。
|
||||
func parseFrontmatter(raw string) (map[string]string, string) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(s, "---") {
|
||||
return map[string]string{}, raw
|
||||
}
|
||||
rel := strings.Index(s[3:], "\n---")
|
||||
if rel < 0 {
|
||||
return map[string]string{}, raw
|
||||
}
|
||||
meta := map[string]string{}
|
||||
for _, line := range strings.Split(s[3:3+rel], "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"'`)
|
||||
}
|
||||
body := s[3+rel+4:] // 跳过 "\n---"
|
||||
return meta, strings.TrimLeft(body, "\n")
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeSkill(t *testing.T, root, name, content string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoad 验证 frontmatter 解析、优先级覆盖与缺省名。
|
||||
func TestLoad(t *testing.T) {
|
||||
user := t.TempDir()
|
||||
proj := t.TempDir()
|
||||
|
||||
writeSkill(t, user, "deploy", "---\nname: deploy\ndescription: \"How to deploy\"\nallowed-tools: read, grep\n---\n\nStep 1: run make deploy\n")
|
||||
writeSkill(t, user, "notes", "No frontmatter here, just body.\n")
|
||||
writeSkill(t, proj, "deploy", "---\ndescription: Project override\n---\nProject deploy steps.\n")
|
||||
|
||||
got := Load(proj, user) // 项目级优先
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 skills, got %d: %+v", len(got), got)
|
||||
}
|
||||
byName := map[string]Skill{}
|
||||
for _, s := range got {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
dep := byName["deploy"]
|
||||
if dep.Description != "Project override" || !strings.Contains(dep.Body, "Project deploy steps") {
|
||||
t.Fatalf("project skill should win: %+v", dep)
|
||||
}
|
||||
notes := byName["notes"]
|
||||
if notes.Name != "notes" || !strings.Contains(notes.Body, "just body") {
|
||||
t.Fatalf("missing frontmatter should fall back to dir name: %+v", notes)
|
||||
}
|
||||
if len(dep.AllowedTools) != 0 {
|
||||
t.Fatalf("override should not inherit allowed-tools from user version")
|
||||
}
|
||||
|
||||
// 用户级版本的字段
|
||||
gotUser := Load(user)
|
||||
var found bool
|
||||
for _, s := range gotUser {
|
||||
if s.Name == "deploy" {
|
||||
found = true
|
||||
if len(s.AllowedTools) != 2 || s.AllowedTools[0] != "read" || s.AllowedTools[1] != "grep" {
|
||||
t.Fatalf("allowed-tools parse: %+v", s.AllowedTools)
|
||||
}
|
||||
if !strings.Contains(s.Body, "make deploy") {
|
||||
t.Fatalf("body: %q", s.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("user deploy skill missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromptSection:列表注入、预算截断、空列表为空。
|
||||
func TestPromptSection(t *testing.T) {
|
||||
if PromptSection(nil) != "" {
|
||||
t.Fatal("empty skills should produce empty section")
|
||||
}
|
||||
sk := []Skill{{Name: "deploy", Description: "How to deploy", AllowedTools: []string{"read"}}}
|
||||
sec := PromptSection(sk)
|
||||
if !strings.Contains(sec, "- deploy: How to deploy") || !strings.Contains(sec, "allowed-tools: read") {
|
||||
t.Fatalf("section: %q", sec)
|
||||
}
|
||||
many := make([]Skill, 200)
|
||||
for i := range many {
|
||||
many[i] = Skill{Name: strings.Repeat("s", 40) + string(rune('a'+i%26)) + string(rune(i))}
|
||||
}
|
||||
if got := PromptSection(many); len(got) > maxPromptChars+100 {
|
||||
t.Fatalf("budget exceeded: %d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 技能描述在本仓库里几乎都是中文(SKILL.md 的 frontmatter),而 PromptSection 的
|
||||
// 预算 maxPromptChars 是字节数:按字节切会在尾部留下半个中文字,这段是要进 system
|
||||
// prompt 发给模型的。
|
||||
func TestPromptSectionTruncationKeepsUTF8(t *testing.T) {
|
||||
var sk []Skill
|
||||
for i := 0; i < 200; i++ {
|
||||
sk = append(sk, Skill{
|
||||
Name: fmt.Sprintf("技能%03d", i),
|
||||
Description: strings.Repeat("用于演示中文描述的截断位置", 20),
|
||||
})
|
||||
}
|
||||
out := PromptSection(sk)
|
||||
if len(out) <= maxPromptChars {
|
||||
t.Skip("没造出溢出场景,用例无效")
|
||||
}
|
||||
if !utf8.ValidString(out) {
|
||||
t.Errorf("技能列表截断后含非法 UTF-8,切点附近:%q",
|
||||
out[maxPromptChars-20:min(len(out), maxPromptChars+30)])
|
||||
}
|
||||
if !strings.Contains(out, "skill list truncated") {
|
||||
t.Error("截断标记丢了——用户与模型都不知道这份列表不是全量")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user