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:
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user