diff --git a/runtime/ext/pkg/manager.go b/runtime/ext/pkg/manager.go new file mode 100644 index 0000000..2ef4c88 --- /dev/null +++ b/runtime/ext/pkg/manager.go @@ -0,0 +1,181 @@ +package pkg + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Manifest is the agent-native.json discovered in a package. +type Manifest struct { + Name string `json:"name"` + Extensions []string `json:"extensions,omitempty"` + Config map[string]string `json:"config,omitempty"` +} + +// Manager installs packages under Dir. +type Manager struct { + Dir string +} + +func (m Manager) root() string { + if m.Dir != "" { + return m.Dir + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".agent-native", "packages") +} + +// Install copies a local directory, clones a git URL, or runs npm pack +// when src starts with "npm:". The package name comes from its manifest. +func (m Manager) Install(src string) (Manifest, error) { + dir, err := os.MkdirTemp("", "agent-native-pkg-*") + if err != nil { + return Manifest{}, err + } + defer os.RemoveAll(dir) + switch { + case strings.HasPrefix(src, "npm:"): + if _, err := exec.LookPath("npm"); err != nil { + return Manifest{}, fmt.Errorf("pkg: npm is not installed") + } + cmd := exec.Command("npm", "pack", strings.TrimPrefix(src, "npm:")) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + return Manifest{}, fmt.Errorf("pkg: npm pack: %s", out) + } + case strings.HasPrefix(src, "git+") || strings.HasSuffix(src, ".git") || strings.Contains(src, "://"): + cmd := exec.Command("git", "clone", "--depth", "1", src, filepath.Join(dir, "src")) + if out, err := cmd.CombinedOutput(); err != nil { + return Manifest{}, fmt.Errorf("pkg: git clone: %s", out) + } + dir = filepath.Join(dir, "src") + default: + if err := copyDir(src, filepath.Join(dir, "src")); err != nil { + return Manifest{}, err + } + dir = filepath.Join(dir, "src") + } + manifest, err := ReadManifest(dir) + if err != nil { + return Manifest{}, err + } + dest := filepath.Join(m.root(), manifest.Name) + if err := os.RemoveAll(dest); err != nil { + return Manifest{}, err + } + if err := copyDir(dir, dest); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +// Remove deletes an installed package. +func (m Manager) Remove(name string) error { + return os.RemoveAll(filepath.Join(m.root(), name)) +} + +// Update reinstalls from the recorded source when present, otherwise no-ops. +func (m Manager) Update(name string) (Manifest, error) { + manifest, err := ReadManifest(filepath.Join(m.root(), name)) + if err != nil { + return Manifest{}, err + } + src := manifest.Config["source"] + if src == "" { + return manifest, nil + } + return m.Install(src) +} + +// List returns installed manifests. +func (m Manager) List() ([]Manifest, error) { + entries, err := os.ReadDir(m.root()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []Manifest + for _, ent := range entries { + if !ent.IsDir() { + continue + } + manifest, err := ReadManifest(filepath.Join(m.root(), ent.Name())) + if err != nil { + continue + } + out = append(out, manifest) + } + return out, nil +} + +// Config sets one package config key. +func (m Manager) Config(name, key, value string) error { + path := filepath.Join(m.root(), name) + manifest, err := ReadManifest(path) + if err != nil { + return err + } + if manifest.Config == nil { + manifest.Config = map[string]string{} + } + manifest.Config[key] = value + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(path, "agent-native.json"), raw, 0o644) +} + +// ReadManifest loads agent-native.json from a directory. +func ReadManifest(dir string) (Manifest, error) { + raw, err := os.ReadFile(filepath.Join(dir, "agent-native.json")) + if err != nil { + return Manifest{}, err + } + var manifest Manifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return Manifest{}, err + } + if manifest.Name == "" { + return Manifest{}, fmt.Errorf("pkg: manifest name is empty") + } + return manifest, nil +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err + }) +} diff --git a/runtime/ext/process.go b/runtime/ext/process.go new file mode 100644 index 0000000..99069ff --- /dev/null +++ b/runtime/ext/process.go @@ -0,0 +1,235 @@ +package ext + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" + "sync" + + "agent-native/runtime/llm" + "agent-native/runtime/tools" +) + +// Message is one JSONL frame on the extension pipe. +type Message struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` + Content string `json:"content,omitempty"` + IsError bool `json:"isError,omitempty"` + Event string `json:"event,omitempty"` + Block bool `json:"block,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Protocol string `json:"protocol,omitempty"` + BaseURL string `json:"baseURL,omitempty"` + CustomType string `json:"customType,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Command string `json:"command,omitempty"` +} + +// UIRequest is a question an extension wants a human to answer. +type UIRequest struct { + ID string + Message string +} + +// Process is one extension subprocess. +type Process struct { + cmd *exec.Cmd + stdin io.WriteCloser + mu sync.Mutex + tools []registeredTool + commands []string + providers []llm.Provider + pending map[string]chan Message + UI chan UIRequest + OnCustom func(customType string, data json.RawMessage) + closed bool +} + +type registeredTool struct { + name string + description string + parameters json.RawMessage + proc *Process +} + +func (t registeredTool) Name() string { return t.name } +func (t registeredTool) Description() string { return t.description } +func (t registeredTool) Parameters() json.RawMessage { return t.parameters } +func (t registeredTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) { + return t.proc.callTool(ctx, t.name, args) +} + +// Start launches command and reads its registration frames until it goes idle +// or the context ends. command is argv, not a shell line. +func Start(ctx context.Context, command []string) (*Process, error) { + if len(command) == 0 { + return nil, fmt.Errorf("ext: empty command") + } + cmd := exec.CommandContext(ctx, command[0], command[1:]...) + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + p := &Process{ + cmd: cmd, stdin: stdin, pending: map[string]chan Message{}, + UI: make(chan UIRequest, 4), + } + go p.read(stdout) + if err := p.send(Message{Type: "init"}); err != nil { + _ = p.Close() + return nil, err + } + return p, nil +} + +// Tools returns the tools the extension registered. +func (p *Process) Tools() []tools.Tool { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]tools.Tool, len(p.tools)) + for i := range p.tools { + out[i] = p.tools[i] + } + return out +} + +// Commands returns slash-command names. +func (p *Process) Commands() []string { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.commands...) +} + +// Providers returns endpoints the extension registered. +func (p *Process) Providers() []llm.Provider { + p.mu.Lock() + defer p.mu.Unlock() + return append([]llm.Provider(nil), p.providers...) +} + +// Hook tells the extension about an event. A block result stops the host action. +func (p *Process) Hook(ctx context.Context, event, name string, args json.RawMessage) (bool, string) { + id := name + ":" + event + ch := make(chan Message, 1) + p.mu.Lock() + p.pending[id] = ch + p.mu.Unlock() + if err := p.send(Message{Type: "event", ID: id, Event: event, Name: name, Arguments: args}); err != nil { + return false, "" + } + select { + case <-ctx.Done(): + return false, ctx.Err().Error() + case msg := <-ch: + return msg.Block, msg.Reason + } +} + +func (p *Process) callTool(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) { + id := name + ch := make(chan Message, 1) + p.mu.Lock() + p.pending[id] = ch + p.mu.Unlock() + if err := p.send(Message{Type: "tool_call", ID: id, Name: name, Arguments: args}); err != nil { + return tools.Result{IsError: true, Content: err.Error()}, err + } + select { + case <-ctx.Done(): + return tools.Result{IsError: true, Content: ctx.Err().Error()}, ctx.Err() + case msg := <-ch: + return tools.Result{Content: msg.Content, IsError: msg.IsError}, nil + } +} + +func (p *Process) send(msg Message) error { + raw, err := json.Marshal(msg) + if err != nil { + return err + } + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return fmt.Errorf("ext: closed") + } + _, err = p.stdin.Write(append(raw, '\n')) + return err +} + +func (p *Process) read(r io.Reader) { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for sc.Scan() { + var msg Message + if err := json.Unmarshal(sc.Bytes(), &msg); err != nil { + continue + } + p.dispatch(msg) + } +} + +func (p *Process) dispatch(msg Message) { + switch msg.Type { + case "register_tool": + p.mu.Lock() + p.tools = append(p.tools, registeredTool{ + name: msg.Name, description: msg.Description, parameters: msg.Parameters, proc: p, + }) + p.mu.Unlock() + case "register_command": + p.mu.Lock() + p.commands = append(p.commands, msg.Name) + p.mu.Unlock() + case "register_provider": + p.mu.Lock() + p.providers = append(p.providers, llm.Provider{ + Name: msg.Name, Protocol: llm.Protocol(msg.Protocol), BaseURL: msg.BaseURL, + }) + p.mu.Unlock() + case "tool_result", "hook_result": + p.mu.Lock() + ch := p.pending[msg.ID] + delete(p.pending, msg.ID) + p.mu.Unlock() + if ch != nil { + ch <- msg + } + case "ui_request": + select { + case p.UI <- UIRequest{ID: msg.ID, Message: msg.Message}: + default: + } + case "custom_entry": + if p.OnCustom != nil { + p.OnCustom(msg.CustomType, msg.Data) + } + } +} + +// Close stops the subprocess. +func (p *Process) Close() error { + p.mu.Lock() + p.closed = true + _ = p.stdin.Close() + p.mu.Unlock() + if p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + } + return p.cmd.Wait() +} diff --git a/runtime/ext/process_test.go b/runtime/ext/process_test.go new file mode 100644 index 0000000..bc3c9e4 --- /dev/null +++ b/runtime/ext/process_test.go @@ -0,0 +1,100 @@ +package ext + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "agent-native/runtime/ext/pkg" +) + +func TestExtensionRegistersAndHooks(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "ext.sh") + body := `#!/bin/sh +while IFS= read -r line; do + case "$line" in + *'"type":"init"'*) + echo '{"type":"register_tool","name":"ping","description":"p","parameters":{"type":"object"}}' + echo '{"type":"register_command","name":"hi"}' + echo '{"type":"register_provider","name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9"}' + echo '{"type":"ui_request","id":"u1","message":"choose"}' + ;; + *'"type":"tool_call"'*) + echo '{"type":"tool_result","id":"ping","content":"pong"}' + ;; + *'"type":"event"'*) + echo '{"type":"hook_result","id":"ping:tool_call","block":true,"reason":"no"}' + ;; + esac +done +` + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + proc, err := Start(ctx, []string{"/bin/sh", script}) + if err != nil { + t.Fatal(err) + } + defer proc.Close() + deadline := time.Now().Add(2 * time.Second) + for len(proc.Tools()) == 0 && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + tools := proc.Tools() + if len(tools) != 1 || tools[0].Name() != "ping" { + t.Fatalf("tools %+v", tools) + } + res, err := tools[0].Execute(ctx, []byte(`{}`)) + if err != nil || res.Content != "pong" { + t.Fatalf("tool: %+v %v", res, err) + } + block, reason := proc.Hook(ctx, "tool_call", "ping", []byte(`{}`)) + if !block || reason != "no" { + t.Fatalf("hook %v %s", block, reason) + } + if len(proc.Commands()) != 1 || len(proc.Providers()) != 1 { + t.Fatalf("commands %v providers %v", proc.Commands(), proc.Providers()) + } + select { + case ui := <-proc.UI: + if ui.Message != "choose" { + t.Fatal(ui) + } + case <-time.After(time.Second): + t.Fatal("missing ui request") + } +} + +func TestPackageInstallListConfigRemove(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "agent-native.json"), []byte(`{"name":"demo","extensions":["./ext.sh"]}`), 0o644); err != nil { + t.Fatal(err) + } + mgr := pkg.Manager{Dir: t.TempDir()} + manifest, err := mgr.Install(src) + if err != nil || manifest.Name != "demo" { + t.Fatalf("%+v %v", manifest, err) + } + if err := mgr.Config("demo", "source", src); err != nil { + t.Fatal(err) + } + list, err := mgr.List() + if err != nil || len(list) != 1 || list[0].Config["source"] != src { + t.Fatalf("list %+v %v", list, err) + } + if _, err := mgr.Update("demo"); err != nil { + t.Fatal(err) + } + if err := mgr.Remove("demo"); err != nil { + t.Fatal(err) + } + list, _ = mgr.List() + if len(list) != 0 { + t.Fatalf("still installed: %+v", list) + } +}