From dfd0d23bf3ed9c25cb9bef48c25b51fd5de452cc Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 10:12:26 +0000 Subject: [PATCH] Add pi tool aliases, context files, and project trust Why: Headless runs need the pi tool names, walked context files, prompt includes, and a trust gate before project settings are loaded. Evidence: go test -count=1 ./runtime/tools/ ./runtime/resources/ ./runtime/skills/ Impact: Existing tool names stay registered. Aliases are added only when the underlying tool exists. Refs: feature/pi-headless N4 Co-authored-by: Cursor --- runtime/resources/context.go | 37 ++++++++++ runtime/resources/prompt.go | 44 ++++++++++++ runtime/resources/resources_test.go | 72 ++++++++++++++++++++ runtime/resources/settings.go | 102 ++++++++++++++++++++++++++++ runtime/skills/skills.go | 29 ++++++++ runtime/tools/alias.go | 83 ++++++++++++++++++++++ 6 files changed, 367 insertions(+) create mode 100644 runtime/resources/context.go create mode 100644 runtime/resources/prompt.go create mode 100644 runtime/resources/resources_test.go create mode 100644 runtime/resources/settings.go create mode 100644 runtime/tools/alias.go diff --git a/runtime/resources/context.go b/runtime/resources/context.go new file mode 100644 index 0000000..d4d84b2 --- /dev/null +++ b/runtime/resources/context.go @@ -0,0 +1,37 @@ +package resources + +import ( + "os" + "path/filepath" + "strings" +) + +// ContextFiles walks from dir toward the filesystem root and concatenates +// AGENTS.md and CLAUDE.md (nearest first). SYSTEM.md and APPEND_SYSTEM.md +// are read from dir only. +func ContextFiles(dir string) (string, error) { + var parts []string + cur := dir + for { + for _, name := range []string{"AGENTS.md", "CLAUDE.md"} { + raw, err := os.ReadFile(filepath.Join(cur, name)) + if err != nil { + continue + } + parts = append(parts, "# "+name+" ("+cur+")\n"+string(raw)) + } + parent := filepath.Dir(cur) + if parent == cur { + break + } + cur = parent + } + for _, name := range []string{"SYSTEM.md", "APPEND_SYSTEM.md"} { + raw, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + parts = append(parts, "# "+name+"\n"+string(raw)) + } + return strings.Join(parts, "\n\n"), nil +} diff --git a/runtime/resources/prompt.go b/runtime/resources/prompt.go new file mode 100644 index 0000000..44d3518 --- /dev/null +++ b/runtime/resources/prompt.go @@ -0,0 +1,44 @@ +package resources + +import ( + "os" + "path/filepath" + "strings" +) + +// Expand replaces {{var}} placeholders and /name includes. +// An include reads /.md. Unknown variables are left intact. +func Expand(text, dir string, vars map[string]string) (string, error) { + out := text + for key, value := range vars { + out = strings.ReplaceAll(out, "{{"+key+"}}", value) + } + for { + i := strings.Index(out, "/") + if i < 0 { + break + } + rest := out[i+1:] + j := 0 + for j < len(rest) && isName(rest[j]) { + j++ + } + if j == 0 || (i > 0 && out[i-1] != ' ' && out[i-1] != '\n') { + break + } + name := rest[:j] + raw, err := os.ReadFile(filepath.Join(dir, name+".md")) + if err != nil { + break + } + out = out[:i] + string(raw) + out[i+1+j:] + } + for key, value := range vars { + out = strings.ReplaceAll(out, "{{"+key+"}}", value) + } + return out, nil +} + +func isName(b byte) bool { + return b == '-' || b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') +} diff --git a/runtime/resources/resources_test.go b/runtime/resources/resources_test.go new file mode 100644 index 0000000..7080749 --- /dev/null +++ b/runtime/resources/resources_test.go @@ -0,0 +1,72 @@ +package resources + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "agent-native/runtime/skills" + "agent-native/runtime/tools" +) + +func TestContextAndPromptAndTrust(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + if err := os.MkdirAll(proj, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("root-agents"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proj, "SYSTEM.md"), []byte("sys"), 0o644); err != nil { + t.Fatal(err) + } + text, err := ContextFiles(proj) + if err != nil || !strings.Contains(text, "root-agents") || !strings.Contains(text, "sys") { + t.Fatalf("context: %s %v", text, err) + } + if err := os.WriteFile(filepath.Join(proj, "greet.md"), []byte("hello {{name}}"), 0o644); err != nil { + t.Fatal(err) + } + got, err := Expand("say /greet", proj, map[string]string{"name": "Ada"}) + if err != nil || got != "say hello Ada" { + t.Fatalf("expand: %q %v", got, err) + } + if ProjectTrusted(TrustAsk, false, false) { + t.Fatal("ask is not trusted without approve") + } + if !ProjectTrusted(TrustNever, true, false) { + t.Fatal("approve overrides never") + } + if ProjectTrusted(TrustAlways, false, true) { + t.Fatal("no-approve wins") + } +} + +func TestPiAliasesFilterAndSkillCommand(t *testing.T) { + reg := tools.WorkspaceTools(t.TempDir()) + reg.RegisterPiAliases() + if !reg.Has("read") || !reg.Has("ls") || !reg.Has("read_file") { + t.Fatal("aliases missing") + } + if err := reg.Filter(tools.FilterOptions{Exclude: []string{"read"}, NoBuiltin: false}); err != nil { + t.Fatal(err) + } + if reg.Has("read") || !reg.Has("read_file") { + t.Fatal("exclude should drop only the alias") + } + dir := t.TempDir() + skillDir := filepath.Join(dir, "review") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("be thorough"), 0o644); err != nil { + t.Fatal(err) + } + store := skills.NewStore(dir) + out, err := store.ExpandCommands("use /skill:review now") + if err != nil || !strings.Contains(out, "be thorough") { + t.Fatalf("skill expand: %q %v", out, err) + } +} diff --git a/runtime/resources/settings.go b/runtime/resources/settings.go new file mode 100644 index 0000000..fda7296 --- /dev/null +++ b/runtime/resources/settings.go @@ -0,0 +1,102 @@ +package resources + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Trust is how a project directory is treated in non-interactive runs. +type Trust string + +const ( + TrustAsk Trust = "ask" + TrustAlways Trust = "always" + TrustNever Trust = "never" +) + +// Settings is the global plus project configuration. +type Settings struct { + Trust Trust `json:"trust,omitempty"` + Model string `json:"model,omitempty"` + Thinking string `json:"thinking,omitempty"` + Tools []string `json:"tools,omitempty"` + Exclude []string `json:"exclude,omitempty"` + Vars map[string]string `json:"vars,omitempty"` +} + +// LoadSettings reads global (optional) then project settings. +// Project keys override global keys. A missing file is an empty settings. +func LoadSettings(globalPath, projectPath string) (Settings, error) { + global, err := readSettings(globalPath) + if err != nil { + return Settings{}, err + } + project, err := readSettings(projectPath) + if err != nil { + return Settings{}, err + } + return mergeSettings(global, project), nil +} + +// ProjectTrusted reports whether project resources may be loaded. +// ask and never refuse in non-interactive mode. approve forces trust. +func ProjectTrusted(trust Trust, approve, noApprove bool) bool { + if noApprove { + return false + } + if approve { + return true + } + return trust == TrustAlways +} + +func readSettings(path string) (Settings, error) { + if path == "" { + return Settings{}, nil + } + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return Settings{}, nil + } + return Settings{}, err + } + var s Settings + if err := json.Unmarshal(raw, &s); err != nil { + return Settings{}, err + } + return s, nil +} + +func mergeSettings(base, over Settings) Settings { + if over.Trust != "" { + base.Trust = over.Trust + } + if over.Model != "" { + base.Model = over.Model + } + if over.Thinking != "" { + base.Thinking = over.Thinking + } + if over.Tools != nil { + base.Tools = over.Tools + } + if over.Exclude != nil { + base.Exclude = over.Exclude + } + if len(over.Vars) > 0 { + if base.Vars == nil { + base.Vars = map[string]string{} + } + for k, v := range over.Vars { + base.Vars[k] = v + } + } + return base +} + +// DefaultPaths returns the global and project settings paths for a cwd. +func DefaultPaths(home, cwd string) (string, string) { + return filepath.Join(home, ".agent-native", "settings.json"), filepath.Join(cwd, ".agent-native", "settings.json") +} diff --git a/runtime/skills/skills.go b/runtime/skills/skills.go index 387661c..d2158e9 100644 --- a/runtime/skills/skills.go +++ b/runtime/skills/skills.go @@ -107,6 +107,35 @@ func (s *Store) Load(name string) (Skill, error) { return Skill{}, fmt.Errorf("unknown skill %q (not installed in any configured root)", name) } +// ExpandCommands replaces /skill:name with that skill's body. +// An unknown name returns an error and leaves the text unchanged. +func (s *Store) ExpandCommands(text string) (string, error) { + const marker = "/skill:" + var b strings.Builder + rest := text + for { + i := strings.Index(rest, marker) + if i < 0 { + b.WriteString(rest) + break + } + b.WriteString(rest[:i]) + name := rest[i+len(marker):] + end := 0 + for end < len(name) && name[end] != ' ' && name[end] != '\n' && name[end] != '\t' { + end++ + } + skillName := name[:end] + skill, err := s.Load(skillName) + if err != nil { + return text, err + } + b.WriteString(skill.Body) + rest = name[end:] + } + return b.String(), nil +} + // parse 解析一份 SKILL.md:目录名权威,frontmatter 可选。 func parse(dirName, path string, data []byte) (Skill, error) { if !validSkillName(dirName) { diff --git a/runtime/tools/alias.go b/runtime/tools/alias.go new file mode 100644 index 0000000..bd3ad5f --- /dev/null +++ b/runtime/tools/alias.go @@ -0,0 +1,83 @@ +package tools + +import ( + "context" + "encoding/json" +) + +// aliasTool exposes an existing tool under a pi-style name. +type aliasTool struct { + name string + inner Tool +} + +func (a aliasTool) Name() string { return a.name } +func (a aliasTool) Description() string { return a.inner.Description() } +func (a aliasTool) Parameters() json.RawMessage { return a.inner.Parameters() } +func (a aliasTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) { + return a.inner.Execute(ctx, args) +} + +// piAliases maps the headless pi tool name onto the name already registered. +var piAliases = map[string]string{ + "read": "read_file", + "write": "write_file", + "edit": "edit_file", + "bash": "run_command", + "grep": "grep", + "find": "glob", + "ls": "list_dir", +} + +// RegisterPiAliases adds pi tool names beside the existing ones. +// A name is skipped when the underlying tool is not registered. +func (r *Registry) RegisterPiAliases() { + for alias, inner := range piAliases { + if alias == inner || r.Has(alias) || !r.Has(inner) { + continue + } + r.Register(aliasTool{name: alias, inner: r.tools[inner]}) + } +} + +// FilterOptions selects which tools stay visible. +type FilterOptions struct { + Tools []string + Exclude []string + NoBuiltin bool +} + +// Filter applies an allow list, then exclusions. NoBuiltin drops the +// workspace tool names and their pi aliases. +func (r *Registry) Filter(opts FilterOptions) error { + if len(opts.Tools) > 0 { + if err := r.Restrict(opts.Tools); err != nil { + return err + } + } + drop := map[string]bool{} + for _, name := range opts.Exclude { + drop[name] = true + } + if opts.NoBuiltin { + for _, name := range []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "run_command", "run_python"} { + drop[name] = true + } + for alias := range piAliases { + drop[alias] = true + } + } + if len(drop) == 0 { + return nil + } + var order []string + for _, name := range r.order { + if drop[name] { + delete(r.tools, name) + continue + } + order = append(order, name) + } + r.order = order + return nil +}