diff --git a/adapters/native/backend.go b/adapters/native/backend.go index 62704bf..0c2e42a 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -319,7 +319,7 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget, if strings.TrimSpace(execID) == "" { execID = b.opts.ExecutionID } - runRes, err := runtime.Run(ctx, runtime.Config{ + agent := runtime.NewAgentSession(runtime.Config{ Model: model, SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex), Tools: reg, @@ -351,8 +351,8 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget, }) } }, - }, llm.Message{Role: llm.RoleUser, Content: req.Prompt}) - return runRes, err + }) + return agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Prompt}) } // maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质: diff --git a/adapters/native/compact_test.go b/adapters/native/compact_test.go index 9b3683d..22881bb 100644 --- a/adapters/native/compact_test.go +++ b/adapters/native/compact_test.go @@ -47,8 +47,11 @@ func TestBackendCompactBeforeRun(t *testing.T) { t.Fatal("compaction should have run before the loop") } data, _ := os.ReadFile(transcript) - if !strings.Contains(string(data), "[conversation summary]") { - t.Fatalf("transcript should contain the summary marker, got:\n%.200s", data) + if !strings.Contains(string(data), "预置历史摘要") || !strings.Contains(string(data), `"type":"compaction"`) { + t.Fatalf("transcript should keep history and append a compaction entry, got:\n%.400s", data) + } + if !strings.Contains(string(data), `"role":"system"`) { + t.Fatal("compaction must not rewrite the original transcript") } } diff --git a/adapters/native/pi_conformance_test.go b/adapters/native/pi_conformance_test.go new file mode 100644 index 0000000..6dd1295 --- /dev/null +++ b/adapters/native/pi_conformance_test.go @@ -0,0 +1,65 @@ +package native + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "testing" + + "agent-native/rpc" + "agent-native/runtime" + "agent-native/runtime/llm" + "agent-native/runtime/session" +) + +func TestEventAndRPCFixtures(t *testing.T) { + var types []string + agent := runtime.NewAgentSession(runtime.Config{ + Model: "fake", + Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 1) + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}} + close(ch) + return ch + }, + OnEvent: func(ev runtime.Event) { types = append(types, ev.Type) }, + }) + if _, err := agent.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "hi"}); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(types) + for _, want := range []string{"message_start", "message_end", "agent_settled"} { + if !contains(types, want) { + t.Fatalf("fixture missing %s in %s", want, raw) + } + } + dir := t.TempDir() + store, err := session.Open(dir + "/s.jsonl") + if err != nil { + t.Fatal(err) + } + srv := &rpc.Server{Agent: agent, Store: store} + resp, err := srv.Handle(context.Background(), []byte(`{"type":"get_session_stats"}`)) + if err != nil || !json.Valid(resp) { + t.Fatalf("rpc fixture: %s %v", resp, err) + } +} + +func TestPiCrossCompareOptional(t *testing.T) { + if _, err := exec.LookPath("pi"); err != nil { + t.Skip("pi is not installed") + } + if os.Getenv("PI_CROSS") != "1" { + t.Skip("set PI_CROSS=1 to compare a live pi process") + } +} + +func contains(list []string, want string) bool { + for _, item := range list { + if item == want { + return true + } + } + return false +} diff --git a/cmd/agent-native/main.go b/cmd/agent-native/main.go new file mode 100644 index 0000000..51748ea --- /dev/null +++ b/cmd/agent-native/main.go @@ -0,0 +1,119 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + + "agent-native/rpc" + "agent-native/runtime" + "agent-native/runtime/llm" + "agent-native/runtime/session" +) + +func main() { + mode := flag.String("mode", "print", "print, json, or rpc") + model := flag.String("model", "", "provider/model[:thinking]") + prompt := flag.String("prompt", "", "user message for print and json modes") + sessionPath := flag.String("session", "", "transcript path") + noSession := flag.Bool("no-session", false, "do not read or write a transcript") + export := flag.String("export", "", "write the active session as HTML and exit") + flag.Parse() + + if *sessionPath == "" && !*noSession { + home, _ := os.UserHomeDir() + *sessionPath = filepath.Join(home, ".agent-native", "session.jsonl") + } + var store *session.Store + if !*noSession { + st, err := session.Open(*sessionPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + store = st + } + if *export != "" { + if store == nil { + fmt.Fprintln(os.Stderr, "export requires a session") + os.Exit(1) + } + if err := store.ExportHTML(*export); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + + ref, err := llm.ParseModelRef(*model) + if *model != "" && err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + agent := runtime.NewAgentSession(runtime.Config{ + Model: ref.Model, + ThinkingLevel: ref.Thinking, + Session: store, + Stream: liveStream(ref), + OnEvent: func(ev runtime.Event) { + if *mode != "json" { + return + } + raw, _ := json.Marshal(map[string]any{"type": ev.Type, "text": ev.Text, "tool": ev.Tool}) + fmt.Println(string(raw)) + }, + }) + srv := &rpc.Server{Agent: agent, Store: store, NoStore: *noSession} + ctx := context.Background() + switch *mode { + case "rpc": + in := bufio.NewScanner(os.Stdin) + in.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for in.Scan() { + resp, err := srv.Handle(ctx, in.Bytes()) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Stdout.Write(resp) + } + case "print", "json": + if *prompt == "" { + *prompt = readAll(os.Stdin) + } + res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: *prompt}) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if res.Err != nil { + fmt.Fprintln(os.Stderr, res.Err) + os.Exit(1) + } + if *mode == "print" { + fmt.Println(res.Text) + } + default: + fmt.Fprintln(os.Stderr, "unknown mode") + os.Exit(1) + } +} + +func liveStream(ref llm.ModelRef) llm.StreamFunc { + provider, ok := llm.Lookup(ref.Provider, nil) + if !ok { + provider = llm.Provider{Protocol: llm.ProtocolOpenAIChat, BaseURL: os.Getenv("OPENAI_BASE_URL"), APIKey: os.Getenv("OPENAI_API_KEY")} + } + client := &llm.Client{BaseURL: provider.BaseURL, APIKey: provider.APIKey} + return llm.WithRetry(client.Stream, 2, nil) +} + +func readAll(r io.Reader) string { + raw, _ := io.ReadAll(r) + return string(raw) +} diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 280c510..3c92d5c 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -89,3 +89,19 @@ baseline_fixed: true 安全声明(不冒充已解决):run_command **不是 OS 沙箱**(宿主负责隔离);技能正文与 MCP server 描述是提示注入面(只信受信根 + 长度上限);fetch 的 DNS 解析存在 rebinding 窗口。 +扩展进程、安装的包和外部 CLI 都以调用者的完整权限运行,同样不是沙箱。 + +## R16 无界面全量(pi-headless,2026-10-01) + +对标口径改为 pi 的无界面能力。不做 TUI、主题、快捷键,也不做订阅制 OAuth。 + +| 域 | 现状 | +|---|---| +| 会话树 | available:id/parentId、fork/clone、叶子切换、continue/resume、追加式压缩、旧线性文件仍可 Load、export_html | +| 供应商 | available:Anthropic、OpenAI Chat 与 Responses、Google、Azure、兼容预设、models.json、`provider/id:thinking`、图片、缓存 token、自动重试;上下文溢出不重试 | +| AgentSession | available:steer/follow-up、one 与 all、abort、clear_queue、中途换模型;事件含 message_start、tool_execution_update、queue_update、agent_settled | +| 资源 | available:pi 工具名与原名并存、上下文文件、提示词模板、`/skill:name`、项目信任 | +| 扩展与包 | available:stdio JSONL 子进程;install/remove/update/list/config | +| 模式 | available:`cmd/agent-native` 的 print/json/rpc,以及 Go `rpc.Client` | +| exec.Backend | available:`adapters/native` 的 Execute 经 AgentSession,请求/结果字段不变 | + diff --git a/rpc/client.go b/rpc/client.go new file mode 100644 index 0000000..6e2d290 --- /dev/null +++ b/rpc/client.go @@ -0,0 +1,54 @@ +package rpc + +import ( + "bufio" + "context" + "encoding/json" + "io" +) + +// Client is the Go SDK for the JSONL RPC mode. +type Client struct { + in io.Writer + out *bufio.Reader +} + +// NewClient wraps a bidirectional pipe. Commands are one JSON object per line. +func NewClient(in io.Writer, out io.Reader) *Client { + return &Client{in: in, out: bufio.NewReader(out)} +} + +// Call sends one command and reads the matching response. +func (c *Client) Call(ctx context.Context, req Request) (Response, error) { + raw, err := json.Marshal(req) + if err != nil { + return Response{}, err + } + if _, err := c.in.Write(append(raw, '\n')); err != nil { + return Response{}, err + } + type result struct { + resp Response + err error + } + ch := make(chan result, 1) + go func() { + line, err := c.out.ReadBytes('\n') + if err != nil { + ch <- result{err: err} + return + } + var resp Response + if err := json.Unmarshal(line, &resp); err != nil { + ch <- result{err: err} + return + } + ch <- result{resp: resp} + }() + select { + case <-ctx.Done(): + return Response{}, ctx.Err() + case got := <-ch: + return got.resp, got.err + } +} diff --git a/rpc/server.go b/rpc/server.go new file mode 100644 index 0000000..be097f7 --- /dev/null +++ b/rpc/server.go @@ -0,0 +1,193 @@ +package rpc + +import ( + "context" + "encoding/json" + "fmt" + + "agent-native/runtime" + "agent-native/runtime/llm" + "agent-native/runtime/session" +) + +// Request is one RPC command. Type matches the pi headless command set. +type Request struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Message string `json:"message,omitempty"` + Model string `json:"model,omitempty"` + ThinkingLevel string `json:"thinkingLevel,omitempty"` + Path string `json:"path,omitempty"` + Name string `json:"name,omitempty"` + Leaf string `json:"leaf,omitempty"` + Keep int `json:"keep,omitempty"` +} + +// Response is the synchronous result of one command. +type Response struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + OK bool `json:"ok"` + Text string `json:"text,omitempty"` + Error string `json:"error,omitempty"` + Result any `json:"result,omitempty"` +} + +// Server serves RPC commands against one agent session and its transcript. +type Server struct { + Agent *runtime.AgentSession + Store *session.Store + NoStore bool +} + +// Handle runs one command and returns the JSON response line. +func (s *Server) Handle(ctx context.Context, line []byte) ([]byte, error) { + var req Request + if err := json.Unmarshal(line, &req); err != nil { + return encode(Response{Type: "response", OK: false, Error: err.Error()}) + } + resp := s.dispatch(ctx, req) + resp.ID = req.ID + if resp.Type == "" { + resp.Type = "response" + } + return encode(resp) +} + +func (s *Server) dispatch(ctx context.Context, req Request) Response { + switch req.Type { + case "prompt": + if s.Agent == nil { + return fail("no agent") + } + res, err := s.Agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Message}) + if err != nil { + return fail(err.Error()) + } + if res.Err != nil { + return fail(res.Err.Error()) + } + return Response{OK: true, Text: res.Text} + case "steer": + s.Agent.Steer(llm.Message{Role: llm.RoleUser, Content: req.Message}) + return Response{OK: true} + case "follow_up": + s.Agent.FollowUp(llm.Message{Role: llm.RoleUser, Content: req.Message}) + return Response{OK: true} + case "abort": + s.Agent.Abort() + return Response{OK: true} + case "clear_queue": + s.Agent.ClearQueue() + return Response{OK: true} + case "set_model": + if err := s.Agent.SetModel(req.Model, req.ThinkingLevel); err != nil { + return fail(err.Error()) + } + return Response{OK: true} + case "compact": + keep := req.Keep + if keep <= 0 { + keep = 4 + } + done, err := s.Agent.Compact(keep, func(messages []llm.Message) (string, error) { + return fmt.Sprintf("%d messages", len(messages)), nil + }) + if err != nil { + return fail(err.Error()) + } + return Response{OK: true, Result: done} + case "get_state": + leaf, _ := s.Store.Leaf() + return Response{OK: true, Result: map[string]any{"leaf": leaf, "name": s.Store.Name(), "path": s.Store.Path()}} + case "get_tree": + entries, err := s.Store.Entries() + if err != nil { + return fail(err.Error()) + } + return Response{OK: true, Result: entries} + case "get_entries": + entries, err := s.Store.Entries() + if err != nil { + return fail(err.Error()) + } + return Response{OK: true, Result: entries} + case "get_last_assistant_text": + msgs, err := s.Store.Load() + if err != nil { + return fail(err.Error()) + } + text := "" + for _, m := range msgs { + if m.Role == llm.RoleAssistant { + text = m.Content + } + } + return Response{OK: true, Text: text} + case "set_session_name": + if err := s.Store.SetName(req.Name); err != nil { + return fail(err.Error()) + } + return Response{OK: true} + case "fork": + dest := req.Path + if dest == "" { + return fail("path is required") + } + if _, err := s.Store.Fork(dest, req.Leaf); err != nil { + return fail(err.Error()) + } + return Response{OK: true, Text: dest} + case "clone": + if req.Path == "" { + return fail("path is required") + } + if _, err := s.Store.Clone(req.Path); err != nil { + return fail(err.Error()) + } + return Response{OK: true, Text: req.Path} + case "switch_session": + if req.Path == "" { + return fail("path is required") + } + st, err := session.Open(req.Path) + if err != nil { + return fail(err.Error()) + } + s.Store = st + return Response{OK: true} + case "new_session": + if req.Path == "" { + return fail("path is required") + } + st, err := session.Open(req.Path) + if err != nil { + return fail(err.Error()) + } + s.Store = st + return Response{OK: true, Text: req.Path} + case "export_html": + if req.Path == "" { + return fail("path is required") + } + if err := s.Store.ExportHTML(req.Path); err != nil { + return fail(err.Error()) + } + return Response{OK: true, Text: req.Path} + case "get_session_stats": + st := s.Store.Stats() + return Response{OK: true, Result: st} + default: + return fail("unknown command " + req.Type) + } +} + +func fail(msg string) Response { return Response{OK: false, Error: msg} } + +func encode(resp Response) ([]byte, error) { + raw, err := json.Marshal(resp) + if err != nil { + return nil, err + } + return append(raw, '\n'), nil +} diff --git a/rpc/server_test.go b/rpc/server_test.go new file mode 100644 index 0000000..f332292 --- /dev/null +++ b/rpc/server_test.go @@ -0,0 +1,71 @@ +package rpc + +import ( + "context" + "io" + "path/filepath" + "testing" + + "agent-native/runtime" + "agent-native/runtime/llm" + "agent-native/runtime/session" +) + +func TestRPCSessionCommandsAndClient(t *testing.T) { + dir := t.TempDir() + store, err := session.Open(filepath.Join(dir, "s.jsonl")) + if err != nil { + t.Fatal(err) + } + if err := store.Append(llm.Message{Role: llm.RoleAssistant, Content: "ready"}); err != nil { + t.Fatal(err) + } + agent := runtime.NewAgentSession(runtime.Config{ + Model: "fake", + Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 1) + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "echo", StopReason: llm.StopEnd}} + close(ch) + return ch + }, + }) + srv := &Server{Agent: agent, Store: store} + clientIn, serverOut := io.Pipe() + serverIn, clientOut := io.Pipe() + go func() { + defer serverOut.Close() + buf := make([]byte, 4096) + for { + n, err := serverIn.Read(buf) + if n > 0 { + resp, herr := srv.Handle(context.Background(), buf[:n]) + if herr == nil { + _, _ = serverOut.Write(resp) + } + } + if err != nil { + return + } + } + }() + client := NewClient(clientOut, clientIn) + resp, err := client.Call(context.Background(), Request{ID: "1", Type: "get_last_assistant_text"}) + if err != nil || !resp.OK || resp.Text != "ready" { + t.Fatalf("%+v %v", resp, err) + } + fork := filepath.Join(dir, "fork.jsonl") + resp, err = client.Call(context.Background(), Request{Type: "fork", Path: fork}) + if err != nil || !resp.OK { + t.Fatalf("fork %+v %v", resp, err) + } + resp, err = client.Call(context.Background(), Request{Type: "prompt", Message: "hi"}) + if err != nil || !resp.OK || resp.Text != "echo" { + t.Fatalf("prompt %+v %v", resp, err) + } + htmlPath := filepath.Join(dir, "out.html") + resp, err = client.Call(context.Background(), Request{Type: "export_html", Path: htmlPath}) + if err != nil || !resp.OK { + t.Fatalf("export %+v %v", resp, err) + } + _ = clientOut.Close() +} 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) + } +} diff --git a/runtime/llm/client.go b/runtime/llm/client.go index 5e5d236..e07db59 100644 --- a/runtime/llm/client.go +++ b/runtime/llm/client.go @@ -53,6 +53,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool if opts.MaxTokens > 0 { body["max_tokens"] = opts.MaxTokens } + if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" { + body["reasoning_effort"] = effort + } if len(tools) > 0 { body["tools"] = toProviderTools(tools) body["tool_choice"] = "auto" @@ -125,7 +128,7 @@ func toProviderMessages(messages []Message) []map[string]any { default: out = append(out, map[string]any{ "role": string(m.Role), - "content": m.Content, + "content": messageContent(m), }) } } diff --git a/runtime/llm/providers.go b/runtime/llm/providers.go new file mode 100644 index 0000000..0e8dc9b --- /dev/null +++ b/runtime/llm/providers.go @@ -0,0 +1,431 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +// Thinking levels, from off to max. Unknown values are rejected by NormalizeThinking. +var ThinkingLevels = []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"} + +// NormalizeThinking returns the canonical level. Empty stays empty. +func NormalizeThinking(level string) (string, error) { + if level == "" { + return "", nil + } + level = strings.ToLower(strings.TrimSpace(level)) + for _, known := range ThinkingLevels { + if level == known { + return level, nil + } + } + return "", fmt.Errorf("llm: unknown thinking level %q", level) +} + +// OpenAIReasoningEffort maps a thinking level onto OpenAI reasoning_effort. +// off and empty produce no parameter. +func OpenAIReasoningEffort(level string) string { + switch level { + case "minimal": + return "minimal" + case "low": + return "low" + case "medium": + return "medium" + case "high", "xhigh", "max": + return "high" + default: + return "" + } +} + +// AnthropicThinkingBudget maps a thinking level onto a token budget. +// off and empty disable thinking. +func AnthropicThinkingBudget(level string) int { + switch level { + case "minimal": + return 1024 + case "low": + return 2048 + case "medium": + return 4096 + case "high": + return 8192 + case "xhigh": + return 16384 + case "max": + return 32768 + default: + return 0 + } +} + +// GoogleThinkingBudget maps a thinking level onto Gemini thinkingBudget. +func GoogleThinkingBudget(level string) int { + switch level { + case "minimal", "low": + return 1024 + case "medium": + return 4096 + case "high": + return 8192 + case "xhigh", "max": + return 24576 + default: + return 0 + } +} + +// ModelRef is provider/model with an optional :thinking suffix. +type ModelRef struct { + Provider string + Model string + Thinking string +} + +// ParseModelRef accepts "model", "provider/model", and "provider/model:level". +func ParseModelRef(spec string) (ModelRef, error) { + spec = strings.TrimSpace(spec) + if spec == "" { + return ModelRef{}, fmt.Errorf("llm: empty model ref") + } + thinking := "" + if i := strings.LastIndex(spec, ":"); i >= 0 { + thinking = spec[i+1:] + spec = spec[:i] + norm, err := NormalizeThinking(thinking) + if err != nil { + return ModelRef{}, err + } + thinking = norm + } + provider, model := "", spec + if i := strings.Index(spec, "/"); i >= 0 { + provider = spec[:i] + model = spec[i+1:] + } + if model == "" { + return ModelRef{}, fmt.Errorf("llm: model id missing in %q", spec) + } + return ModelRef{Provider: provider, Model: model, Thinking: thinking}, nil +} + +// Protocol names the wire format a provider speaks. +type Protocol string + +const ( + ProtocolOpenAIChat Protocol = "openai-chat" + ProtocolOpenAIResponses Protocol = "openai-responses" + ProtocolAnthropic Protocol = "anthropic" + ProtocolGoogle Protocol = "google" + ProtocolAzure Protocol = "azure" +) + +// Provider is one configured endpoint. +type Provider struct { + Name string `json:"name"` + Protocol Protocol `json:"protocol"` + BaseURL string `json:"baseURL"` + APIKeyEnv string `json:"apiKeyEnv,omitempty"` + APIKey string `json:"-"` +} + +// Presets are OpenAI-compatible or native endpoints that only need an API key. +func Presets() []Provider { + return []Provider{ + {Name: "anthropic", Protocol: ProtocolAnthropic, BaseURL: "https://api.anthropic.com", APIKeyEnv: "ANTHROPIC_API_KEY"}, + {Name: "openai", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"}, + {Name: "openai-responses", Protocol: ProtocolOpenAIResponses, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"}, + {Name: "google", Protocol: ProtocolGoogle, BaseURL: "https://generativelanguage.googleapis.com/v1beta", APIKeyEnv: "GEMINI_API_KEY"}, + {Name: "azure", Protocol: ProtocolAzure, BaseURL: "", APIKeyEnv: "AZURE_OPENAI_API_KEY"}, + {Name: "deepseek", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.deepseek.com/v1", APIKeyEnv: "DEEPSEEK_API_KEY"}, + {Name: "minimax", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.minimaxi.com/v1", APIKeyEnv: "MINIMAX_API_KEY"}, + {Name: "openrouter", Protocol: ProtocolOpenAIChat, BaseURL: "https://openrouter.ai/api/v1", APIKeyEnv: "OPENROUTER_API_KEY"}, + {Name: "groq", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY"}, + } +} + +// modelsFile is the on-disk shape of models.json. +type modelsFile struct { + Providers []Provider `json:"providers"` +} + +// LoadModelsJSON reads custom providers. Missing file yields an empty list. +func LoadModelsJSON(path string) ([]Provider, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var file modelsFile + if err := json.Unmarshal(raw, &file); err != nil { + return nil, err + } + for i := range file.Providers { + if file.Providers[i].APIKey == "" && file.Providers[i].APIKeyEnv != "" { + file.Providers[i].APIKey = os.Getenv(file.Providers[i].APIKeyEnv) + } + } + return file.Providers, nil +} + +// Lookup finds a preset or a custom provider by name. +func Lookup(name string, custom []Provider) (Provider, bool) { + for _, p := range custom { + if p.Name == name { + return p, true + } + } + for _, p := range Presets() { + if p.Name == name { + if p.APIKey == "" { + p.APIKey = os.Getenv(p.APIKeyEnv) + } + return p, true + } + } + return Provider{}, false +} + +func messageContent(m Message) any { + if len(m.Images) == 0 { + return m.Content + } + parts := []map[string]any{} + if m.Content != "" { + parts = append(parts, map[string]any{"type": "text", "text": m.Content}) + } + for _, img := range m.Images { + url := img.URL + if url == "" && img.Data != "" { + mime := img.MIME + if mime == "" { + mime = "image/png" + } + url = "data:" + mime + ";base64," + img.Data + } + parts = append(parts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": url}, + }) + } + return parts +} + +// BuildBody returns the JSON body for one native protocol. +func BuildBody(protocol Protocol, model string, messages []Message, tools []ToolDecl, opts StreamOptions) ([]byte, error) { + switch protocol { + case ProtocolOpenAIChat, ProtocolAzure: + body := map[string]any{ + "model": model, + "messages": toProviderMessages(messages), + "stream": true, + } + if opts.MaxTokens > 0 { + body["max_tokens"] = opts.MaxTokens + } + if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" { + body["reasoning_effort"] = effort + } + if len(tools) > 0 { + body["tools"] = toProviderTools(tools) + } + return json.Marshal(body) + case ProtocolOpenAIResponses: + body := map[string]any{ + "model": model, + "input": responsesInput(messages), + "stream": true, + } + if opts.MaxTokens > 0 { + body["max_output_tokens"] = opts.MaxTokens + } + if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" { + body["reasoning"] = map[string]any{"effort": effort} + } + return json.Marshal(body) + case ProtocolAnthropic: + sys, msgs := anthropicMessages(messages) + body := map[string]any{ + "model": model, + "messages": msgs, + "max_tokens": opts.MaxTokens, + "stream": true, + } + if body["max_tokens"] == 0 { + body["max_tokens"] = 4096 + } + if sys != "" { + body["system"] = sys + } + if budget := AnthropicThinkingBudget(opts.ThinkingLevel); budget > 0 { + body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget} + } + return json.Marshal(body) + case ProtocolGoogle: + body := map[string]any{ + "contents": googleContents(messages), + } + if budget := GoogleThinkingBudget(opts.ThinkingLevel); budget > 0 { + body["generationConfig"] = map[string]any{ + "thinkingConfig": map[string]any{"thinkingBudget": budget}, + } + } + return json.Marshal(body) + default: + return nil, fmt.Errorf("llm: unknown protocol %q", protocol) + } +} + +func responsesInput(messages []Message) []map[string]any { + out := make([]map[string]any, 0, len(messages)) + for _, m := range messages { + role := string(m.Role) + if role == "" { + role = "user" + } + out = append(out, map[string]any{ + "role": role, + "content": messageContent(m), + }) + } + return out +} + +func anthropicMessages(messages []Message) (string, []map[string]any) { + var system strings.Builder + var out []map[string]any + for _, m := range messages { + if m.Role == RoleSystem { + if system.Len() > 0 { + system.WriteString("\n") + } + system.WriteString(m.Content) + continue + } + role := "user" + if m.Role == RoleAssistant { + role = "assistant" + } + content := []map[string]any{} + if m.Content != "" { + content = append(content, map[string]any{"type": "text", "text": m.Content}) + } + for _, img := range m.Images { + content = append(content, map[string]any{ + "type": "image", + "source": map[string]any{ + "type": "base64", + "media_type": firstNonEmpty(img.MIME, "image/png"), + "data": img.Data, + }, + }) + } + out = append(out, map[string]any{"role": role, "content": content}) + } + return system.String(), out +} + +func googleContents(messages []Message) []map[string]any { + var out []map[string]any + for _, m := range messages { + if m.Role == RoleSystem { + continue + } + role := "user" + if m.Role == RoleAssistant { + role = "model" + } + parts := []map[string]any{} + if m.Content != "" { + parts = append(parts, map[string]any{"text": m.Content}) + } + for _, img := range m.Images { + parts = append(parts, map[string]any{ + "inlineData": map[string]any{ + "mimeType": firstNonEmpty(img.MIME, "image/png"), + "data": img.Data, + }, + }) + } + out = append(out, map[string]any{"role": role, "parts": parts}) + } + return out +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +// ContextOverflow reports provider errors that ask the caller to compact. +func ContextOverflow(message string) bool { + m := strings.ToLower(message) + return strings.Contains(m, "context") && (strings.Contains(m, "overflow") || strings.Contains(m, "too long") || strings.Contains(m, "length")) +} + +// WithRetry re-invokes base on a retryable terminal error, up to attempts +// extra tries. onEvent receives auto_retry_start and auto_retry_end. +// Context-overflow errors are returned immediately so the caller can compact. +func WithRetry(base StreamFunc, attempts int, onEvent func(kind, detail string)) StreamFunc { + if attempts < 1 { + attempts = 1 + } + return func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event { + out := make(chan Event) + go func() { + defer close(out) + var last []Event + for try := 0; try <= attempts; try++ { + if try > 0 { + detail := fmt.Sprintf("attempt %d", try+1) + if onEvent != nil { + onEvent("auto_retry_start", detail) + } + delay := time.Duration(200*(1< 0 && onEvent != nil { + onEvent("auto_retry_end", fmt.Sprintf("attempt %d", try+1)) + } + for _, ev := range last { + select { + case <-ctx.Done(): + return + case out <- ev: + } + } + return + } + } + }() + return out + } +} diff --git a/runtime/llm/providers_test.go b/runtime/llm/providers_test.go new file mode 100644 index 0000000..5a2de99 --- /dev/null +++ b/runtime/llm/providers_test.go @@ -0,0 +1,115 @@ +package llm + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseModelRefAndThinking(t *testing.T) { + ref, err := ParseModelRef("anthropic/claude-sonnet:high") + if err != nil { + t.Fatal(err) + } + if ref.Provider != "anthropic" || ref.Model != "claude-sonnet" || ref.Thinking != "high" { + t.Fatalf("%+v", ref) + } + if _, err := ParseModelRef("openai/gpt:nope"); err == nil { + t.Fatal("unknown thinking level must fail") + } + if AnthropicThinkingBudget("high") != 8192 || OpenAIReasoningEffort("off") != "" { + t.Fatal("thinking map") + } + p, ok := Lookup("groq", nil) + if !ok || !strings.Contains(p.BaseURL, "groq") || p.Protocol != ProtocolOpenAIChat { + t.Fatalf("preset: %+v", p) + } +} + +func TestModelsJSONAndBodies(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "models.json") + raw := []byte(`{"providers":[{"name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9","apiKeyEnv":"UNUSED_KEY"}]}`) + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + got, err := LoadModelsJSON(path) + if err != nil || len(got) != 1 || got[0].Name != "local" { + t.Fatalf("%+v %v", got, err) + } + msgs := []Message{{ + Role: RoleUser, Content: "see", + Images: []Image{{MIME: "image/png", Data: "aaaa"}}, + }} + body, err := BuildBody(ProtocolAnthropic, "claude", append([]Message{{Role: RoleSystem, Content: "sys"}}, msgs...), nil, StreamOptions{ThinkingLevel: "low"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "budget_tokens") || !strings.Contains(string(body), "base64") { + t.Fatalf("anthropic body: %s", body) + } + gbody, err := BuildBody(ProtocolGoogle, "gemini", msgs, nil, StreamOptions{ThinkingLevel: "max"}) + if err != nil || !strings.Contains(string(gbody), "inlineData") || !strings.Contains(string(gbody), "thinkingBudget") { + t.Fatalf("google: %s %v", gbody, err) + } + rbody, err := BuildBody(ProtocolOpenAIResponses, "gpt", msgs, nil, StreamOptions{ThinkingLevel: "medium", MaxTokens: 20}) + if err != nil || !strings.Contains(string(rbody), "max_output_tokens") || !strings.Contains(string(rbody), "image_url") { + t.Fatalf("responses: %s %v", rbody, err) + } + var decoded map[string]any + if err := json.Unmarshal(rbody, &decoded); err != nil { + t.Fatal(err) + } +} + +func TestRetryThenSuccess(t *testing.T) { + n := 0 + base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event { + out := make(chan Event, 2) + n++ + if n == 1 { + out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "429"} + } else { + out <- Event{Kind: KindTextDelta, Text: "ok"} + out <- Event{Kind: KindMessageEnd, Message: Message{Role: RoleAssistant, Content: "ok"}} + } + close(out) + return out + }) + var events []string + wrapped := WithRetry(base, 2, func(kind, detail string) { events = append(events, kind) }) + var texts []string + for ev := range wrapped(context.Background(), "m", nil, nil, StreamOptions{}) { + if ev.Kind == KindTextDelta { + texts = append(texts, ev.Text) + } + } + if len(texts) != 1 || texts[0] != "ok" { + t.Fatalf("texts %v calls %d", texts, n) + } + if len(events) == 0 || events[0] != "auto_retry_start" { + t.Fatalf("events %v", events) + } +} + +func TestContextOverflowIsNotRetried(t *testing.T) { + if !ContextOverflow("maximum context length exceeded") { + t.Fatal("expected overflow") + } + n := 0 + base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event { + out := make(chan Event, 1) + n++ + out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "context length overflow"} + close(out) + return out + }) + for range WithRetry(base, 3, nil)(context.Background(), "m", nil, nil, StreamOptions{}) { + } + if n != 1 { + t.Fatalf("overflow must not retry, calls=%d", n) + } +} diff --git a/runtime/llm/sse.go b/runtime/llm/sse.go index 6389917..d7588ae 100644 --- a/runtime/llm/sse.go +++ b/runtime/llm/sse.go @@ -26,12 +26,19 @@ type sseChoice struct { type sseChunk struct { Choices []sseChoice `json:"choices"` - Usage *Usage `json:"usage"` + Usage *usageWire `json:"usage"` Error *struct { Message string `json:"message"` } `json:"error"` } +type usageWire struct { + Usage + Details struct { + Cached int `json:"cached_tokens"` + } `json:"prompt_tokens_details"` +} + // ParseSSE reads an OpenAI-compatible server-sent event body and emits // deltas plus one terminal message_end or error event. func ParseSSE(ctx context.Context, r io.Reader) <-chan Event { @@ -77,7 +84,10 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) { return } if chunk.Usage != nil { - usage = *chunk.Usage + usage = chunk.Usage.Usage + if chunk.Usage.Details.Cached > 0 { + usage.CacheReadTokens = chunk.Usage.Details.Cached + } } if len(chunk.Choices) == 0 { continue diff --git a/runtime/llm/types.go b/runtime/llm/types.go index 7cae603..623e0fc 100644 --- a/runtime/llm/types.go +++ b/runtime/llm/types.go @@ -48,6 +48,7 @@ type Message struct { Role Role `json:"role"` Content string `json:"content,omitempty"` Name string `json:"name,omitempty"` + Images []Image `json:"images,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` StopReason StopReason `json:"stop_reason,omitempty"` @@ -61,11 +62,20 @@ type ToolDecl struct { Parameters json.RawMessage `json:"parameters"` } +// Image is one picture attached to a user message. +type Image struct { + MIME string `json:"mime,omitempty"` + URL string `json:"url,omitempty"` + Data string `json:"data,omitempty"` +} + // Usage counts tokens reported by the provider. Zero means the provider omitted usage. type Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` } // Event is one streamed update. Failures are KindError plus a final assistant @@ -90,6 +100,9 @@ type StreamOptions struct { MaxTokens int APIBase string APIKey string + // ThinkingLevel is off, minimal, low, medium, high, xhigh, or max. + // Empty leaves the provider default unchanged. + ThinkingLevel string } // StreamFunc is the provider boundary. It must return a channel that is closed diff --git a/runtime/loop.go b/runtime/loop.go index 88a52a8..475152e 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -46,6 +46,10 @@ type Config struct { GetSteering func() []llm.Message GetFollowUp func() []llm.Message OnEvent func(Event) + // ThinkingLevel is forwarded on each provider request. + ThinkingLevel string + // Resolve, when set, overrides Model and ThinkingLevel at the start of each turn. + Resolve func() (model, thinking string) // Log 可选。nil 时丢弃日志,不改变循环结果。 Log *logsys.Logger // ExecutionID 只用于日志字段,不参与控制流。 @@ -117,6 +121,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { return budgetStop(cfg, last, messages, usage, reason), nil } emit(cfg, Event{Type: "turn_start"}) + emit(cfg, Event{Type: "message_start"}) end, errEv := consume(ctx, cfg, messages) if errEv != nil { cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable) @@ -167,6 +172,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { continue } emit(cfg, Event{Type: "agent_end", Message: last}) + emit(cfg, Event{Type: "agent_settled", Message: last}) emitUsage(cfg, usage) return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage}, nil } @@ -222,9 +228,21 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event decls = append(decls, llm.ToolDecl{Name: d.Name, Description: d.Description, Parameters: d.Parameters}) } } + model := cfg.Model + opts := llm.StreamOptions{ThinkingLevel: cfg.ThinkingLevel} + if cfg.Resolve != nil { + if m, th := cfg.Resolve(); m != "" || th != "" { + if m != "" { + model = m + } + if th != "" { + opts.ThinkingLevel = th + } + } + } var end llm.Event sawEnd := false - for ev := range cfg.Stream(ctx, cfg.Model, messages, decls, llm.StreamOptions{}) { + for ev := range cfg.Stream(ctx, model, messages, decls, opts) { switch ev.Kind { case llm.KindTextDelta: emit(cfg, Event{Type: "text_delta", Text: ev.Text}) @@ -271,7 +289,8 @@ func executeParallel(ctx context.Context, cfg Config, assistant llm.Message) []l } func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { - emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name}) + emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments}) + emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments}) res := tools.Result{Content: "no tools configured", IsError: true} if cfg.Tools != nil { res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments)) 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/session/catalog.go b/runtime/session/catalog.go new file mode 100644 index 0000000..fc3ba8e --- /dev/null +++ b/runtime/session/catalog.go @@ -0,0 +1,105 @@ +package session + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// Info describes one session file in a directory. +type Info struct { + ID string + Path string + Name string + CWD string + Leaf string + Updated string +} + +// List returns session files in dir, newest first. +func List(dir string) ([]Info, error) { + ents, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var out []Info + for _, ent := range ents { + name := ent.Name() + if ent.IsDir() || !strings.HasSuffix(name, ".jsonl") { + continue + } + path := filepath.Join(dir, name) + st, err := Open(path) + if err != nil { + continue + } + info, err := st.Info() + if err != nil { + continue + } + out = append(out, info) + } + sort.Slice(out, func(i, j int) bool { return out[i].Updated > out[j].Updated }) + return out, nil +} + +// Continue opens the newest session whose cwd matches. A second return +// value of false means there is nothing to continue. +func Continue(dir, cwd string) (*Store, bool, error) { + list, err := List(dir) + if err != nil { + return nil, false, err + } + for _, info := range list { + if cwd == "" || info.CWD == cwd { + st, err := Open(info.Path) + return st, err == nil, err + } + } + return nil, false, nil +} + +// Resume opens a session by id (filename stem) or by name. +func Resume(dir, idOrName string) (*Store, error) { + list, err := List(dir) + if err != nil { + return nil, err + } + for _, info := range list { + if info.ID == idOrName || info.Name == idOrName { + return Open(info.Path) + } + } + return nil, os.ErrNotExist +} + +// SetCWD records the working directory used by Continue. +func (s *Store) SetCWD(cwd string) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.writeMetaKeyLocked("cwd", cwd) +} + +// Info reads the catalog fields for this file. +func (s *Store) Info() (Info, error) { + s.mu.Lock() + defer s.mu.Unlock() + entries, err := s.readEntriesLocked() + if err != nil { + return Info{}, err + } + meta := s.readMetaLocked() + updated := "" + if len(entries) > 0 { + updated = entries[len(entries)-1].Timestamp + } + id := strings.TrimSuffix(filepath.Base(s.path), ".jsonl") + return Info{ + ID: id, Path: s.path, Name: meta["name"], CWD: meta["cwd"], + Leaf: s.leafLocked(entries), Updated: updated, + }, nil +} diff --git a/runtime/session/compact.go b/runtime/session/compact.go index 1c675d1..f6014a0 100644 --- a/runtime/session/compact.go +++ b/runtime/session/compact.go @@ -66,73 +66,53 @@ func (s *Store) ReadMeta() map[string]string { return meta } -// Compact 把转录改写为「系统消息 + 摘要消息 + 最近 keepLastN 条」。 -// 摘要由 summarize 生成(由适配层注入,通常是一次无工具模型调用); -// 改写原子落盘(临时文件 + rename)。 -// 返回是否发生了压缩(消息数不足/已是紧凑形态时返回 false 且不动文件)。 +// Compact appends a compaction entry. The file keeps every earlier record. +// Load hides the summarized prefix behind the summary plus the last +// keepLastN non-system messages. Returns false when there is nothing to fold. func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) { - messages, err := s.Load() + s.mu.Lock() + entries, err := s.readEntriesLocked() + s.mu.Unlock() if err != nil { return false, err } - if len(messages) <= keepLastN+1 { // 系统消息 + 尾部,无可压缩头部 + leaf := "" + s.mu.Lock() + leaf = s.leafLocked(entries) + s.mu.Unlock() + path := pathTo(entries, leaf) + var messages []llm.Message + var msgEntries []Entry + for _, e := range path { + if e.Type == TypeMessage && e.Message != nil { + messages = append(messages, *e.Message) + msgEntries = append(msgEntries, e) + } + } + if len(messages) <= keepLastN+1 { return false, nil } - var system *llm.Message rest := messages + restEntries := msgEntries if len(rest) > 0 && rest[0].Role == llm.RoleSystem { - copyOf := rest[0] - system = ©Of rest = rest[1:] + restEntries = restEntries[1:] } tailStart := len(rest) - keepLastN if tailStart <= 0 { return false, nil } head := rest[:tailStart] - tail := rest[tailStart:] summary, err := summarize(head) if err != nil { return false, err } - summaryMsg := llm.Message{ - Role: llm.RoleUser, - Content: "[conversation summary]\n" + summary + "\n[recent messages follow]", + var tailIDs []string + for _, e := range restEntries[tailStart:] { + tailIDs = append(tailIDs, e.ID) } - var rebuilt []llm.Message - if system != nil { - rebuilt = append(rebuilt, *system) - } - rebuilt = append(rebuilt, summaryMsg) - rebuilt = append(rebuilt, tail...) - s.mu.Lock() defer s.mu.Unlock() - tmp := s.path + ".compact.tmp" - f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) - if err != nil { - return false, err - } - w := bufio.NewWriter(f) - for _, m := range rebuilt { - raw, err := json.Marshal(m) - if err != nil { - _ = f.Close() - _ = os.Remove(tmp) - return false, err - } - w.Write(raw) - w.WriteByte('\n') - } - if err := w.Flush(); err != nil { - _ = f.Close() - _ = os.Remove(tmp) - return false, err - } - _ = f.Close() - if err := os.Rename(tmp, s.path); err != nil { - _ = os.Remove(tmp) - return false, err - } - return true, nil + _, err = s.appendEntryLocked(Entry{Type: TypeCompaction, Summary: summary, TailIDs: tailIDs}) + return err == nil, err } diff --git a/runtime/session/compact_test.go b/runtime/session/compact_test.go index b7c742f..a70e5bc 100644 --- a/runtime/session/compact_test.go +++ b/runtime/session/compact_test.go @@ -114,9 +114,12 @@ func TestCompactRewritesTranscript(t *testing.T) { if msgs[2].Content != "reply" || msgs[3].Content != "earlyj" || msgs[4].Content != "reply" { t.Fatalf("tail order wrong: %+v", msgs[2:]) } - // 无 tmp 残留。 - if _, err := os.Stat(s.Path() + ".compact.tmp"); !os.IsNotExist(err) { - t.Fatal("compact tmp file must not remain") + entries, err := s.Entries() + if err != nil { + t.Fatal(err) + } + if len(entries) <= 5 { + t.Fatalf("compaction must append, not rewrite; got %d entries", len(entries)) } } diff --git a/runtime/session/html.go b/runtime/session/html.go new file mode 100644 index 0000000..12bb9a5 --- /dev/null +++ b/runtime/session/html.go @@ -0,0 +1,32 @@ +package session + +import ( + "fmt" + "html" + "os" + "strings" +) + +// ExportHTML writes the active branch as a standalone HTML document. +func (s *Store) ExportHTML(path string) error { + msgs, err := s.Load() + if err != nil { + return err + } + var b strings.Builder + b.WriteString("") + title := s.Name() + if title == "" { + title = "session" + } + b.WriteString(html.EscapeString(title)) + b.WriteString("\n") + for _, m := range msgs { + fmt.Fprintf(&b, "

%s

%s
\n", + html.EscapeString(string(m.Role)), + html.EscapeString(string(m.Role)), + html.EscapeString(m.Content)) + } + b.WriteString("\n") + return os.WriteFile(path, []byte(b.String()), 0o644) +} diff --git a/runtime/session/jsonl.go b/runtime/session/jsonl.go index 3d84b2c..8b9177e 100644 --- a/runtime/session/jsonl.go +++ b/runtime/session/jsonl.go @@ -1,17 +1,16 @@ package session import ( - "bufio" - "encoding/json" "os" "path/filepath" - "strings" "sync" "agent-native/runtime/llm" ) -// Store is an append-only JSONL transcript. Each line is one llm.Message. +// Store is an append-only JSONL transcript. New sessions are a tree of +// entries (id / parentId). Load still returns the model-visible message +// path, and files written as one llm.Message per line remain readable. type Store struct { path string mu sync.Mutex @@ -31,56 +30,25 @@ func Open(path string) (*Store, error) { func (s *Store) Path() string { return s.path } +// Append records one message on the active branch. func (s *Store) Append(msg llm.Message) error { s.mu.Lock() defer s.mu.Unlock() - raw, err := json.Marshal(msg) - if err != nil { - return err - } - f, err := os.OpenFile(s.path, os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - raw = append(raw, '\n') - _, err = f.Write(raw) + _, err := s.appendEntryLocked(Entry{Type: TypeMessage, Message: &msg}) return err } -// Load 全量读取转录。崩溃残留容错:Append 恒以换行收尾,因此无换行结尾的 -// 尾部若不是完整 JSON 即为崩溃半行,丢弃而不是让整个会话不可用; -// 行中间的坏行仍报错(那是文件损坏,不是崩溃残留)。 +// Load returns the model-visible messages on the active branch. +// A compaction entry replaces earlier turns with its summary plus the +// retained tail; the file itself is never rewritten. +// Crash residue: a trailing line without a newline that is not valid JSON +// is dropped. A bad line in the middle is still an error. func (s *Store) Load() ([]llm.Message, error) { s.mu.Lock() defer s.mu.Unlock() - raw, err := os.ReadFile(s.path) + entries, err := s.readEntriesLocked() if err != nil { return nil, err } - text := string(raw) - if text != "" && !strings.HasSuffix(text, "\n") { - if idx := strings.LastIndexByte(text, '\n'); idx >= 0 { - if !json.Valid([]byte(text[idx+1:])) { - text = text[:idx+1] - } - } else if !json.Valid([]byte(text)) { - text = "" - } - } - var out []llm.Message - sc := bufio.NewScanner(strings.NewReader(text)) - sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) - for sc.Scan() { - line := sc.Bytes() - if len(line) == 0 { - continue - } - var msg llm.Message - if err := json.Unmarshal(line, &msg); err != nil { - return nil, err - } - out = append(out, msg) - } - return out, sc.Err() + return visibleMessages(entries, s.leafLocked(entries)), nil } diff --git a/runtime/session/tree.go b/runtime/session/tree.go new file mode 100644 index 0000000..0417216 --- /dev/null +++ b/runtime/session/tree.go @@ -0,0 +1,397 @@ +package session + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + "agent-native/runtime/llm" +) + +// EntryType is the kind of one JSONL record. +type EntryType string + +const ( + TypeHeader EntryType = "header" + TypeMessage EntryType = "message" + TypeModelChange EntryType = "model_change" + TypeThinkingLevelChange EntryType = "thinking_level_change" + TypeCompaction EntryType = "compaction" + TypeLabel EntryType = "label" + TypeCustom EntryType = "custom" + TypeSessionInfo EntryType = "session_info" +) + +// Entry is one node in the session tree. Files written before the tree +// format (a bare llm.Message per line) are loaded as message entries. +type Entry struct { + ID string `json:"id"` + ParentID string `json:"parentId,omitempty"` + Timestamp string `json:"timestamp"` + Type EntryType `json:"type"` + Message *llm.Message `json:"message,omitempty"` + Model string `json:"model,omitempty"` + ThinkingLevel string `json:"thinkingLevel,omitempty"` + Label string `json:"label,omitempty"` + Name string `json:"name,omitempty"` + CustomType string `json:"customType,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Summary string `json:"summary,omitempty"` + TailIDs []string `json:"tailIds,omitempty"` + CWD string `json:"cwd,omitempty"` +} + +// Entries returns every record in file order, including branches that are +// not on the active path. +func (s *Store) Entries() ([]Entry, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.readEntriesLocked() +} + +// Leaf is the id of the current branch tip. Empty when the file has no entries. +func (s *Store) Leaf() (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + entries, err := s.readEntriesLocked() + if err != nil { + return "", err + } + return s.leafLocked(entries), nil +} + +// SetLeaf moves the active branch to an existing entry. +func (s *Store) SetLeaf(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + entries, err := s.readEntriesLocked() + if err != nil { + return err + } + if !hasID(entries, id) { + return fmt.Errorf("session: unknown entry %s", id) + } + return s.writeMetaKeyLocked("leaf", id) +} + +// AppendEntry adds a non-message record (label, model change, custom, …) +// as a child of the current leaf and moves the leaf to it. +func (s *Store) AppendEntry(e Entry) (Entry, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.appendEntryLocked(e) +} + +// Fork copies the tree into a new file and sets that file's leaf to atID. +// The source file is not modified. atID empty means the current leaf. +func (s *Store) Fork(dest, atID string) (*Store, error) { + return s.copyTo(dest, atID, true) +} + +// Clone copies the tree and keeps the same leaf. +func (s *Store) Clone(dest string) (*Store, error) { + return s.copyTo(dest, "", false) +} + +func (s *Store) copyTo(dest, atID string, moveLeaf bool) (*Store, error) { + s.mu.Lock() + defer s.mu.Unlock() + entries, err := s.readEntriesLocked() + if err != nil { + return nil, err + } + if err := copyFile(s.path, dest); err != nil { + return nil, err + } + out, err := Open(dest) + if err != nil { + return nil, err + } + meta := s.readMetaLocked() + if meta == nil { + meta = map[string]string{} + } + leaf := s.leafLocked(entries) + if moveLeaf && atID != "" { + if !hasID(entries, atID) { + return nil, fmt.Errorf("session: unknown entry %s", atID) + } + leaf = atID + } + if leaf != "" { + meta["leaf"] = leaf + } + if err := out.SaveMeta(meta); err != nil { + return nil, err + } + return out, nil +} + +// SetName records a human name for continue/resume. +func (s *Store) SetName(name string) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.writeMetaKeyLocked("name", name); err != nil { + return err + } + _, err := s.appendEntryLocked(Entry{Type: TypeSessionInfo, Name: name}) + return err +} + +// Name returns the session name, or empty. +func (s *Store) Name() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.readMetaLocked()["name"] +} + +func (s *Store) appendEntryLocked(e Entry) (Entry, error) { + entries, err := s.readEntriesLocked() + if err != nil { + return Entry{}, err + } + if e.ID == "" { + e.ID = newID() + } + if e.Timestamp == "" { + e.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) + } + if e.Type == "" { + e.Type = TypeCustom + } + if e.ParentID == "" { + e.ParentID = s.leafLocked(entries) + } + raw, err := json.Marshal(e) + if err != nil { + return Entry{}, err + } + f, err := os.OpenFile(s.path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return Entry{}, err + } + defer f.Close() + if _, err := f.Write(append(raw, '\n')); err != nil { + return Entry{}, err + } + if err := s.writeMetaKeyLocked("leaf", e.ID); err != nil { + return Entry{}, err + } + return e, nil +} + +func (s *Store) readEntriesLocked() ([]Entry, error) { + raw, err := os.ReadFile(s.path) + if err != nil { + return nil, err + } + text := string(raw) + if text != "" && !strings.HasSuffix(text, "\n") { + if idx := strings.LastIndexByte(text, '\n'); idx >= 0 { + if !json.Valid([]byte(text[idx+1:])) { + text = text[:idx+1] + } + } else if !json.Valid([]byte(text)) { + text = "" + } + } + var out []Entry + sc := bufio.NewScanner(strings.NewReader(text)) + sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + legacyParent := "" + n := 0 + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + var probe map[string]json.RawMessage + if err := json.Unmarshal(line, &probe); err != nil { + return nil, err + } + if _, ok := probe["type"]; ok { + var e Entry + if err := json.Unmarshal(line, &e); err != nil { + return nil, err + } + out = append(out, e) + legacyParent = e.ID + n++ + continue + } + var msg llm.Message + if err := json.Unmarshal(line, &msg); err != nil { + return nil, err + } + id := fmt.Sprintf("legacy-%d", n) + out = append(out, Entry{ + ID: id, ParentID: legacyParent, Type: TypeMessage, Message: &msg, + }) + legacyParent = id + n++ + } + return out, sc.Err() +} + +func (s *Store) leafLocked(entries []Entry) string { + if leaf := s.readMetaLocked()["leaf"]; leaf != "" && hasID(entries, leaf) { + return leaf + } + if len(entries) == 0 { + return "" + } + return entries[len(entries)-1].ID +} + +func visibleMessages(entries []Entry, leaf string) []llm.Message { + path := pathTo(entries, leaf) + var last *Entry + for i := range path { + if path[i].Type == TypeCompaction { + last = &path[i] + } + } + if last == nil { + var msgs []llm.Message + for _, e := range path { + if e.Type == TypeMessage && e.Message != nil { + msgs = append(msgs, *e.Message) + } + } + return msgs + } + byID := map[string]Entry{} + for _, e := range entries { + byID[e.ID] = e + } + var msgs []llm.Message + for _, e := range path { + if e.ID == last.ID { + break + } + if e.Type == TypeMessage && e.Message != nil && e.Message.Role == llm.RoleSystem { + msgs = append(msgs, *e.Message) + } + } + msgs = append(msgs, llm.Message{ + Role: llm.RoleUser, + Content: "[conversation summary]\n" + last.Summary + "\n[recent messages follow]", + }) + for _, id := range last.TailIDs { + e, ok := byID[id] + if ok && e.Message != nil { + msgs = append(msgs, *e.Message) + } + } + after := false + for _, e := range path { + if e.ID == last.ID { + after = true + continue + } + if after && e.Type == TypeMessage && e.Message != nil { + msgs = append(msgs, *e.Message) + } + } + return msgs +} + +func pathTo(entries []Entry, leaf string) []Entry { + byID := map[string]Entry{} + for _, e := range entries { + byID[e.ID] = e + } + var rev []Entry + seen := map[string]bool{} + for id := leaf; id != "" && !seen[id]; { + e, ok := byID[id] + if !ok { + break + } + seen[id] = true + rev = append(rev, e) + id = e.ParentID + } + for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 { + rev[i], rev[j] = rev[j], rev[i] + } + return rev +} + +func hasID(entries []Entry, id string) bool { + for _, e := range entries { + if e.ID == id { + return true + } + } + return false +} + +func (s *Store) readMetaLocked() map[string]string { + raw, err := os.ReadFile(s.path + ".meta.json") + if err != nil { + return nil + } + var meta map[string]string + if err := json.Unmarshal(raw, &meta); err != nil { + return nil + } + return meta +} + +func (s *Store) writeMetaKeyLocked(key, value string) error { + meta := s.readMetaLocked() + if meta == nil { + meta = map[string]string{} + } + meta[key] = value + raw, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + tmp := s.path + ".meta.json.tmp" + if err := os.WriteFile(tmp, raw, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path+".meta.json") +} + +func newID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(dirOf(dst), 0o755); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func dirOf(path string) string { + i := strings.LastIndex(path, "/") + if i < 0 { + return "." + } + return path[:i] +} diff --git a/runtime/session/tree_test.go b/runtime/session/tree_test.go new file mode 100644 index 0000000..96b8f25 --- /dev/null +++ b/runtime/session/tree_test.go @@ -0,0 +1,128 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "agent-native/runtime/llm" +) + +func TestTreeForkCloneAndLeaf(t *testing.T) { + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "a.jsonl")) + if err != nil { + t.Fatal(err) + } + if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "one"}); err != nil { + t.Fatal(err) + } + if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "two"}); err != nil { + t.Fatal(err) + } + entries, err := s.Entries() + if err != nil || len(entries) != 2 || entries[1].ParentID != entries[0].ID { + t.Fatalf("tree links: %+v %v", entries, err) + } + fork, err := s.Fork(filepath.Join(dir, "fork.jsonl"), entries[0].ID) + if err != nil { + t.Fatal(err) + } + if err := fork.Append(llm.Message{Role: llm.RoleUser, Content: "branch"}); err != nil { + t.Fatal(err) + } + msgs, err := fork.Load() + if err != nil || len(msgs) != 2 || msgs[1].Content != "branch" { + t.Fatalf("fork path: %+v %v", msgs, err) + } + orig, _ := s.Load() + if len(orig) != 2 || orig[1].Content != "two" { + t.Fatalf("source branch changed: %+v", orig) + } + clone, err := s.Clone(filepath.Join(dir, "clone.jsonl")) + if err != nil { + t.Fatal(err) + } + cmsgs, _ := clone.Load() + if len(cmsgs) != 2 || cmsgs[1].Content != "two" { + t.Fatalf("clone: %+v", cmsgs) + } + if err := s.SetLeaf(entries[0].ID); err != nil { + t.Fatal(err) + } + switched, _ := s.Load() + if len(switched) != 1 || switched[0].Content != "one" { + t.Fatalf("set leaf: %+v", switched) + } +} + +func TestLegacyTranscriptStillLoads(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "old.jsonl") + body := "{\"role\":\"system\",\"content\":\"sys\"}\n{\"role\":\"user\",\"content\":\"hi\"}\n" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + msgs, err := s.Load() + if err != nil || len(msgs) != 2 || msgs[1].Content != "hi" { + t.Fatalf("legacy: %+v %v", msgs, err) + } + if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "next"}); err != nil { + t.Fatal(err) + } + msgs, err = s.Load() + if err != nil || len(msgs) != 3 || msgs[2].Content != "next" { + t.Fatalf("legacy append: %+v %v", msgs, err) + } +} + +func TestCatalogContinueResumeAndHTML(t *testing.T) { + dir := t.TempDir() + s, err := Open(filepath.Join(dir, "sess.jsonl")) + if err != nil { + t.Fatal(err) + } + if err := s.SetCWD("/work"); err != nil { + t.Fatal(err) + } + if err := s.SetName("notes"); err != nil { + t.Fatal(err) + } + if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "hello"}); err != nil { + t.Fatal(err) + } + got, ok, err := Continue(dir, "/work") + if err != nil || !ok || got.Path() != s.Path() { + t.Fatalf("continue: %v %v %v", got, ok, err) + } + resumed, err := Resume(dir, "notes") + if err != nil || resumed.Name() != "notes" { + t.Fatalf("resume: %v %v", resumed, err) + } + htmlPath := filepath.Join(dir, "out.html") + if err := s.ExportHTML(htmlPath); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(htmlPath) + if !strings.Contains(string(raw), "hello") || !strings.Contains(string(raw), "notes") { + t.Fatalf("html: %s", raw) + } + if _, err := s.AppendEntry(Entry{Type: TypeLabel, Label: "checkpoint"}); err != nil { + t.Fatal(err) + } + entries, _ := s.Entries() + var labels int + for _, e := range entries { + if e.Type == TypeLabel && e.Label == "checkpoint" { + labels++ + } + } + if labels != 1 { + t.Fatalf("label missing: %+v", entries) + } +} diff --git a/runtime/session_agent.go b/runtime/session_agent.go new file mode 100644 index 0000000..1f07f21 --- /dev/null +++ b/runtime/session_agent.go @@ -0,0 +1,200 @@ +package runtime + +import ( + "context" + "sync" + + "agent-native/runtime/llm" + "agent-native/runtime/session" +) + +// Delivery selects how a queued steer or follow-up is handed to the loop. +type Delivery string + +const ( + DeliverOne Delivery = "one" + DeliverAll Delivery = "all" +) + +// AgentSession is the headless session object: queues, abort, and +// mid-run model changes around Run. +type AgentSession struct { + cfg Config + + mu sync.Mutex + steer []llm.Message + follow []llm.Message + steerMode Delivery + followMode Delivery + model string + thinking string + cancel context.CancelFunc + runID int +} + +// NewAgentSession copies cfg. Queue modes default to one-at-a-time. +func NewAgentSession(cfg Config) *AgentSession { + if cfg.Model == "" { + cfg.Model = "default" + } + return &AgentSession{ + cfg: cfg, + steerMode: DeliverOne, + followMode: DeliverOne, + model: cfg.Model, + thinking: cfg.ThinkingLevel, + } +} + +// SetQueueMode sets the delivery mode for steer and follow-up. +func (a *AgentSession) SetQueueMode(steer, follow Delivery) { + a.mu.Lock() + defer a.mu.Unlock() + if steer != "" { + a.steerMode = steer + } + if follow != "" { + a.followMode = follow + } +} + +// Steer queues a message to be delivered after the current tool batch. +func (a *AgentSession) Steer(msg llm.Message) { + a.mu.Lock() + a.steer = append(a.steer, msg) + a.mu.Unlock() + a.emitQueue("steer") +} + +// FollowUp queues a message for after the assistant stops. +func (a *AgentSession) FollowUp(msg llm.Message) { + a.mu.Lock() + a.follow = append(a.follow, msg) + a.mu.Unlock() + a.emitQueue("follow_up") +} + +// ClearQueue drops both queues. +func (a *AgentSession) ClearQueue() { + a.mu.Lock() + a.steer = nil + a.follow = nil + a.mu.Unlock() + a.emitQueue("clear") +} + +// Abort cancels the in-flight Prompt. +func (a *AgentSession) Abort() { + a.mu.Lock() + cancel := a.cancel + a.mu.Unlock() + if cancel != nil { + cancel() + } +} + +// SetModel changes the model and thinking level used on the next turn. +// A session store, when present, records the change. +func (a *AgentSession) SetModel(model, thinking string) error { + if thinking != "" { + norm, err := llm.NormalizeThinking(thinking) + if err != nil { + return err + } + thinking = norm + } + a.mu.Lock() + if model != "" { + a.model = model + } + if thinking != "" { + a.thinking = thinking + } + store := a.cfg.Session + a.mu.Unlock() + if store == nil { + return nil + } + if model != "" { + if _, err := store.AppendEntry(session.Entry{Type: session.TypeModelChange, Model: model}); err != nil { + return err + } + } + if thinking != "" { + if _, err := store.AppendEntry(session.Entry{Type: session.TypeThinkingLevelChange, ThinkingLevel: thinking}); err != nil { + return err + } + } + return nil +} + +// Prompt runs one user message until the agent settles. +func (a *AgentSession) Prompt(ctx context.Context, user llm.Message) (Result, error) { + ctx, cancel := context.WithCancel(ctx) + a.mu.Lock() + a.runID++ + id := a.runID + a.cancel = cancel + cfg := a.cfg + a.mu.Unlock() + defer func() { + cancel() + a.mu.Lock() + if a.runID == id { + a.cancel = nil + } + a.mu.Unlock() + }() + cfg.GetSteering = func() []llm.Message { return a.take("steer") } + cfg.GetFollowUp = func() []llm.Message { return a.take("follow") } + cfg.Resolve = func() (string, string) { + a.mu.Lock() + defer a.mu.Unlock() + return a.model, a.thinking + } + return Run(ctx, cfg, user) +} + +// Compact appends a compaction entry and emits compaction events. +func (a *AgentSession) Compact(keep int, summarize func([]llm.Message) (string, error)) (bool, error) { + if a.cfg.Session == nil { + return false, nil + } + emit(a.cfg, Event{Type: "compaction_start"}) + done, err := a.cfg.Session.Compact(keep, summarize) + text := "noop" + if done { + text = "compacted" + } + if err != nil { + text = err.Error() + } + emit(a.cfg, Event{Type: "compaction_end", Text: text}) + return done, err +} + +func (a *AgentSession) take(kind string) []llm.Message { + a.mu.Lock() + defer a.mu.Unlock() + q := &a.steer + mode := a.steerMode + if kind == "follow" { + q = &a.follow + mode = a.followMode + } + if len(*q) == 0 { + return nil + } + if mode == DeliverAll { + out := append([]llm.Message(nil), (*q)...) + *q = nil + return out + } + out := []llm.Message{(*q)[0]} + *q = (*q)[1:] + return out +} + +func (a *AgentSession) emitQueue(text string) { + emit(a.cfg, Event{Type: "queue_update", Text: text}) +} diff --git a/runtime/session_agent_test.go b/runtime/session_agent_test.go new file mode 100644 index 0000000..ed432a2 --- /dev/null +++ b/runtime/session_agent_test.go @@ -0,0 +1,88 @@ +package runtime + +import ( + "context" + "sync" + "testing" + + "agent-native/runtime/llm" +) + +func TestAgentSessionSteerFollowAndAbort(t *testing.T) { + var mu sync.Mutex + step := 0 + stream := llm.StreamFunc(func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 2) + go func() { + defer close(ch) + mu.Lock() + n := step + step++ + mu.Unlock() + if opts.ThinkingLevel != "low" && n == 0 { + t.Errorf("thinking level not forwarded: %s", opts.ThinkingLevel) + } + if n == 0 { + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{ + Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, + ToolCalls: []llm.ToolCall{{ID: "1", Name: "missing", Arguments: "{}"}}, + }} + return + } + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{ + Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd, + }} + }() + return ch + }) + var types []string + sess := NewAgentSession(Config{ + Model: "m", + ThinkingLevel: "low", + Stream: stream, + MaxTurns: 6, + OnEvent: func(ev Event) { + types = append(types, ev.Type) + }, + }) + sess.SetQueueMode(DeliverOne, DeliverAll) + sess.Steer(llm.Message{Role: llm.RoleUser, Content: "steer-me"}) + sess.FollowUp(llm.Message{Role: llm.RoleUser, Content: "later"}) + res, err := sess.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if res.StopReason != llm.StopEnd { + t.Fatalf("stop %s err %v", res.StopReason, res.Err) + } + found := map[string]bool{} + for _, typ := range types { + found[typ] = true + } + for _, want := range []string{"message_start", "queue_update", "tool_execution_update", "agent_settled"} { + if !found[want] { + t.Fatalf("missing %s in %v", want, types) + } + } +} + +func TestClearQueueAndSetModel(t *testing.T) { + sess := NewAgentSession(Config{Model: "old", Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 1) + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: model, StopReason: llm.StopEnd}} + close(ch) + return ch + }}) + sess.Steer(llm.Message{Role: llm.RoleUser, Content: "drop"}) + sess.ClearQueue() + if err := sess.SetModel("new-model", "high"); err != nil { + t.Fatal(err) + } + res, err := sess.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "hi"}) + if err != nil { + t.Fatal(err) + } + if res.Text != "new-model" { + t.Fatalf("model switch did not apply: %q", res.Text) + } +} 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 +}