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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 10:12:26 +00:00
co-authored by Cursor
parent 2dedc0d20c
commit dfd0d23bf3
6 changed files with 367 additions and 0 deletions
+37
View File
@@ -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
}
+44
View File
@@ -0,0 +1,44 @@
package resources
import (
"os"
"path/filepath"
"strings"
)
// Expand replaces {{var}} placeholders and /name includes.
// An include reads <dir>/<name>.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')
}
+72
View File
@@ -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)
}
}
+102
View File
@@ -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")
}
+29
View File
@@ -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) {
+83
View File
@@ -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
}