diff --git a/README.md b/README.md index 657f43a4b8e7..05be2472e17e 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ local-ai run https://gist.githubusercontent.com/.../phi-2.yaml local-ai run oci://localai/phi-2:latest ``` -To test a running LocalAI server from the terminal, open an interactive chat session from another shell. Inside the prompt, `/models` lists installed models and `/model ` switches between them. +To work with a running LocalAI server from the terminal, start the built-in agent from another shell. It answers questions, reads your files and runs commands on your machine, asking you to approve anything that changes state. Inside a session, `/models` lists installed models and `/model ` switches between them. See the [Terminal agent](https://localai.io/docs/features/terminal-agent/) docs. ```bash # Terminal 1 diff --git a/cmd/local-ai/main.go b/cmd/local-ai/main.go index fca6b26382a8..b1799dbb8ec1 100644 --- a/cmd/local-ai/main.go +++ b/cmd/local-ai/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "path/filepath" @@ -107,6 +108,13 @@ For documentation and support: // Run the thing! err = ctx.Run(&cli.CLI.Context) if err != nil { + // A command that has already told the user what went wrong returns + // only a status. Logging it as well would print a bare "exit status 1" + // underneath the explanation they just read. + var reported cli.ExitCodeError + if errors.As(err, &reported) { + os.Exit(reported.Code) + } xlog.Fatal("Error running the application", "error", err) } } diff --git a/core/cli/chat/chat.go b/core/cli/chat/chat.go deleted file mode 100644 index 071d3a7858ad..000000000000 --- a/core/cli/chat/chat.go +++ /dev/null @@ -1,30 +0,0 @@ -package chat - -import ( - "context" - "io" - "strings" -) - -type Options struct { - Model string - BaseURL string - APIKey string - In io.Reader - Out io.Writer -} - -func Run(ctx context.Context, opts Options) error { - if opts.In == nil { - opts.In = strings.NewReader("") - } - if opts.Out == nil { - opts.Out = io.Discard - } - - session, err := newChatSession(ctx, newLocalAIChatClient(opts.BaseURL, opts.APIKey), opts.Model) - if err != nil { - return err - } - return runTerminalChat(ctx, session, opts.In, opts.Out) -} diff --git a/core/cli/chat/chat_test.go b/core/cli/chat/chat_test.go deleted file mode 100644 index 7399c3802b10..000000000000 --- a/core/cli/chat/chat_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package chat - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Run chat", func() { - It("streams a single chat response", func() { - var capturedModel string - var capturedAuth string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/v1/models" { - w.Header().Set("Content-Type", "application/json") - writeResponse(w, `{"object":"list","data":[{"id":"test-model","object":"model"}]}`) - return - } - - Expect(r.URL.Path).To(Equal("/v1/chat/completions")) - capturedAuth = r.Header.Get("Authorization") - - var body struct { - Model string `json:"model"` - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` - } - Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) - capturedModel = body.Model - Expect(body.Messages).To(HaveLen(1)) - Expect(body.Messages[0].Role).To(Equal("user")) - Expect(body.Messages[0].Content).To(Equal("hello")) - - w.Header().Set("Content-Type", "text/event-stream") - writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"}}]}\n\n") - writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\n") - writeResponse(w, "data: [DONE]\n\n") - })) - defer server.Close() - - var out bytes.Buffer - err := Run(GinkgoT().Context(), Options{ - Model: "test-model", - BaseURL: server.URL + "/v1", - APIKey: "secret", - In: strings.NewReader("hello\n/exit\n"), - Out: &out, - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(capturedModel).To(Equal("test-model")) - Expect(capturedAuth).To(Equal("Bearer secret")) - Expect(out.String()).To(ContainSubstring("assistant: hi!")) - Expect(out.String()).To(ContainSubstring("bye")) - }) - - It("auto-selects the only available model", func() { - server := chatTestServer([]string{"solo"}, nil) - defer server.Close() - - var out bytes.Buffer - err := Run(GinkgoT().Context(), Options{ - BaseURL: server.URL + "/v1", - In: strings.NewReader("/exit\n"), - Out: &out, - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(out.String()).To(ContainSubstring("LocalAI chat (solo)")) - }) - - It("returns an actionable error when no models are installed", func() { - server := chatTestServer(nil, nil) - defer server.Close() - - err := Run(GinkgoT().Context(), Options{ - BaseURL: server.URL + "/v1", - In: strings.NewReader(""), - }) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no chat models are installed")) - Expect(err.Error()).To(ContainSubstring("local-ai models install ")) - }) - - It("returns an actionable error when multiple models are available without a selection", func() { - server := chatTestServer([]string{"alpha", "beta"}, nil) - defer server.Close() - - err := Run(GinkgoT().Context(), Options{ - BaseURL: server.URL + "/v1", - In: strings.NewReader(""), - }) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("multiple models are available")) - Expect(err.Error()).To(ContainSubstring("--model")) - Expect(err.Error()).To(ContainSubstring("alpha")) - Expect(err.Error()).To(ContainSubstring("beta")) - }) - - It("lists and switches models inside the chat", func() { - requestedModels := []string{} - server := chatTestServer([]string{"alpha", "beta"}, func(model string) { - requestedModels = append(requestedModels, model) - }) - defer server.Close() - - var out bytes.Buffer - err := Run(GinkgoT().Context(), Options{ - Model: "alpha", - BaseURL: server.URL + "/v1", - In: strings.NewReader("/models\n/model beta\nhello\n/exit\n"), - Out: &out, - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(out.String()).To(ContainSubstring("* alpha")) - Expect(out.String()).To(ContainSubstring(" beta")) - Expect(out.String()).To(ContainSubstring("switched to beta; conversation cleared")) - Expect(requestedModels).To(Equal([]string{"beta"})) - }) -}) - -func chatTestServer(models []string, onChat func(model string)) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v1/models": - w.Header().Set("Content-Type", "application/json") - writeResponse(w, `{"object":"list","data":[`) - for i, model := range models { - if i > 0 { - writeResponse(w, ",") - } - writeResponsef(w, `{"id":%q,"object":"model"}`, model) - } - writeResponse(w, `]}`) - case "/v1/chat/completions": - var body struct { - Model string `json:"model"` - } - Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) - if onChat != nil { - onChat(body.Model) - } - w.Header().Set("Content-Type", "text/event-stream") - writeResponse(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}\n\n") - writeResponse(w, "data: [DONE]\n\n") - default: - w.WriteHeader(http.StatusNotFound) - } - })) -} - -func writeResponse(w io.Writer, text string) { - _, err := fmt.Fprint(w, text) - Expect(err).ToNot(HaveOccurred()) -} - -func writeResponsef(w io.Writer, format string, args ...any) { - _, err := fmt.Fprintf(w, format, args...) - Expect(err).ToNot(HaveOccurred()) -} diff --git a/core/cli/chat/client.go b/core/cli/chat/client.go deleted file mode 100644 index 407845d0bdc9..000000000000 --- a/core/cli/chat/client.go +++ /dev/null @@ -1,114 +0,0 @@ -package chat - -import ( - "context" - "errors" - "fmt" - "io" - "sort" - "strings" - - openai "github.com/sashabaranov/go-openai" -) - -type chatClient interface { - ListModels(ctx context.Context) ([]string, error) - StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error) -} - -type localAIChatClient struct { - client *openai.Client -} - -func newLocalAIChatClient(baseURL string, apiKey string) *localAIChatClient { - cfg := openai.DefaultConfig(apiKey) - cfg.BaseURL = baseURL - return &localAIChatClient{client: openai.NewClientWithConfig(cfg)} -} - -func (c *localAIChatClient) ListModels(ctx context.Context) ([]string, error) { - resp, err := c.client.ListModels(ctx) - if err != nil { - return nil, err - } - - models := make([]string, 0, len(resp.Models)) - for _, model := range resp.Models { - if model.ID != "" { - models = append(models, model.ID) - } - } - sort.Strings(models) - return models, nil -} - -func (c *localAIChatClient) StreamChat(ctx context.Context, model string, messages []chatMessage, out io.Writer) (string, error) { - stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ - Model: model, - Messages: openAIChatMessages(messages), - }) - if err != nil { - return "", friendlyChatError(err, model) - } - defer func() { - _ = stream.Close() - }() - - var answer strings.Builder - for { - resp, err := stream.Recv() - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return answer.String(), friendlyChatError(err, model) - } - if len(resp.Choices) == 0 { - continue - } - - token := resp.Choices[0].Delta.Content - if token == "" { - continue - } - answer.WriteString(token) - if _, err := fmt.Fprint(out, token); err != nil { - return answer.String(), err - } - } - - return answer.String(), nil -} - -func openAIChatMessages(messages []chatMessage) []openai.ChatCompletionMessage { - converted := make([]openai.ChatCompletionMessage, len(messages)) - for i, message := range messages { - converted[i] = openai.ChatCompletionMessage{ - Role: message.Role, - Content: message.Content, - } - } - return converted -} - -func friendlyChatError(err error, model string) error { - var apiErr *openai.APIError - if errors.As(err, &apiErr) { - switch apiErr.HTTPStatusCode { - case 404: - return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install `, or switch with `/model `", model) - case 403: - return fmt.Errorf("model %q is disabled. Enable it from LocalAI settings or choose another model with `/model `", model) - } - if apiErr.Message != "" { - return errors.New(apiErr.Message) - } - } - - msg := err.Error() - if strings.Contains(msg, "model") && strings.Contains(msg, "not found") { - return fmt.Errorf("model %q is not available. Run `local-ai models list`, install a model with `local-ai models install `, or switch with `/model `", model) - } - - return err -} diff --git a/core/cli/chat/models.go b/core/cli/chat/models.go deleted file mode 100644 index 291ec15aa8c6..000000000000 --- a/core/cli/chat/models.go +++ /dev/null @@ -1,17 +0,0 @@ -package chat - -import "strings" - -func formatChatModelList(models []string, current string) string { - var b strings.Builder - for _, model := range models { - prefix := " " - if model == current { - prefix = "* " - } - b.WriteString(prefix) - b.WriteString(model) - b.WriteByte('\n') - } - return b.String() -} diff --git a/core/cli/chat/paths.go b/core/cli/chat/paths.go new file mode 100644 index 000000000000..1cfa348219db --- /dev/null +++ b/core/cli/chat/paths.go @@ -0,0 +1,153 @@ +package chat + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// stateDirMode matches the mode nib uses for the same directory. The directory +// holds an API key, so it stays owner-only. +const stateDirMode = 0o700 + +// configFileMode keeps the config owner-only: nib stores the user's API key in +// it alongside the keys written here. +const configFileMode = 0o600 + +// StateDir resolves where the chat agent keeps its config, plugins, and +// skills. This is user-scoped rather than server-scoped: chat is a client that +// may target a remote LocalAI, so it does not belong under LOCALAI_CONFIG_DIR. +func StateDir(override string) (string, error) { + if override != "" { + return override, nil + } + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "localai", "chat"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory for the agent state dir: %w", err) + } + return filepath.Join(home, ".config", "localai", "chat"), nil +} + +// ConfigPath is the agent's config file inside dir. +func ConfigPath(dir string) string { return filepath.Join(dir, "config.yaml") } + +// EnsureStateDir creates dir and, on first run only, seeds a config file +// pointing at baseURL. It deliberately does not seed a model: a baked-in model +// name goes stale as soon as the user installs a different one. +// +// The config file is machine-managed from here on: nib rewrites it whenever it +// self-configures, so hand-written comments in it do not survive. +func EnsureStateDir(dir, baseURL string) error { + if err := os.MkdirAll(dir, stateDirMode); err != nil { + return fmt.Errorf("creating agent state dir %s: %w", dir, err) + } + path := ConfigPath(dir) + if _, err := os.Stat(path); err == nil { + return nil // already configured; never overwrite the user's file + } else if !os.IsNotExist(err) { + return fmt.Errorf("checking agent config %s: %w", path, err) + } + + seed := map[string]string{"base_url": baseURL} + data, err := yaml.Marshal(seed) + if err != nil { + return fmt.Errorf("encoding seed agent config: %w", err) + } + if err := writeConfigFile(path, data); err != nil { + return fmt.Errorf("writing seed agent config: %w", err) + } + return nil +} + +// PersistModel records the chosen model in the agent config, preserving every +// other key the user may have set, including the api_key nib writes there. +// +// The file is machine-managed: this overlays the model onto the parsed keys and +// re-marshals, which drops comments. That is deliberate rather than an +// oversight, because nib's own save path does the same thing and would erase +// them on its next write regardless. +func PersistModel(dir, model string) error { + // PersistModel is callable before EnsureStateDir, so it cannot assume the + // directory exists. + if err := os.MkdirAll(dir, stateDirMode); err != nil { + return fmt.Errorf("creating agent state dir %s: %w", dir, err) + } + path := ConfigPath(dir) + + values := map[string]any{} + // #nosec G304 -- path is the fixed config.yaml name under the user-selected + // chat state directory; selecting that directory is the documented override. + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("reading agent config %s: %w", path, err) + } + if err == nil { + if err := yaml.Unmarshal(data, &values); err != nil { + return fmt.Errorf("parsing agent config %s: %w", path, err) + } + } + values["model"] = model + + out, err := yaml.Marshal(values) + if err != nil { + return fmt.Errorf("encoding agent config: %w", err) + } + if err := writeConfigFile(path, out); err != nil { + return fmt.Errorf("writing agent config: %w", err) + } + return nil +} + +// writeConfigFile replaces path with data atomically: it writes a temporary +// file next to the target and renames it over the target. Writing the target in +// place would truncate it first, so an interrupted or out-of-disk write would +// leave a half-written config and destroy the api_key nib keeps in the same +// file. The temporary file must share the directory because rename is only +// atomic within one filesystem. +func writeConfigFile(path string, data []byte) error { + dir := filepath.Dir(path) + + // A randomized name rather than a fixed config.yaml.tmp, so two concurrent + // writers cannot corrupt each other's temporary file. + tmp, err := os.CreateTemp(dir, "config.yaml.*.tmp") + if err != nil { + return fmt.Errorf("creating temp file in %s: %w", dir, err) + } + tmpPath := tmp.Name() + renamed := false + defer func() { + if !renamed { + // Leave no litter behind on any failure path. + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing %s: %w", tmpPath, err) + } + // Flush before the rename: renaming a file whose contents are still only in + // the page cache can still lose them across a crash. + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("syncing %s: %w", tmpPath, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing %s: %w", tmpPath, err) + } + // CreateTemp already asks for 0600, but the umask can only ever clear bits, + // so set the mode explicitly rather than inheriting whatever survived. + if err := os.Chmod(tmpPath, configFileMode); err != nil { + return fmt.Errorf("setting mode on %s: %w", tmpPath, err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("replacing %s: %w", path, err) + } + renamed = true + return nil +} diff --git a/core/cli/chat/paths_test.go b/core/cli/chat/paths_test.go new file mode 100644 index 000000000000..7a4b9aa290df --- /dev/null +++ b/core/cli/chat/paths_test.go @@ -0,0 +1,186 @@ +package chat + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" +) + +// richConfig stands in for a config nib has already taken ownership of: a +// comment, a secret, and a nested block. A flat scalar alone would not catch a +// writer that mangles structure or drops a key it does not know about. +const richConfig = `# hand written note +base_url: http://x.invalid/v1 +api_key: secret-token +mcp_servers: + files: + command: mcp-files + args: + - --root + - /tmp +` + +var _ = Describe("Agent state directory", func() { + Describe("StateDir", func() { + It("prefers an explicit override", func() { + Expect(StateDir("/custom/dir")).To(Equal("/custom/dir")) + }) + + It("uses XDG_CONFIG_HOME when set", func() { + tmp := GinkgoT().TempDir() + GinkgoT().Setenv("XDG_CONFIG_HOME", tmp) + Expect(StateDir("")).To(Equal(filepath.Join(tmp, "localai", "chat"))) + }) + + It("falls back to ~/.config/localai/chat", func() { + tmp := GinkgoT().TempDir() + GinkgoT().Setenv("XDG_CONFIG_HOME", "") + GinkgoT().Setenv("HOME", tmp) + Expect(StateDir("")).To(Equal(filepath.Join(tmp, ".config", "localai", "chat"))) + }) + + It("fails when neither XDG_CONFIG_HOME nor a home directory is resolvable", func() { + GinkgoT().Setenv("XDG_CONFIG_HOME", "") + GinkgoT().Setenv("HOME", "") + + dir, err := StateDir("") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("agent state dir")) + // No silent fallback to a relative path: writing an API key into the + // working directory would be worse than refusing. + Expect(dir).To(BeEmpty()) + }) + }) + + Describe("EnsureStateDir", func() { + It("creates the directory and seeds base_url on first run", func() { + dir := filepath.Join(GinkgoT().TempDir(), "chat") + Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed()) + + data, err := os.ReadFile(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring("base_url: http://127.0.0.1:8080/v1")) + // A model must NOT be seeded: it goes stale as soon as the user + // installs a different one. + Expect(string(data)).ToNot(ContainSubstring("model:")) + }) + + It("keeps the seeded config and its directory owner-only", func() { + dir := filepath.Join(GinkgoT().TempDir(), "chat") + Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed()) + + // nib writes the user's api_key into this same file, so the modes are + // load-bearing, not cosmetic. + config, err := os.Stat(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + Expect(config.Mode().Perm()).To(Equal(os.FileMode(0o600))) + + state, err := os.Stat(dir) + Expect(err).ToNot(HaveOccurred()) + Expect(state.Mode().Perm()).To(Equal(os.FileMode(0o700))) + }) + + It("leaves an existing config byte-for-byte untouched", func() { + dir := GinkgoT().TempDir() + Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed()) + + Expect(EnsureStateDir(dir, "http://127.0.0.1:8080/v1")).To(Succeed()) + + data, err := os.ReadFile(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + // Byte-exact against a fixture carrying a comment and a nested block: + // an implementation that "preserves" by re-marshaling through a map + // fails here rather than passing on a flat scalar. + Expect(string(data)).To(Equal(richConfig)) + }) + }) + + Describe("PersistModel", func() { + It("adds a model to an existing config, preserving other keys", func() { + dir := GinkgoT().TempDir() + Expect(os.WriteFile(ConfigPath(dir), []byte("base_url: http://x.invalid/v1\n"), 0o600)).To(Succeed()) + + Expect(PersistModel(dir, "chosen-model")).To(Succeed()) + + data, err := os.ReadFile(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring("base_url: http://x.invalid/v1")) + Expect(string(data)).To(ContainSubstring("model: chosen-model")) + }) + + It("replaces an existing model rather than duplicating the key", func() { + dir := GinkgoT().TempDir() + Expect(os.WriteFile(ConfigPath(dir), []byte("model: old\nbase_url: http://x.invalid/v1\n"), 0o600)).To(Succeed()) + + Expect(PersistModel(dir, "new")).To(Succeed()) + + data, err := os.ReadFile(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring("model: new")) + Expect(string(data)).ToNot(ContainSubstring("model: old")) + }) + + It("preserves secrets and nested blocks it does not understand", func() { + dir := GinkgoT().TempDir() + Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed()) + + Expect(PersistModel(dir, "chosen-model")).To(Succeed()) + + data, err := os.ReadFile(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + + var got map[string]any + Expect(yaml.Unmarshal(data, &got)).To(Succeed()) + Expect(got).To(HaveKeyWithValue("model", "chosen-model")) + Expect(got).To(HaveKeyWithValue("base_url", "http://x.invalid/v1")) + // Losing this key logs the user out of their own server. + Expect(got).To(HaveKeyWithValue("api_key", "secret-token")) + Expect(got).To(HaveKeyWithValue("mcp_servers", + HaveKeyWithValue("files", And( + HaveKeyWithValue("command", "mcp-files"), + HaveKeyWithValue("args", ConsistOf("--root", "/tmp")), + )), + )) + + // Documented, accepted behavior rather than an aspiration: the overlay + // re-marshals, so comments do not survive. nib's own save path erases + // them too, so preserving them here would buy nothing. + Expect(string(data)).ToNot(ContainSubstring("# hand written note")) + }) + + It("keeps the rewritten config owner-only and leaves no temp file behind", func() { + dir := GinkgoT().TempDir() + Expect(os.WriteFile(ConfigPath(dir), []byte(richConfig), 0o600)).To(Succeed()) + + Expect(PersistModel(dir, "chosen-model")).To(Succeed()) + + info, err := os.Stat(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600))) + + // The atomic write stages through a sibling temp file; it must not + // survive a successful write. + entries, err := os.ReadDir(dir) + Expect(err).ToNot(HaveOccurred()) + names := []string{} + for _, entry := range entries { + names = append(names, entry.Name()) + } + Expect(names).To(ConsistOf("config.yaml")) + }) + + It("creates the state directory when it does not exist yet", func() { + // Task 4 may persist a picked model before anything else has run. + dir := filepath.Join(GinkgoT().TempDir(), "chat") + + Expect(PersistModel(dir, "chosen-model")).To(Succeed()) + + data, err := os.ReadFile(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring("model: chosen-model")) + }) + }) +}) diff --git a/core/cli/chat/probe.go b/core/cli/chat/probe.go new file mode 100644 index 000000000000..6b5032fbdd33 --- /dev/null +++ b/core/cli/chat/probe.go @@ -0,0 +1,86 @@ +package chat + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + openai "github.com/sashabaranov/go-openai" +) + +var ( + // ErrUnreachable means nothing answered at the endpoint. Callers use this + // to decide whether offering to start a server makes sense. + ErrUnreachable = errors.New("no LocalAI server reachable") + // ErrUnauthorized means the server answered but rejected the credentials. + ErrUnauthorized = errors.New("LocalAI server rejected the API key") +) + +// Probe lists the models the endpoint advertises. It classifies the two +// failures that need different advice: nothing listening, and bad credentials. +// +// The returned list is what the server advertises, verbatim and in server +// order. LocalAI happily lists non-model entries it finds in the models +// directory (stray archives, dotfiles), and guessing which advertised IDs are +// real belongs to whoever presents them, not here. +func Probe(ctx context.Context, baseURL, apiKey string) ([]string, error) { + cfg := openai.DefaultConfig(apiKey) + cfg.BaseURL = baseURL + + resp, err := openai.NewClientWithConfig(cfg).ListModels(ctx) + if err != nil { + if status, answered := responseStatus(err); answered { + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return nil, fmt.Errorf("%w: %w", ErrUnauthorized, err) + } + // The server answered, so it is up; surface its error as-is. + return nil, fmt.Errorf("listing models at %s: %w", baseURL, err) + } + // A caller who cancelled the probe learned nothing about the endpoint, + // so claiming it is unreachable would send them to fix a server that + // may be fine. A deadline is left alone: an endpoint that cannot answer + // within the probe's budget is unreachable for our purposes. + var urlErr *url.Error + if errors.As(err, &urlErr) && !errors.Is(err, context.Canceled) { + // Only a failure to complete the round trip means nothing is + // listening. A reply we could not parse is a different problem, + // so it falls through to the generic error below. + return nil, fmt.Errorf("%w at %s: %w", ErrUnreachable, baseURL, err) + } + return nil, fmt.Errorf("listing models at %s: %w", baseURL, err) + } + + models := make([]string, 0, len(resp.Models)) + for _, m := range resp.Models { + if m.ID != "" { + models = append(models, m.ID) + } + } + return models, nil +} + +// responseStatus reports the HTTP status a failed call came back with, and +// whether there was one at all. +// +// go-openai splits this across two types depending on the error body, and both +// occur against a real LocalAI: it returns *openai.APIError when the body +// parses as an OpenAI error envelope, which is what LocalAI's normal error +// handler sends, and *openai.RequestError when it does not, which is what +// LocalAI sends when started with opaque errors, since that handler replies +// with a bare status and no body. +func responseStatus(err error) (int, bool) { + // *RequestError is checked first because it is the outer type when + // go-openai nests one error inside the other; the inner value in that case + // carries no status. + var reqErr *openai.RequestError + if errors.As(err, &reqErr) { + return reqErr.HTTPStatusCode, true + } + var apiErr *openai.APIError + if errors.As(err, &apiErr) { + return apiErr.HTTPStatusCode, true + } + return 0, false +} diff --git a/core/cli/chat/probe_test.go b/core/cli/chat/probe_test.go new file mode 100644 index 000000000000..ec9d4705a844 --- /dev/null +++ b/core/cli/chat/probe_test.go @@ -0,0 +1,169 @@ +package chat + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Probe", func() { + It("returns the advertised models", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + Expect(json.NewEncoder(w).Encode(map[string]any{ + "object": "list", + "data": []map[string]string{ + {"id": "model-a", "object": "model"}, + {"id": "model-b", "object": "model"}, + }, + })).To(Succeed()) + })) + defer srv.Close() + + models, err := Probe(context.Background(), srv.URL+"/v1", "") + Expect(err).ToNot(HaveOccurred()) + Expect(models).To(Equal([]string{"model-a", "model-b"})) + }) + + It("reports an unreachable server distinguishably", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := srv.URL + srv.Close() // nothing is listening now + + _, err := Probe(context.Background(), url+"/v1", "") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrUnreachable)).To(BeTrue(), "want ErrUnreachable, got %v", err) + }) + + It("reports an auth failure distinguishably", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := Probe(context.Background(), srv.URL+"/v1", "bad-key") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrUnauthorized)).To(BeTrue(), "want ErrUnauthorized, got %v", err) + }) + + // LocalAI's normal error handler replies with an OpenAI error envelope, and + // its opaque-errors handler replies with a bare status and no body. Those + // reach the client as two different go-openai types, so both have to be + // classified the same way. + It("reports an auth failure carrying an error envelope distinguishably", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + Expect(json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"message": "invalid api key", "code": http.StatusUnauthorized}, + })).To(Succeed()) + })) + defer srv.Close() + + _, err := Probe(context.Background(), srv.URL+"/v1", "bad-key") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrUnauthorized)).To(BeTrue(), "want ErrUnauthorized, got %v", err) + }) + + It("does not call a server that answered with an error unreachable", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := Probe(context.Background(), srv.URL+"/v1", "") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "a server that replied is not unreachable, got %v", err) + Expect(errors.Is(err, ErrUnauthorized)).To(BeFalse(), "500 is not an auth failure, got %v", err) + }) + + // Pointing chat at some other service that happens to be listening is a + // different problem from nothing listening, and needs different advice. + It("does not call a reply it could not parse unreachable", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, err := w.Write([]byte("not LocalAI")) + Expect(err).ToNot(HaveOccurred()) + })) + defer srv.Close() + + _, err := Probe(context.Background(), srv.URL+"/v1", "") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "something answered, got %v", err) + }) + + It("returns every advertised id, including ones that are not models", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + Expect(json.NewEncoder(w).Encode(map[string]any{ + "object": "list", + "data": []map[string]string{ + {"id": "zeta", "object": "model"}, + {"id": ".gitignore", "object": "model"}, + {"id": "alpha", "object": "model"}, + {"id": "voice.tar.bz2", "object": "model"}, + }, + })).To(Succeed()) + })) + defer srv.Close() + + // Verbatim and in server order: deciding which of these are real, and + // what order to show them in, belongs to the caller. + models, err := Probe(context.Background(), srv.URL+"/v1", "") + Expect(err).ToNot(HaveOccurred()) + Expect(models).To(Equal([]string{"zeta", ".gitignore", "alpha", "voice.tar.bz2"})) + }) + + It("stops early when the context is already cancelled", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": []any{}})).To(Succeed()) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := Probe(ctx, srv.URL+"/v1", "") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "want the cancellation preserved, got %v", err) + // A cancelled probe learned nothing about the endpoint, so it must not + // send the caller off to start a server that may already be running. + Expect(errors.Is(err, ErrUnreachable)).To(BeFalse(), "cancelling is not a verdict on the server, got %v", err) + }) + + It("reports a server that never answers as unreachable", func() { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer srv.Close() + defer close(release) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := Probe(ctx, srv.URL+"/v1", "") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrUnreachable)).To(BeTrue(), "want ErrUnreachable, got %v", err) + Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue(), "want the deadline preserved, got %v", err) + }) + + It("returns an empty list when the server has no models", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": []any{}})).To(Succeed()) + })) + defer srv.Close() + + models, err := Probe(context.Background(), srv.URL+"/v1", "") + Expect(err).ToNot(HaveOccurred()) + Expect(models).To(BeEmpty()) + }) +}) diff --git a/core/cli/chat/resolve.go b/core/cli/chat/resolve.go new file mode 100644 index 000000000000..8b39f8138504 --- /dev/null +++ b/core/cli/chat/resolve.go @@ -0,0 +1,95 @@ +package chat + +import ( + "errors" + "fmt" + "slices" + "sort" + "strings" + + "github.com/mudler/xlog" +) + +// ModelChooser asks the user to pick one of models. It is nil when the session +// is not interactive. +type ModelChooser func(models []string) (string, error) + +// ModelRequest is everything model resolution needs. +type ModelRequest struct { + Flag string // --model + Configured string // model recorded in the agent config + Available []string // models the server advertises + StateDir string // where an interactive choice is persisted + Choose ModelChooser // nil means non-interactive + // Notify reports a problem that is worth telling the user about but not + // worth failing over. Nil discards it. It exists because the one such + // problem here, a choice that could not be saved, changes what the user + // should expect next: they will be asked again. A log line does not reach + // them, since the agent runs at log level error by default. + Notify func(message string) +} + +// ResolveModel picks the model for this invocation. A flag or a configured +// value wins outright and is not persisted; only an interactive choice is +// written back, so the prompt appears at most once. +// +// Available is used exactly as the server gave it. LocalAI advertises stray +// files it finds in the models directory alongside real models, but real model +// IDs contain dots too (lfm2.5-8b-a1b), so any client-side "looks like a +// filename" heuristic would eventually hide a model the user has. Deciding +// which advertised IDs are real belongs to the endpoint, not to a guess here. +func ResolveModel(req ModelRequest) (string, error) { + if req.Flag != "" { + return req.Flag, nil + } + if req.Configured != "" { + return req.Configured, nil + } + + // The server's /v1/models ordering is not stable between calls, so sort + // before showing or listing: the same number must mean the same model on + // the next run. Sort a copy; the caller's slice is not ours to reorder. + available := append([]string(nil), req.Available...) + sort.Strings(available) + + switch len(available) { + case 0: + return "", errors.New("the LocalAI server has no models installed. Install one with 'local-ai models install ', then run 'local-ai chat' again") + case 1: + return available[0], nil + } + + if req.Choose == nil { + return "", fmt.Errorf( + "several models are available; pick one with --model. Available: %s", + strings.Join(available, ", "), + ) + } + + chosen, err := req.Choose(available) + if err != nil { + return "", err + } + // Choose is an interface, so its answer is checked rather than trusted. + // What comes back is persisted and every later run starts against it, so a + // chooser that returns an empty string or a name of its own would record a + // model the server never offered and there would be nothing left to catch + // it. + if !slices.Contains(available, chosen) { + return "", fmt.Errorf( + "the model chooser answered %q, which is not one of the available models: %s", + chosen, strings.Join(available, ", "), + ) + } + if req.StateDir != "" { + if err := PersistModel(req.StateDir, chosen); err != nil { + // A failure to remember the choice must not block the session: the + // user picked a model, so honour it and say what will happen. + xlog.Warn("could not save the model choice", "error", err, "model", chosen) + if req.Notify != nil { + req.Notify(fmt.Sprintf("Your choice of %s could not be saved, so this question comes back next time: %v", chosen, err)) + } + } + } + return chosen, nil +} diff --git a/core/cli/chat/resolve_test.go b/core/cli/chat/resolve_test.go new file mode 100644 index 000000000000..bbb49d06e401 --- /dev/null +++ b/core/cli/chat/resolve_test.go @@ -0,0 +1,156 @@ +package chat + +import ( + "errors" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ResolveModel", func() { + It("prefers the flag over everything", func() { + got, err := ResolveModel(ModelRequest{ + Flag: "from-flag", + Configured: "from-config", + Available: []string{"a", "b"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal("from-flag")) + }) + + It("uses the configured model when no flag is given", func() { + got, err := ResolveModel(ModelRequest{ + Configured: "from-config", + Available: []string{"a", "b"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal("from-config")) + }) + + It("auto-selects when the server offers exactly one model", func() { + got, err := ResolveModel(ModelRequest{Available: []string{"only-one"}}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal("only-one")) + }) + + It("errors and lists the options when several models exist and there is no chooser", func() { + _, err := ResolveModel(ModelRequest{Available: []string{"a", "b"}}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("a")) + Expect(err.Error()).To(ContainSubstring("b")) + Expect(err.Error()).To(ContainSubstring("--model")) + }) + + It("sorts before offering, so the same number means the same model next run", func() { + var offered []string + available := []string{"zeta", "alpha", "mid"} + _, err := ResolveModel(ModelRequest{ + Available: available, + StateDir: GinkgoT().TempDir(), + Choose: func(models []string) (string, error) { + offered = models + return models[0], nil + }, + }) + Expect(err).ToNot(HaveOccurred()) + // The server's /v1/models ordering is unstable between calls. + Expect(offered).To(Equal([]string{"alpha", "mid", "zeta"})) + // Sorting must happen on a copy: the caller still owns this slice, and + // reordering it under them would move whatever they index into it. + Expect(available).To(Equal([]string{"zeta", "alpha", "mid"})) + }) + + It("lists models in sorted order in the several-models error", func() { + _, err := ResolveModel(ModelRequest{Available: []string{"zeta", "alpha"}}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("alpha, zeta")) + }) + + It("asks the chooser when several models exist, and persists the answer", func() { + dir := GinkgoT().TempDir() + got, err := ResolveModel(ModelRequest{ + Available: []string{"a", "b"}, + StateDir: dir, + Choose: func(models []string) (string, error) { return models[1], nil }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal("b")) + + data, err := os.ReadFile(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring("model: b")) + }) + + // The answer is persisted and every later run starts against it, and + // ModelChooser is exported, so the invariant has to hold for choosers this + // package did not write. + DescribeTable("refuses an answer the chooser was not offered", + func(answer string) { + dir := GinkgoT().TempDir() + got, err := ResolveModel(ModelRequest{ + Available: []string{"alpha", "zeta"}, + StateDir: dir, + Choose: func([]string) (string, error) { return answer, nil }, + }) + Expect(err).To(HaveOccurred()) + Expect(got).To(BeEmpty()) + Expect(err.Error()).To(ContainSubstring("alpha, zeta")) + + _, statErr := os.Stat(ConfigPath(dir)) + Expect(os.IsNotExist(statErr)).To(BeTrue(), "nothing may be recorded for an answer that was refused") + }, + Entry("nothing at all", ""), + Entry("a model the server never offered", "gamma"), + Entry("an offered model with stray whitespace", " alpha"), + Entry("an offered model in the wrong case", "Alpha"), + ) + + It("notifies, and still honours the choice, when it cannot be persisted", func() { + dir := GinkgoT().TempDir() + // A directory where the config file belongs: the write fails for any + // user, including root. + Expect(os.MkdirAll(ConfigPath(dir), 0o700)).To(Succeed()) + + var notices []string + got, err := ResolveModel(ModelRequest{ + Available: []string{"a", "b"}, + StateDir: dir, + Choose: func(models []string) (string, error) { return models[0], nil }, + Notify: func(message string) { notices = append(notices, message) }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal("a")) + Expect(notices).To(HaveLen(1)) + Expect(notices[0]).To(ContainSubstring("a")) + Expect(notices[0]).To(ContainSubstring("could not be saved")) + }) + + It("says nothing when the choice was saved", func() { + var notices []string + _, err := ResolveModel(ModelRequest{ + Available: []string{"a", "b"}, + StateDir: GinkgoT().TempDir(), + Choose: func(models []string) (string, error) { return models[0], nil }, + Notify: func(message string) { notices = append(notices, message) }, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(notices).To(BeEmpty()) + }) + + It("propagates a chooser cancellation", func() { + cancelled := errors.New("cancelled") + _, err := ResolveModel(ModelRequest{ + Available: []string{"a", "b"}, + StateDir: GinkgoT().TempDir(), + Choose: func([]string) (string, error) { return "", cancelled }, + }) + Expect(errors.Is(err, cancelled)).To(BeTrue()) + }) + + It("errors with an install hint when the server has no models", func() { + _, err := ResolveModel(ModelRequest{Available: nil}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("local-ai models install")) + }) +}) diff --git a/core/cli/chat/run.go b/core/cli/chat/run.go new file mode 100644 index 000000000000..3da8e08d2b60 --- /dev/null +++ b/core/cli/chat/run.go @@ -0,0 +1,475 @@ +package chat + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/mudler/nib/app" + nibcmd "github.com/mudler/nib/cmd" + nibconfig "github.com/mudler/nib/config" + nibtypes "github.com/mudler/nib/types" + "golang.org/x/term" +) + +// Options is everything the chat command passes down from its flags. +type Options struct { + Args []string // forwarded to the agent verbatim + Endpoint string // the server root, e.g. http://127.0.0.1:8080 + BaseURL string // the API base, e.g. http://127.0.0.1:8080/v1 + APIKey string + Model string + StateDir string + TraceDir string + Yolo bool + // ProbeTimeout bounds each check of the server. Zero means + // defaultProbeTimeout. + ProbeTimeout time.Duration + + In io.Reader + Out io.Writer + ErrOut io.Writer +} + +// ExitStatus reports the status the process should exit with for an agent run +// that failed, and whether err is such a failure. +// +// nib writes what went wrong to the error stream itself and hands back nothing +// but a code, so an error that satisfies this has already been explained to the +// user and must not be reported a second time. The refusal to open a +// full-screen session on a stdin that cannot be read arrives this way, and it +// is the one a user is most likely to meet: 'echo q | local-ai chat' names +// --cli, and burying that under a second message would hide the fix. +func ExitStatus(err error) (int, bool) { + var exit app.ExitError + if errors.As(err, &exit) { + return exit.Code, true + } + return 0, false +} + +// shutdownSignals end the session. SIGHUP is one of them because this is a +// terminal program: once the terminal is gone there is nobody left to talk to, +// and a server started for the session has to go with it. +var shutdownSignals = []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP} + +// shutdownContext derives a context that is cancelled when the process is +// asked to stop. +// +// Without it a signal kills this process where it stands, skipping every +// deferred call, and a 'local-ai run' started for the session is reparented to +// init with nothing left that knows to shut it down. An interactive Ctrl+C is +// safe on its own, because the child shares this process' foreground process +// group and the terminal signals all of it, but a SIGTERM from a supervisor or +// a script reaches only this process. +// +// Since nib v0.5.1 cancelling this context does end the session: RunTUI passes +// it to bubbletea, which unwinds the program and reports the context's own +// error. The server is still stopped on cancellation rather than on the way +// out (see runSession), because registering here removes SIGHUP's default +// terminate disposition, and a guarantee about a server this process owns is +// not worth resting on how promptly a third party unwinds its interface. +// +// A handler rather than SysProcAttr.Pdeathsig on the child: Pdeathsig is +// Linux-only, and in Go it is delivered when the OS thread that forked exits +// rather than when the process does, so it can fire on a perfectly healthy +// parent. Setpgid is not an alternative either, since taking the child out of +// the foreground process group is what would break the Ctrl+C that works +// today. SIGKILL stays uncovered, as it must: nothing in the process can +// observe it. +func shutdownContext(parent context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, shutdownSignals...) +} + +// Run starts the agent: resolve where state lives, make sure a server is +// reachable, pick a model, then hand off to nib. +func Run(ctx context.Context, opts Options) error { + ctx, stop := shutdownContext(ctx) + defer stop() + + p, err := prepare(ctx, opts, isTerminal(opts.In)) + if err != nil { + return err + } + // A server this process started belongs to this session, and Stop is + // nil-safe and idempotent, so one defer covers both cases and costs nothing + // when runSession has already stopped it. + defer p.server.Stop() + + return runSession(ctx, p.server, func(ctx context.Context) error { + return runAgent(ctx, p.dir, p.model, opts) + }) +} + +// runSession hands the terminal to agent, and stops a server started for this +// session as soon as the context is cancelled rather than when agent returns. +// +// The difference matters because the deferred Stop in Run is only reached once +// agent returns, and how long that takes is nib's business rather than ours. +// nib v0.5.1 does unwind the TUI on a cancelled context, so it does return; a +// SIGHUP no longer leaves the interface on screen with the server behind it, +// which it did before, when bubbletea's own SIGINT and SIGTERM handler was the +// only thing that ever quit the program and registering for SIGHUP had removed +// the default disposition that used to end the process. Watching the context +// keeps the guarantee independent of what the agent does with it. +func runSession(ctx context.Context, server *StartedServer, agent func(context.Context) error) error { + returned := make(chan struct{}) + defer close(returned) + + go func() { + select { + case <-ctx.Done(): + server.Stop() + case <-returned: + } + }() + + return agent(ctx) +} + +// preparation is what the agent needs once the environment is ready: where its +// state lives, which model to talk to, and the server this process started on +// the user's behalf, if any. +type preparation struct { + dir string + model string + server *StartedServer +} + +// prepare does everything that has to happen before the agent takes over the +// terminal. It is split out of Run because all of it is testable and none of +// what follows is: once app.Run has the terminal there is no seam left. +// +// interactive says whether there is a user to prompt. It is a parameter rather +// than a second read of opts.In so the prompts can be driven over a pipe. +func prepare(ctx context.Context, opts Options, interactive bool) (_ *preparation, err error) { + dir, dirErr := StateDir(opts.StateDir) + if dirErr != nil { + return nil, dirErr + } + if err := EnsureStateDir(dir, opts.BaseURL); err != nil { + return nil, err + } + + if isLocalOnlyArgs(opts.Args) { + return &preparation{dir: dir}, nil + } + + // One prompter for every question this run asks; see its doc comment for + // why the reader cannot be rebuilt per question. + var prompts *prompter + if interactive { + prompts = newPrompter(opts.In, opts.ErrOut) + } + + var started *StartedServer + defer func() { + // Nothing after the spawn may leave a server behind: the caller only + // learns about it through a successful return. + if err != nil { + started.Stop() + } + }() + + models, err := probeModels(ctx, opts) + if err != nil { + if errors.Is(err, ErrUnauthorized) { + return nil, fmt.Errorf("the LocalAI server at %s rejected the API key. Pass --api-key or set LOCALAI_API_KEY", opts.Endpoint) + } + if !errors.Is(err, ErrUnreachable) { + return nil, err + } + + var confirm Confirmer + if interactive { + confirm = prompts.yesNo + } + var startErr error + started, startErr = OfferToStart(ctx, StartOptions{ + Endpoint: opts.Endpoint, + Confirm: confirm, + Stderr: opts.ErrOut, + }) + if startErr != nil { + err = startErr + if errors.Is(startErr, ErrDeclined) { + err = fmt.Errorf("no LocalAI server at %s. Start one with 'local-ai run', or point elsewhere with --endpoint", opts.Endpoint) + } + return nil, err + } + say(opts.ErrOut, "Started a temporary LocalAI server; it stops when you exit. Use 'local-ai run' for a persistent one.\n") + + if models, err = probeModels(ctx, opts); err != nil { + return nil, err + } + } + + var chooser ModelChooser + if interactive { + chooser = prompts.choose + } + model, err := ResolveModel(ModelRequest{ + Flag: opts.Model, + Configured: configuredModel(dir), + Available: models, + StateDir: dir, + Choose: chooser, + Notify: func(message string) { say(opts.ErrOut, "%s\n", message) }, + }) + if err != nil { + return nil, err + } + + return &preparation{dir: dir, model: model, server: started}, nil +} + +func runAgent(ctx context.Context, dir, model string, opts Options) error { + return app.Run(ctx, agentOptions(dir, model, opts)) +} + +// agentOptions builds the request handed to nib. It is split out of runAgent +// because app.Run takes the terminal and cannot be called from a test, while +// what is asked of it is exactly the part worth pinning. +// +// The stream fields are the interesting ones, and they are not symmetric. +// +// nib reads a non-nil stream as "the embedder wants this used", and refuses +// every mode but --cli when such a stream is not a terminal, because the +// full-screen interface renders on /dev/tty and would otherwise ignore it in +// silence. Nil means "not injected": nib falls back to the process stream and +// behaves as standalone nib does. +// +// Stdin is passed through as it comes. A piped or redirected stdin really is +// ignored by the interface, so the refusal is the honest answer there, and it +// is the one users meet: 'echo q | local-ai chat' says to re-run with --cli +// rather than opening a full-screen session that will never read the question. +// +// Stdout is different, and the process stream is deliberately sent as nil. The +// interface does write to stdout even when it is a pipe: that is the whole of +// nib's shell-capture idiom, out=$(local-ai chat --height 50%), which is what +// the Ctrl+Space widget emitted by --init is built on. Injecting os.Stdout +// there would refuse the widget for a stream nib was going to use anyway. +// +// The test is identity with os.Stdout rather than whether it happens to be a +// terminal, which means a shell redirect goes the same way as the widget: +// 'local-ai chat > out.txt' no longer refuses either, and renders on /dev/tty +// with the capture line landing in the file. That is not a second decision, it +// is the same one. Both are the process stdout as the shell handed it over, +// differing only in being a pipe rather than a regular file, which nib's gate +// does not look at and should not. Refusing one would refuse the other. +// +// What stays injected, and so stays subject to the refusal, is a writer some +// in-process caller chose for itself rather than inherited: a bytes.Buffer, or +// an *os.File it opened. The specs rely on that. +// +// Stderr is never gated by nib, so it is passed through unchanged. +// +// The config values go through Overrides rather than Defaults, and that is not +// a detail. Defaults are seeds: they sit BENEATH the config file, so the file +// silently undoes them. Everything here is a decision this invocation already +// made on the user's behalf, and a flag that the file can undo is not a flag. +// It was not a rare case either, since EnsureStateDir writes base_url on the +// first run and an interactive choice writes model, so from the second run on +// the file carried a value for both and --endpoint and --model did nothing. +// +// The one asymmetry to plan around is that nib cannot tell "set to the zero +// value" from "not set", so an override only ever raises a field. --yolo can +// turn approval off, but nothing on the command line can turn it back on over +// an approval_mode: auto in the file; that needs a config edit. Same shape for +// the strings, which is what makes an unset --api-key or --trace-dir leave the +// file's value standing, as it should. +// +// nib's own --trace-dir and --yolo, and their NIB_TRACE_DIR and NIB_YOLO twins, +// are resolved after the config load and so still outrank these. That is +// deliberate upstream: they are instructions to nib rather than ambient +// environment. +func agentOptions(dir, model string, opts Options) app.Options { + // Model is the model this run resolved, which already prefers --model and + // falls back to the file's own model, so the override restates the file's + // value rather than fighting it whenever no flag was given. + // + // BaseURL is the endpoint this run probed, offered to start a server for, + // and seeded the config with. Handing nib a different one is precisely the + // split that made --endpoint a no-op, so the agent talks to the server + // LocalAI checked. Pointing somewhere else for good is LOCALAI_CHAT_ENDPOINT + // or --endpoint, not a hand-edited base_url the probe never reads. + // + // APIKey and TraceDir are the flags as given, empty when they were not, and + // an empty override leaves the file alone. TraceDir is runtime-only in nib + // (yaml:"-"), so no file value exists for it to beat today; it belongs here + // with the other flags rather than one rung down for a reason that could + // quietly stop being true. + overrides := nibtypes.Config{ + Model: model, + APIKey: opts.APIKey, + BaseURL: opts.BaseURL, + TraceDir: opts.TraceDir, + } + if opts.Yolo { + overrides.ApprovalMode = "auto" + } + + return app.Options{ + Args: opts.Args, + ProgramName: "local-ai chat", + BaseDir: dir, + Overrides: overrides, + SkipSetup: true, + SkipBareEnv: true, + Stdin: opts.In, + Stdout: ownStdout(opts.Out), + Stderr: opts.ErrOut, + } +} + +// ownStdout reports the writer as nib's own rather than as an injected one when +// it is the process stdout, by answering nil for it. See agentOptions for why +// that distinction is the difference between a working Ctrl+Space widget and a +// refused one. +func ownStdout(w io.Writer) io.Writer { + if f, ok := w.(*os.File); ok && f == os.Stdout { + return nil + } + return w +} + +// defaultProbeTimeout bounds a check of the server. Listing models is cheap, +// so this is long enough that a loaded server is never given up on and short +// enough that a hung one does not leave the user staring at nothing. +const defaultProbeTimeout = 30 * time.Second + +// probeModels lists what the endpoint offers, under a budget. +func probeModels(ctx context.Context, opts Options) ([]string, error) { + timeout := opts.ProbeTimeout + if timeout <= 0 { + timeout = defaultProbeTimeout + } + // A real deadline rather than a cancel plus a timer. Probe reads + // context.Canceled as "the caller gave up", which is a statement about the + // caller and not about the endpoint, and only a deadline as "nothing + // answered in time". Expiring the budget as a cancellation would stop + // ErrUnreachable firing for precisely the hung servers that the offer to + // start one exists for. + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return Probe(probeCtx, opts.BaseURL, opts.APIKey) +} + +// isLocalOnlyArgs reports whether the forwarded arguments do their work +// without ever reaching a model, in which case demanding a running server (and +// offering to start one) would be an obstacle rather than a service. +// +// Two groups qualify. The management subcommands edit nib's own state: plugin, +// skill, and the mcp verbs that add or remove configured servers, which is +// asked of nib rather than restated, because bare 'mcp' and its transport +// flags do serve the agent and do need a model. The other group is the flags +// that only print something, above all --init: its shell snippet goes into an +// rc file, typically long before any server exists. +func isLocalOnlyArgs(args []string) bool { + if len(args) == 0 { + return false + } + // A scan rather than a look at args[0]: the mode flags this command + // translates are prepended, so --init is not necessarily first. Positional + // text cannot be mistaken for a flag here, since nib ignores what is left + // after flag parsing. + for _, a := range args { + switch { + case a == "--init", a == "-init", strings.HasPrefix(a, "--init="), strings.HasPrefix(a, "-init="): + return true + case a == "--version", a == "-version": + return true + } + } + switch args[0] { + case "plugin", "skill": + return true + case "mcp": + return len(args) >= 2 && nibcmd.IsMCPManageSubcommand(args[1]) + } + return false +} + +// configuredModel reads the model already recorded in the agent config, if any. +func configuredModel(dir string) string { + cfg := nibconfig.LoadWith(nibconfig.LoadOptions{BaseDir: dir, SkipBareEnv: true}) + return cfg.Model +} + +func isTerminal(in io.Reader) bool { + f, ok := in.(*os.File) + return ok && term.IsTerminal(int(f.Fd())) +} + +// say writes a line of interactive chatter: a question, or a notice about +// something that did not stop the session. A write that fails is not worth +// failing over, and when the terminal really is gone the read that follows the +// question says so. +func say(w io.Writer, format string, args ...any) { + _, _ = fmt.Fprintf(w, format, args...) +} + +// prompter asks this run's questions on the user's terminal. +// +// It owns the buffered reader rather than wrapping opts.In per question, +// because bufio reads ahead: a throwaway reader for the "start a server?" +// question swallows the model choice that was typed behind it, and the next +// question then sees EOF. A real run asks both, one after the other. +type prompter struct { + in *bufio.Reader + out io.Writer +} + +func newPrompter(in io.Reader, out io.Writer) *prompter { + return &prompter{in: bufio.NewReader(in), out: out} +} + +// yesNo satisfies Confirmer. Anything that is not an explicit yes is a no, so +// a closed stream declines rather than proceeding on the user's behalf. +func (p *prompter) yesNo(question string) (bool, error) { + say(p.out, "%s [y/N]: ", question) + line, err := p.in.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, fmt.Errorf("reading the answer: %w", err) + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true, nil + } + return false, nil +} + +// choose satisfies ModelChooser. It answers with a list index rather than with +// what the user typed, so the result can only ever be one of the models it was +// offered: a model name is not something to accept unvalidated here, since +// ResolveModel persists whatever comes back and every later run then starts +// against it. +func (p *prompter) choose(models []string) (string, error) { + if len(models) == 0 { + return "", errors.New("there is nothing to choose from") + } + say(p.out, "Several models are available:\n") + for i, m := range models { + say(p.out, " %d) %s\n", i+1, m) + } + say(p.out, "Pick one [1-%d]: ", len(models)) + + line, err := p.in.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("reading the choice: %w", err) + } + answer := strings.TrimSpace(line) + n, err := strconv.Atoi(answer) + if err != nil || n < 1 || n > len(models) { + return "", fmt.Errorf("not a valid choice: %q. Pick a number between 1 and %d, or pass --model", answer, len(models)) + } + return models[n-1], nil +} diff --git a/core/cli/chat/run_test.go b/core/cli/chat/run_test.go new file mode 100644 index 000000000000..e334e0a0b0f4 --- /dev/null +++ b/core/cli/chat/run_test.go @@ -0,0 +1,629 @@ +package chat + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/mudler/nib/app" + nibconfig "github.com/mudler/nib/config" + nibtypes "github.com/mudler/nib/types" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// modelServer answers /v1/models with the given ids, as LocalAI does. +func modelServer(ids ...string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data := make([]map[string]string, 0, len(ids)) + for _, id := range ids { + data = append(data, map[string]string{"id": id, "object": "model"}) + } + w.Header().Set("Content-Type", "application/json") + Expect(json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})).To(Succeed()) + })) +} + +var _ = Describe("prepare", func() { + var ( + dir string + errOut *bytes.Buffer + ) + + BeforeEach(func() { + dir = GinkgoT().TempDir() + errOut = &bytes.Buffer{} + }) + + // optionsFor points a run at srv, with no input to read: the default is a + // session nobody can be asked anything in. + optionsFor := func(srv *httptest.Server) Options { + endpoint := "http://127.0.0.1:0" + base := endpoint + "/v1" + if srv != nil { + endpoint, base = srv.URL, srv.URL+"/v1" + } + return Options{ + Endpoint: endpoint, + BaseURL: base, + StateDir: dir, + In: strings.NewReader(""), + Out: &bytes.Buffer{}, + ErrOut: errOut, + } + } + + It("uses the only model the server offers", func() { + srv := modelServer("the-only-model") + defer srv.Close() + + p, err := prepare(context.Background(), optionsFor(srv), false) + Expect(err).ToNot(HaveOccurred()) + Expect(p.model).To(Equal("the-only-model")) + Expect(p.dir).To(Equal(dir)) + Expect(p.server).To(BeNil(), "nothing was started, so nothing is owned") + }) + + It("seeds the agent config with the endpoint on first run", func() { + srv := modelServer("m") + defer srv.Close() + + _, err := prepare(context.Background(), optionsFor(srv), false) + Expect(err).ToNot(HaveOccurred()) + + data, err := os.ReadFile(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring(srv.URL + "/v1")) + }) + + It("lets --model win over what the server offers", func() { + srv := modelServer("a", "b") + defer srv.Close() + + opts := optionsFor(srv) + opts.Model = "not-listed-yet" + p, err := prepare(context.Background(), opts, false) + Expect(err).ToNot(HaveOccurred()) + Expect(p.model).To(Equal("not-listed-yet")) + }) + + It("advises about the API key when the server rejects it", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := prepare(context.Background(), optionsFor(srv), false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("--api-key")) + Expect(err.Error()).To(ContainSubstring(srv.URL)) + }) + + // Not interactive means nobody can answer the offer, so the advice has to + // stand on its own. + It("advises how to start a server when none is reachable", func() { + srv := modelServer() + url := srv.URL + srv.Close() // nothing is listening now + + opts := optionsFor(nil) + opts.Endpoint, opts.BaseURL = url, url+"/v1" + _, err := prepare(context.Background(), opts, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("local-ai run")) + Expect(err.Error()).To(ContainSubstring(url)) + }) + + // A server that accepts the connection and then never replies is the case + // the offer to start one exists for, so the budget has to expire as a + // deadline: Probe reads a cancellation as "the caller gave up" and refuses + // to call the endpoint unreachable on the strength of it. + It("treats a server that never answers as one that is not there", func(ctx SpecContext) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + } + })) + defer srv.Close() + defer close(release) + + opts := optionsFor(srv) + opts.ProbeTimeout = 100 * time.Millisecond + _, err := prepare(context.Background(), opts, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("local-ai run"), "want the offer-a-server advice, got %v", err) + }, SpecTimeout(30*time.Second)) + + It("asks which model to use and remembers the answer", func() { + srv := modelServer("zeta", "alpha") + defer srv.Close() + + opts := optionsFor(srv) + opts.In = strings.NewReader("2\n") + p, err := prepare(context.Background(), opts, true) + Expect(err).ToNot(HaveOccurred()) + // The list is sorted before it is shown, so 2 is zeta, not the second + // thing the server happened to name. + Expect(p.model).To(Equal("zeta")) + Expect(errOut.String()).To(ContainSubstring("1) alpha")) + Expect(errOut.String()).To(ContainSubstring("2) zeta")) + + data, err := os.ReadFile(ConfigPath(dir)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring("zeta")) + }) + + // The choice is prompted for once and remembered. When remembering it fails + // the user is about to be asked again on every future run, so they have to + // be told here: a log line is invisible at the default log level. + It("says so on the prompt when the choice cannot be remembered", func() { + srv := modelServer("zeta", "alpha") + defer srv.Close() + + // A directory where the config file belongs: writable state dir, + // unwritable config, on any platform and as any user. + Expect(os.MkdirAll(ConfigPath(dir), 0o700)).To(Succeed()) + + opts := optionsFor(srv) + opts.In = strings.NewReader("1\n") + p, err := prepare(context.Background(), opts, true) + + // Failing to remember the choice must not cost the user their session. + Expect(err).ToNot(HaveOccurred()) + Expect(p.model).To(Equal("alpha")) + Expect(errOut.String()).To(ContainSubstring("could not be saved"), "the user has to learn they will be asked again") + }) + + It("does not ask again once a model is recorded", func() { + srv := modelServer("zeta", "alpha") + defer srv.Close() + + Expect(PersistModel(dir, "alpha")).To(Succeed()) + + opts := optionsFor(srv) + opts.In = strings.NewReader("") // an answer would have nothing to read + p, err := prepare(context.Background(), opts, true) + Expect(err).ToNot(HaveOccurred()) + Expect(p.model).To(Equal("alpha")) + Expect(errOut.String()).To(BeEmpty()) + }) + + It("says what to install when the server has no models", func() { + srv := modelServer() + defer srv.Close() + + _, err := prepare(context.Background(), optionsFor(srv), false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("models install")) + }) + + Describe("arguments that only touch local state", func() { + unreachable := func(args ...string) Options { + opts := optionsFor(nil) // port 0: nothing can ever answer here + opts.Args = args + return opts + } + + DescribeTable("skips the server entirely", + func(args ...string) { + p, err := prepare(context.Background(), unreachable(args...), false) + Expect(err).ToNot(HaveOccurred()) + Expect(p.model).To(BeEmpty()) + Expect(p.server).To(BeNil()) + }, + Entry("plugin", "plugin", "list"), + Entry("skill", "skill", "list"), + Entry("mcp add", "mcp", "add", "srv"), + Entry("mcp list", "mcp", "list"), + // The shell snippet is what a user puts in their rc file, long + // before any server exists. + Entry("the shell integration script", "--init", "zsh"), + Entry("the version", "--version"), + ) + + // Bare 'mcp' and its transport flags serve the agent over MCP, so they + // need a model like any other session. Only the verbs that edit the + // configured servers are local. + DescribeTable("still needs a server", + func(args ...string) { + _, err := prepare(context.Background(), unreachable(args...), false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("local-ai run")) + }, + Entry("mcp over stdio", "mcp", "--stdio"), + Entry("bare mcp", "mcp"), + ) + }) + + // A reader per question would read ahead into a buffer it then discards, so + // the second question would see EOF whenever both answers were typed ahead. + // That is the shape of a real run: the offer to start a server is followed + // by the model prompt. + It("keeps reading answers from the same stream across questions", func() { + out := &bytes.Buffer{} + p := newPrompter(strings.NewReader("y\n2\n"), out) + + yes, err := p.yesNo("Start one now?") + Expect(err).ToNot(HaveOccurred()) + Expect(yes).To(BeTrue()) + + chosen, err := p.choose([]string{"alpha", "zeta"}) + Expect(err).ToNot(HaveOccurred()) + Expect(chosen).To(Equal("zeta")) + }) + + // Whatever the chooser returns is persisted and used for every later run, + // so an answer that is not one of the offered models must never come back + // as one. + Describe("the model prompt", func() { + offered := []string{"alpha", "zeta"} + + DescribeTable("refuses an answer that is not one of the numbers shown", + func(answer string) { + chosen, err := newPrompter(strings.NewReader(answer), &bytes.Buffer{}).choose(offered) + Expect(err).To(HaveOccurred()) + Expect(chosen).To(BeEmpty()) + }, + Entry("nothing at all", ""), + Entry("a blank line", "\n"), + Entry("only spaces", " \n"), + Entry("zero", "0\n"), + Entry("past the end", "3\n"), + Entry("negative", "-1\n"), + Entry("a model name", "zeta\n"), + Entry("a number with a suffix", "1x\n"), + ) + + It("says how to answer when the answer was not a number", func() { + _, err := newPrompter(strings.NewReader("banana\n"), &bytes.Buffer{}).choose(offered) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("between 1 and 2")) + Expect(err.Error()).To(ContainSubstring("--model")) + }) + + It("returns the model shown against the number", func() { + chosen, err := newPrompter(strings.NewReader("1\n"), &bytes.Buffer{}).choose(offered) + Expect(err).ToNot(HaveOccurred()) + Expect(chosen).To(Equal("alpha")) + }) + + It("refuses to ask when there is nothing to offer", func() { + chosen, err := newPrompter(strings.NewReader("1\n"), &bytes.Buffer{}).choose(nil) + Expect(err).To(HaveOccurred()) + Expect(chosen).To(BeEmpty()) + }) + }) + + // A server started for this session is stopped by a deferred call, which a + // signal skips: the process dies where it stands and leaves 'local-ai run' + // reparented to init. + Describe("shutdown signals", func() { + It("ends the session when the terminal goes away", func() { + ctx, stop := shutdownContext(context.Background()) + defer stop() + + self, err := os.FindProcess(os.Getpid()) + Expect(err).ToNot(HaveOccurred()) + Expect(self.Signal(syscall.SIGHUP)).To(Succeed()) + + Eventually(ctx.Done()).WithTimeout(5 * time.Second).Should(BeClosed()) + Expect(ctx.Err()).To(MatchError(context.Canceled)) + }) + + // SIGINT and SIGTERM cannot be delivered here to prove the same thing: + // Ginkgo registers for both to abort the suite, and a signal goes to + // every registered listener. + It("also listens for an interrupt and a terminate", func() { + Expect(shutdownSignals).To(ContainElements(os.Signal(os.Interrupt), os.Signal(syscall.SIGTERM))) + }) + }) + + // Cancelling the context does unwind nib's TUI since v0.5.1, but how long + // that takes is nib's business, and the deferred Stop in Run is only reached + // once the agent returns. A server this process started is ours to end, so + // the guarantee is made here instead, where it does not depend on the agent + // at all. Before v0.5.1 there was no guarantee to be had on the SIGHUP path: + // bubbletea's own SIGINT and SIGTERM handler was the only thing that ever + // quit the program, and registering for SIGHUP took away the default + // disposition that used to end the process. + Describe("runSession", func() { + It("stops the session's server on cancellation, without waiting for the agent", func() { + server, proc := stoppableServer() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := runSession(ctx, server, func(ctx context.Context) error { + cancel() + Eventually(func() int32 { return proc.interrupts.Load() }). + WithTimeout(5 * time.Second). + Should(BeNumerically(">", 0), "the server has to be stopped while the agent is still running") + return nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(proc.lastSignal.Load()).To(Equal(os.Interrupt)) + }) + + It("leaves the server alone for as long as the session lasts", func() { + server, proc := stoppableServer() + + Expect(runSession(context.Background(), server, func(context.Context) error { + return nil + })).To(Succeed()) + Expect(proc.interrupts.Load()).To(BeZero()) + Expect(proc.kills.Load()).To(BeZero()) + }) + + It("returns what the agent returned", func() { + failed := errors.New("the agent gave up") + server, _ := stoppableServer() + + Expect(runSession(context.Background(), server, func(context.Context) error { + return failed + })).To(MatchError(failed)) + }) + + // Most sessions run against a server the user already had, and there is + // nothing to stop then. + It("copes with a session that started no server", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + Expect(runSession(ctx, nil, func(context.Context) error { + return nil + })).To(Succeed()) + }) + }) + + // Which streams reach nib decides two user-visible behaviours at once, and + // they pull in opposite directions, so both are pinned here rather than left + // to whoever next edits the literal. + // + // nib refuses every mode but --cli when a stream it was handed is not a + // terminal. That refusal is wanted for stdin, where it is what tells someone + // piping a question to re-run with --cli. It is not wanted for the process + // stdout, where it would refuse the Ctrl+Space widget that --init emits: + // out=$(local-ai chat --height 50%) puts a pipe on stdout by construction, + // and writing the chosen command into that pipe is the entire point. + Describe("agentOptions", func() { + // optionsWithStreams is a request that differs from the next only in + // what it was told to read and write. + optionsWithStreams := func(in io.Reader, out, errOut io.Writer) Options { + return Options{ + BaseURL: "http://127.0.0.1:8080/v1", + In: in, + Out: out, + ErrOut: errOut, + } + } + + Describe("stdout", func() { + // The regression this exists to catch: reinstating + // 'Stdout: opts.Out' breaks Ctrl+Space and nothing else notices. + It("hands nib nothing for the process stdout, so the capture widget is not refused", func() { + o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)) + Expect(o.Stdout).To(BeNil(), "injecting os.Stdout is what refuses out=$(local-ai chat)") + }) + + It("keeps a stdout the caller chose, which the refusal still guards", func() { + out := &bytes.Buffer{} + o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, out, os.Stderr)) + Expect(o.Stdout).To(BeIdenticalTo(out)) + }) + + // Being an *os.File is not what makes a stream nib's own; being the + // process stdout is. This is a file an in-process caller opened for + // itself, not one a shell redirect handed over as stdout, which + // still arrives as os.Stdout and is still nil-ed. It was never going + // to receive the interface, so it stays injected and stays refused. + It("keeps a file that is not the process stdout", func() { + f, err := os.CreateTemp(GinkgoT().TempDir(), "captured") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(f.Close) + + o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, f, os.Stderr)) + Expect(o.Stdout).To(BeIdenticalTo(f)) + }) + }) + + Describe("stdin", func() { + // The opposite regression: nilling stdin the way stdout is nilled + // would silently drop the refusal that names --cli. + It("hands the process stdin over, so a piped session is still refused", func() { + o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)) + Expect(o.Stdin).To(BeIdenticalTo(os.Stdin)) + }) + + It("hands over a stdin the caller chose", func() { + in := strings.NewReader("a question") + o := agentOptions(dir, "a-model", optionsWithStreams(in, os.Stdout, os.Stderr)) + Expect(o.Stdin).To(BeIdenticalTo(in)) + }) + }) + + // nib gates stdin and stdout and nothing else, so there is no reason to + // hide the error stream from it. + It("hands the error stream over whatever it is", func() { + errOut := &bytes.Buffer{} + o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, errOut)) + Expect(o.Stderr).To(BeIdenticalTo(errOut)) + + o = agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)) + Expect(o.Stderr).To(BeIdenticalTo(os.Stderr)) + }) + + It("names the command a user would type, not the binary nib ships as", func() { + o := agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr)) + Expect(o.ProgramName).To(Equal("local-ai chat"), + "the --init widget invokes this name, so a user has to be able to run it") + }) + + It("carries the resolved session through to nib", func() { + opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr) + opts.Args = []string{"--cli"} + opts.APIKey = "a-key" + opts.TraceDir = "/traces" + + o := agentOptions(dir, "the-model", opts) + Expect(o.Args).To(Equal([]string{"--cli"})) + Expect(o.BaseDir).To(Equal(dir)) + Expect(o.Overrides.Model).To(Equal("the-model")) + Expect(o.Overrides.APIKey).To(Equal("a-key")) + Expect(o.Overrides.BaseURL).To(Equal("http://127.0.0.1:8080/v1")) + Expect(o.Overrides.TraceDir).To(Equal("/traces")) + // The model and the server are settled before nib starts, and the + // bare MODEL and API_KEY variables belong to some other tool. + Expect(o.SkipSetup).To(BeTrue()) + Expect(o.SkipBareEnv).To(BeTrue()) + }) + + // Defaults sit beneath the config file. Anything routed through them is + // accepted from the command line and then thrown away the moment the + // file carries the same key, which is the normal state rather than an + // edge case. Nothing this command resolves belongs there, so the channel + // stays empty and this says so: it is what fails if the block is moved + // back a rung. + It("seeds nothing, because a seed is not a flag", func() { + opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr) + opts.APIKey = "a-key" + opts.TraceDir = "/traces" + opts.Yolo = true + + Expect(agentOptions(dir, "the-model", opts).Defaults).To(Equal(nibtypes.Config{}), + "Defaults lose to the config file, so a value placed there is a flag that does nothing") + }) + + It("asks for automatic approval only when --yolo was given", func() { + opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr) + Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(BeEmpty()) + + opts.Yolo = true + Expect(agentOptions(dir, "a-model", opts).Overrides.ApprovalMode).To(Equal("auto")) + }) + + // The specs above pin what is handed over. These pin what nib does with + // it, which is the part that was wrong: every value below reached + // app.Options intact and was then discarded by the config load, so a + // spec that stops at the struct cannot see the bug. Resolving the config + // the way app.Run resolves it can. + Describe("the config nib actually resolves", func() { + // writeConfig puts a config file where nib will read it, with values + // that disagree with every flag under test. + writeConfig := func(body string) { + Expect(os.WriteFile(ConfigPath(dir), []byte(body), 0o600)).To(Succeed()) + } + + // resolve loads the config exactly as app.Run does, so the precedence + // under test is nib's own rather than a restatement of it here. + resolve := func(o app.Options) nibtypes.Config { + return nibconfig.LoadWith(nibconfig.LoadOptions{ + BaseDir: o.BaseDir, + Defaults: o.Defaults, + Overrides: o.Overrides, + SkipBareEnv: o.SkipBareEnv, + }) + } + + It("sends the requests to the endpoint the flag named, not the one on disk", func() { + writeConfig("base_url: http://127.0.0.1:9999/v1\n") + + opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr) + opts.BaseURL = "http://127.0.0.1:8080/v1" + + cfg := resolve(agentOptions(dir, "a-model", opts)) + Expect(cfg.BaseURL).To(Equal("http://127.0.0.1:8080/v1"), + "--endpoint probed 8080; every turn has to go there too") + }) + + It("uses the model the flag named, not the one the picker recorded", func() { + writeConfig("model: recorded-model\n") + + cfg := resolve(agentOptions(dir, "flag-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))) + Expect(cfg.Model).To(Equal("flag-model")) + }) + + It("uses the key the flag named, not the one nib saved", func() { + writeConfig("api_key: saved-key\n") + + opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr) + opts.APIKey = "flag-key" + + cfg := resolve(agentOptions(dir, "a-model", opts)) + Expect(cfg.APIKey).To(Equal("flag-key")) + }) + + It("turns approval off for --yolo even when the file demands it", func() { + writeConfig("approval_mode: prompt\n") + + opts := optionsWithStreams(os.Stdin, os.Stdout, os.Stderr) + opts.Yolo = true + + cfg := resolve(agentOptions(dir, "a-model", opts)) + Expect(cfg.ApprovalMode).To(Equal("auto")) + }) + + // The other half of the same rule, and the reason an unset flag is + // not a demand for the empty string: an override only ever raises a + // field, so what the user configured survives a run that said + // nothing about it. + It("leaves what the file configured alone when no flag was given", func() { + writeConfig("api_key: saved-key\napproval_mode: prompt\n") + + cfg := resolve(agentOptions(dir, "a-model", optionsWithStreams(os.Stdin, os.Stdout, os.Stderr))) + Expect(cfg.APIKey).To(Equal("saved-key")) + Expect(cfg.ApprovalMode).To(Equal("prompt")) + }) + }) + }) + + // nib reports its own failures on the error stream and returns nothing but + // a status, so anything that reaches here as one has already been explained + // once. The refusal to open a full-screen session on a stdin that cannot be + // read is the one users meet: 'echo q | local-ai chat' names --cli, and a + // second message on top would bury the fix. + Describe("ExitStatus", func() { + It("recognises a status the agent already explained", func() { + code, reported := ExitStatus(app.ExitError{Code: 2}) + Expect(reported).To(BeTrue()) + Expect(code).To(Equal(2)) + }) + + It("finds one that has been wrapped", func() { + code, reported := ExitStatus(fmt.Errorf("running the agent: %w", app.ExitError{Code: 1})) + Expect(reported).To(BeTrue()) + Expect(code).To(Equal(1)) + }) + + It("leaves an ordinary failure to be reported", func() { + _, reported := ExitStatus(errors.New("no LocalAI server at http://127.0.0.1:8080")) + Expect(reported).To(BeFalse()) + }) + + It("says nothing about a run that succeeded", func() { + _, reported := ExitStatus(nil) + Expect(reported).To(BeFalse()) + }) + }) + + It("reports a state dir it cannot create", func() { + blocked := filepath.Join(dir, "a-file") + Expect(os.WriteFile(blocked, []byte("not a dir"), 0o600)).To(Succeed()) + + opts := optionsFor(nil) + opts.StateDir = filepath.Join(blocked, "chat") + _, err := prepare(context.Background(), opts, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("agent state dir")) + }) +}) diff --git a/core/cli/chat/server.go b/core/cli/chat/server.go new file mode 100644 index 000000000000..3d23d09c7a61 --- /dev/null +++ b/core/cli/chat/server.go @@ -0,0 +1,276 @@ +package chat + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/mudler/LocalAI/pkg/httpclient" +) + +// ErrDeclined means no server was started, either because the session is not +// interactive or because the user said no. +var ErrDeclined = errors.New("no server started") + +// errServerExited means the process we spawned died before it ever reported +// ready, so there is no point in polling out the rest of the budget. +var errServerExited = errors.New("the LocalAI server exited before it became ready") + +const ( + // defaultReadyTimeout bounds the wait for a freshly spawned server. A cold + // start probes hardware and may pull a backend, so the budget is generous. + defaultReadyTimeout = 2 * time.Minute + // readyPollInterval is how long to wait between readiness polls. + readyPollInterval = 500 * time.Millisecond + // readyProbeTimeout bounds a single readiness request, so one connection + // that hangs cannot swallow the whole budget. + readyProbeTimeout = 5 * time.Second + // shutdownGrace is how long a server we started gets to unload models and + // stop its backends after SIGINT before it is killed outright. + shutdownGrace = 10 * time.Second + // childOutputDrainDelay bounds how long cmd.Wait keeps copying the child's + // output after the child itself has exited. + // + // This is not a theoretical guard for LocalAI. 'local-ai run' spawns backend + // subprocesses, and they inherit the write end of the pipe exec created for + // the child's stderr. A backend that outlives its parent holds that pipe + // open, so an unbounded cmd.Wait would block on the copy goroutine long + // after the server itself is gone: exited would never close, Stop would burn + // its whole grace period even on a clean shutdown, and the waiter goroutine + // would leak. + // + // The value is long enough that a legitimate final burst of logs is never + // truncated even on a loaded machine, where the copy itself takes + // microseconds. It must stay strictly below shutdownGrace: at or above it, + // every wedged-pipe shutdown would exhaust the grace period and then SIGKILL + // a process that had already exited cleanly. + childOutputDrainDelay = 5 * time.Second +) + +// Confirmer asks a yes/no question. Nil means the session is not interactive. +type Confirmer func(question string) (bool, error) + +// StartOptions configures OfferToStart. +type StartOptions struct { + // Endpoint is the address the user expected a server on, used in the + // question and polled for readiness. This is the endpoint root, not the + // /v1 API base URL: readiness is served at the root. + Endpoint string + // Confirm asks whether to start a server. Nil means never start. + Confirm Confirmer + // Stderr receives the child's output. + Stderr io.Writer + // Executable overrides the binary to run. Empty means os.Executable(). + Executable string + // ReadyTimeout bounds the wait for readiness. Zero means defaultReadyTimeout. + ReadyTimeout time.Duration +} + +// StartedServer is a server this process started and is responsible for. +type StartedServer struct { + // exited is closed once the child has been reaped. One background waiter + // owns cmd.Wait: it may only be called once, and it is what closes the + // pipes exec created for Stdout/Stderr and joins the goroutines copying + // them, so calling os.Process.Wait directly instead would leak both. + exited chan struct{} + // waitErr is the child's exit status. It is written before exited is + // closed and must only be read after that channel is observed closed. + waitErr error + + // proc is the child. It is an interface rather than *os.Process so that + // Stop's contract, in particular that the child is asked to stop exactly + // once however often Stop is called, can be pinned without a live process + // to signal. Nil means nothing was ever started. + proc processControl + + stopOnce sync.Once +} + +// processControl is the part of *os.Process that Stop needs. +// +// One interface rather than a pair of independent function fields: two fields +// can be wired to each other's operation, or one left nil, and no test can tell, +// because a fake satisfies any combination. There is nothing to swap or forget +// here, since the sole implementation is the real process and the method names +// carry the meaning. +type processControl interface { + Signal(os.Signal) error + Kill() error +} + +// *os.Process satisfies processControl unmodified, so production needs no +// adapter and no nil branch: the wiring is a single assignment. +var _ processControl = (*os.Process)(nil) + +// newServerCommand builds the child process. Split out from OfferToStart so the +// process' configuration can be asserted on without spawning anything. +func newServerCommand(bin string, stderr io.Writer) *exec.Cmd { + cmd := exec.Command(bin, "run") + // Stdin is left nil, so the child gets /dev/null: it is a background + // server, and sharing the terminal would have it stealing keystrokes from + // the agent. + cmd.Stdout = stderr // the child's logs are diagnostics, not chat output + cmd.Stderr = stderr + // Bound the wait for the child's output pipes; see childOutputDrainDelay. + cmd.WaitDelay = childOutputDrainDelay + return cmd +} + +// OfferToStart asks whether to start a LocalAI server and, if allowed, spawns +// one and waits for it to report ready. +// +// A child process rather than an in-process boot: RunCMD.Run installs its own +// signal handling and blocks until shutdown, so re-entering it from a chat +// session would entangle two lifecycles in one process. +func OfferToStart(ctx context.Context, opts StartOptions) (*StartedServer, error) { + if opts.Confirm == nil { + // Not interactive. Spawning a server nobody asked for is the one thing + // this function must never do: in CI, in a pipeline, or under a + // supervisor there is no one to see it or shut it down. + return nil, ErrDeclined + } + ok, err := opts.Confirm(fmt.Sprintf("No LocalAI server at %s. Start one now?", opts.Endpoint)) + if err != nil { + return nil, fmt.Errorf("asking whether to start a server: %w", err) + } + if !ok { + return nil, ErrDeclined + } + + bin := opts.Executable + if bin == "" { + if bin, err = os.Executable(); err != nil { + return nil, fmt.Errorf("locating the local-ai binary: %w", err) + } + } + + cmd := newServerCommand(bin, opts.Stderr) + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("starting a LocalAI server with %s: %w", bin, err) + } + + s := &StartedServer{exited: make(chan struct{}), proc: cmd.Process} + go func() { + s.waitErr = cmd.Wait() + close(s.exited) + }() + + timeout := opts.ReadyTimeout + if timeout <= 0 { + timeout = defaultReadyTimeout + } + if err := waitReady(ctx, opts.Endpoint, timeout, s.exited); err != nil { + if errors.Is(err, errServerExited) { + // Safe to read: errServerExited is only returned once exited has + // been observed closed, which happens after waitErr is written. + err = describeExit(err, s.waitErr) + } + s.Stop() + return nil, fmt.Errorf("%w. Run 'local-ai run' in another terminal to see why it did not come up", err) + } + return s, nil +} + +// describeExit adds what is known about how the child died to exitErr, without +// putting os/exec's plumbing in front of the user. +// +// waitErr is exec.ErrWaitDelay when the child exited cleanly but something it +// spawned still held its output pipe open past childOutputDrainDelay. The +// sentinel's own text names the WaitDelay field, which is meaningless to a +// user, so it is translated. Nothing is swallowed: os/exec only substitutes +// ErrWaitDelay when the process itself exited without an error of its own (see +// Cmd.Wait, "Report an error from the copying goroutines only if the program +// otherwise exited normally"), so it can never stand in for an *ExitError. +func describeExit(exitErr, waitErr error) error { + switch { + case waitErr == nil: + return exitErr + case errors.Is(waitErr, exec.ErrWaitDelay): + return fmt.Errorf("%w, and left a subprocess of its own still running", exitErr) + default: + return fmt.Errorf("%w: %w", exitErr, waitErr) + } +} + +// Stop terminates the server this process started, giving it a chance to shut +// down cleanly first. It is safe to call on a nil or never-started server, and +// safe to call more than once. +func (s *StartedServer) Stop() { + if s == nil || s.proc == nil { + return + } + s.stopOnce.Do(func() { + // SIGINT rather than SIGKILL: local-ai run installs its own handler and + // needs it to unload models and stop backend subprocesses. Killing it + // outright would strand those children. + _ = s.proc.Signal(os.Interrupt) + + select { + case <-s.exited: + case <-time.After(shutdownGrace): + // It ignored the interrupt or wedged on the way down. The user is + // waiting on their shell prompt, so stop being polite. + _ = s.proc.Kill() + } + }) +} + +// waitReady polls the endpoint's /readyz until the server reports ready, the +// budget expires, the caller gives up, or exited signals that the process we +// are waiting on is gone. A nil exited channel means there is no process to +// watch. +// +// Readiness lives on the endpoint ROOT, not under the /v1 API base URL, and it +// answers 503 for as long as startup is still in progress. +func waitReady(ctx context.Context, endpoint string, timeout time.Duration, exited <-chan struct{}) error { + url := strings.TrimSuffix(endpoint, "/") + "/readyz" + + // A real deadline rather than context.WithCancel plus a timer: the latter + // expires as context.Canceled, which every classifier here reads as "the + // caller gave up" rather than "the endpoint never answered". + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + client := httpclient.NewWithTimeout(readyProbeTimeout) + ticker := time.NewTicker(readyPollInterval) + defer ticker.Stop() + + for { + select { + case <-exited: + return errServerExited + case <-waitCtx.Done(): + // Distinguish our budget from the caller's: only ours is advice + // about the server. + if err := ctx.Err(); err != nil { + return err + } + return fmt.Errorf("the LocalAI server did not become ready within %s", timeout) + case <-ticker.C: + } + + req, err := http.NewRequestWithContext(waitCtx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("building the readiness request for %s: %w", url, err) + } + resp, err := client.Do(req) + if err != nil { + continue // nothing listening yet + } + // Drain before closing so the next poll can reuse the connection + // instead of opening a socket every 500ms for two minutes. + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + // Anything else means startup is still in progress; keep polling. + } +} diff --git a/core/cli/chat/server_test.go b/core/cli/chat/server_test.go new file mode 100644 index 000000000000..652c1bdc179e --- /dev/null +++ b/core/cli/chat/server_test.go @@ -0,0 +1,375 @@ +package chat + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// unusedPort is a loopback address nothing listens on, used wherever a spec +// needs a readiness poll to keep failing. Port 1 is privileged, so no test +// process could have bound it. +const unusedPort = "http://127.0.0.1:1" + +var _ = Describe("OfferToStart", func() { + It("never spawns anything when there is no confirmer", func() { + started, err := OfferToStart(context.Background(), StartOptions{ + Endpoint: "http://127.0.0.1:59999", + Confirm: nil, + Stderr: io.Discard, + Executable: "/nonexistent/binary-that-must-not-run", + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrDeclined)).To(BeTrue(), "want ErrDeclined, got %v", err) + Expect(started).To(BeNil()) + }) + + It("does not spawn when the user declines", func() { + asked := false + started, err := OfferToStart(context.Background(), StartOptions{ + Endpoint: "http://127.0.0.1:59999", + Confirm: func(string) (bool, error) { + asked = true + return false, nil + }, + Stderr: io.Discard, + Executable: "/nonexistent/binary-that-must-not-run", + }) + Expect(asked).To(BeTrue(), "the user should have been asked") + Expect(errors.Is(err, ErrDeclined)).To(BeTrue()) + Expect(started).To(BeNil()) + }) + + It("names the endpoint in the question", func() { + var question string + _, _ = OfferToStart(context.Background(), StartOptions{ + Endpoint: "http://example.invalid:9090", + Confirm: func(q string) (bool, error) { + question = q + return false, nil + }, + Stderr: io.Discard, + Executable: "/nonexistent/binary-that-must-not-run", + }) + Expect(question).To(ContainSubstring("http://example.invalid:9090")) + }) + + It("propagates a confirmer error", func() { + boom := errors.New("boom") + _, err := OfferToStart(context.Background(), StartOptions{ + Endpoint: "http://127.0.0.1:59999", + Confirm: func(string) (bool, error) { return false, boom }, + Stderr: io.Discard, + Executable: "/nonexistent/binary-that-must-not-run", + }) + Expect(errors.Is(err, boom)).To(BeTrue()) + }) + + It("reports which binary it failed to launch", func() { + started, err := OfferToStart(context.Background(), StartOptions{ + Endpoint: "http://127.0.0.1:59999", + Confirm: func(string) (bool, error) { return true, nil }, + Stderr: io.Discard, + Executable: "/nonexistent/binary-that-must-not-run", + }) + Expect(started).To(BeNil()) + Expect(err).To(MatchError(ContainSubstring("starting a LocalAI server"))) + Expect(err).To(MatchError(ContainSubstring("/nonexistent/binary-that-must-not-run"))) + }) + + It("stops waiting as soon as the process it started exits", func() { + // A harmless no-op binary rather than a real server: this exercises the + // early-exit path without starting LocalAI, binding a port, or running + // 'local-ai run'. Without early-exit detection the call would sit here + // polling until ReadyTimeout. + bin, lookErr := exec.LookPath("true") + if lookErr != nil { + Skip("no 'true' binary on PATH to stand in for a server that dies at once") + } + + start := time.Now() + started, err := OfferToStart(context.Background(), StartOptions{ + Endpoint: unusedPort, + Confirm: func(string) (bool, error) { return true, nil }, + Stderr: io.Discard, + Executable: bin, + ReadyTimeout: 30 * time.Second, + }) + Expect(started).To(BeNil()) + Expect(err).To(MatchError(ContainSubstring("exited before it became ready"))) + Expect(time.Since(start)).To(BeNumerically("<", 10*time.Second), + "the wait should end with the process, not with the readiness budget") + }) + + It("gives up on a child whose grandchildren still hold its output pipe", func() { + // The real LocalAI shape: 'local-ai run' exits but a backend + // subprocess it spawned inherited the stderr pipe and keeps it open. + // Without cmd.WaitDelay, cmd.Wait blocks on the copy goroutine, exited + // never closes, and the readiness wait runs out the full budget instead + // of reporting that the server died. + sh, lookErr := exec.LookPath("sh") + if lookErr != nil { + Skip("no 'sh' binary on PATH to stand in for a server with a lingering child") + } + + dir := GinkgoT().TempDir() + pidFile := filepath.Join(dir, "grandchild.pid") + script := filepath.Join(dir, "server-with-lingering-child") + // #nosec G306 -- this has to be executable to stand in for a binary. + Expect(os.WriteFile(script, + []byte("#!"+sh+"\nsleep 30 &\necho $! > "+pidFile+"\nexit 0\n"), + 0o700)).To(Succeed()) + + // Reap the grandchild whatever happens: it outlives its own parent by + // design, so nothing else will clean it up. + DeferCleanup(func() { + raw, err := os.ReadFile(pidFile) + if err != nil { + return + } + pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) + if err != nil { + return + } + proc, err := os.FindProcess(pid) + if err != nil { + return + } + _ = proc.Kill() + _, _ = proc.Wait() + }) + + start := time.Now() + started, err := OfferToStart(context.Background(), StartOptions{ + Endpoint: unusedPort, + Confirm: func(string) (bool, error) { return true, nil }, + Stderr: io.Discard, + Executable: script, + ReadyTimeout: 25 * time.Second, + }) + elapsed := time.Since(start) + + Expect(started).To(BeNil()) + Expect(err).To(MatchError(ContainSubstring("exited before it became ready")), + "an unbounded cmd.Wait would report a readiness timeout instead") + Expect(elapsed).To(BeNumerically("<", 20*time.Second), + "the wait must be bounded by the output drain, not by the readiness budget") + + // This is the case where cmd.Wait returns exec.ErrWaitDelay, whose own + // text names a struct field of os/exec. Users get told what happened + // instead. + Expect(err).NotTo(MatchError(ContainSubstring("WaitDelay")), + "os/exec plumbing must not reach the user") + Expect(err).NotTo(MatchError(ContainSubstring("exec:"))) + Expect(err).To(MatchError(ContainSubstring("left a subprocess of its own still running"))) + }) + + It("reports the exit status of a server that failed outright", func() { + // The counterpart to the case above: translating ErrWaitDelay must not + // cost a real exit status, which is the one diagnostic worth having. + bin, lookErr := exec.LookPath("false") + if lookErr != nil { + Skip("no 'false' binary on PATH to stand in for a server that fails") + } + + _, err := OfferToStart(context.Background(), StartOptions{ + Endpoint: unusedPort, + Confirm: func(string) (bool, error) { return true, nil }, + Stderr: io.Discard, + Executable: bin, + ReadyTimeout: 30 * time.Second, + }) + Expect(err).To(MatchError(ContainSubstring("exited before it became ready"))) + Expect(err).To(MatchError(ContainSubstring("exit status 1"))) + }) +}) + +var _ = Describe("StartedServer.Stop", func() { + It("is a no-op on a server that was never started", func() { + var nilServer *StartedServer + Expect(nilServer.Stop).NotTo(Panic()) + Expect((&StartedServer{}).Stop).NotTo(Panic()) + }) + + It("interrupts the child exactly once however often it is called", func() { + s, proc := stoppableServer() + + s.Stop() + s.Stop() + s.Stop() + + Expect(proc.interrupts.Load()).To(Equal(int32(1)), + "a second Stop must not signal the child again") + Expect(proc.kills.Load()).To(BeZero(), "a child that already exited must not be killed") + }) + + It("interrupts the child exactly once when called concurrently", func() { + // The realistic double-Stop: a deferred Stop on the way out racing the + // signal handler that also owns shutting the server down. + const callers = 8 + + s, proc := stoppableServer() + + var wg sync.WaitGroup + wg.Add(callers) + for range callers { + go func() { + defer GinkgoRecover() + defer wg.Done() + s.Stop() + }() + } + wg.Wait() + + Expect(proc.interrupts.Load()).To(Equal(int32(1))) + Expect(proc.kills.Load()).To(BeZero()) + }) + + It("asks the child to interrupt rather than killing it outright", func() { + // The escalation order is the whole point of the grace period: SIGKILL + // first would strand the backend subprocesses local-ai run owns. + s, proc := stoppableServer() + + s.Stop() + + Expect(proc.lastSignal.Load()).To(Equal(os.Interrupt)) + Expect(proc.kills.Load()).To(BeZero()) + }) +}) + +// countingProcess stands in for the *os.Process that Stop drives, recording +// what it was asked to do. +type countingProcess struct { + interrupts atomic.Int32 + kills atomic.Int32 + lastSignal atomic.Value +} + +func (p *countingProcess) Signal(sig os.Signal) error { + p.interrupts.Add(1) + p.lastSignal.Store(sig) + return nil +} + +func (p *countingProcess) Kill() error { + p.kills.Add(1) + return nil +} + +// stoppableServer builds a StartedServer whose child has already exited, driven +// by a countingProcess rather than a real one. Nothing is spawned. +func stoppableServer() (*StartedServer, *countingProcess) { + proc := &countingProcess{} + exited := make(chan struct{}) + close(exited) + return &StartedServer{exited: exited, proc: proc}, proc +} + +var _ = Describe("newServerCommand", func() { + It("bounds how long it will wait for the child's output pipes", func() { + cmd := newServerCommand("/nonexistent/binary-that-must-not-run", io.Discard) + + // An unbounded wait is the failure mode: backend subprocesses inherit + // the child's stderr pipe and can hold it open long after the server + // itself is gone. + Expect(cmd.WaitDelay).To(BeNumerically(">", 0), "cmd.Wait must not be unbounded") + Expect(cmd.WaitDelay).To(BeNumerically("<", shutdownGrace), + "a drain longer than the shutdown grace would kill a cleanly exited server") + }) + + It("runs the server subcommand without giving it the terminal", func() { + cmd := newServerCommand("/nonexistent/binary-that-must-not-run", io.Discard) + + Expect(cmd.Args).To(Equal([]string{"/nonexistent/binary-that-must-not-run", "run"})) + Expect(cmd.Stdin).To(BeNil(), "the child must not compete with the agent for stdin") + Expect(cmd.Stdout).NotTo(BeNil()) + Expect(cmd.Stderr).NotTo(BeNil()) + }) +}) + +var _ = Describe("waitReady", func() { + It("polls /readyz on the endpoint root and returns only once it answers 200", func() { + // readyOnPoll is deliberately above 1. A handler that answers 200 to the + // first poll cannot tell a correct implementation apart from one that + // treats 503 as ready, because both return after a single request; the + // poll count is what makes 503-as-ready observable. + const readyOnPoll = 3 + + var polls atomic.Int32 + var paths atomic.Value + paths.Store("") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths.Store(r.URL.Path) + if polls.Add(1) < readyOnPoll { + // What LocalAI answers while startup is still in progress. + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + Expect(waitReady(context.Background(), srv.URL, 20*time.Second, nil)).To(Succeed()) + Expect(paths.Load()).To(Equal("/readyz"), "readiness lives on the endpoint root, not under /v1") + Expect(polls.Load()).To(BeNumerically(">=", readyOnPoll), + "503 means startup is still in progress and must never be accepted as ready") + }) + + It("tolerates a trailing slash on the endpoint", func() { + var path atomic.Value + path.Store("") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path.Store(r.URL.Path) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + Expect(waitReady(context.Background(), srv.URL+"/", 20*time.Second, nil)).To(Succeed()) + Expect(path.Load()).To(Equal("/readyz")) + }) + + It("reports a timeout, not a cancellation, when the budget runs out", func() { + err := waitReady(context.Background(), unusedPort, 1200*time.Millisecond, nil) + Expect(err).To(HaveOccurred()) + // A budget built from context.WithCancel plus a timer would surface as + // context.Canceled, which downstream code reads as "the caller gave up" + // and would stop classifying a hung server as unreachable. + Expect(errors.Is(err, context.Canceled)).To(BeFalse(), "got %v", err) + Expect(err).To(MatchError(ContainSubstring("did not become ready"))) + }) + + It("returns the caller's cancellation when the caller gives up", func() { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + defer GinkgoRecover() + time.Sleep(200 * time.Millisecond) + cancel() + }() + defer cancel() + + err := waitReady(ctx, unusedPort, time.Minute, nil) + Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "got %v", err) + }) + + It("gives up when the process it is waiting on has exited", func() { + exited := make(chan struct{}) + close(exited) + + err := waitReady(context.Background(), unusedPort, time.Minute, exited) + Expect(err).To(MatchError(ContainSubstring("exited before it became ready"))) + }) +}) diff --git a/core/cli/chat/session.go b/core/cli/chat/session.go deleted file mode 100644 index 651d055326bb..000000000000 --- a/core/cli/chat/session.go +++ /dev/null @@ -1,112 +0,0 @@ -package chat - -import ( - "context" - "errors" - "fmt" - "io" - "slices" - "strings" -) - -const ( - chatRoleUser = "user" - chatRoleAssistant = "assistant" -) - -type chatMessage struct { - Role string - Content string -} - -type chatSession struct { - client chatClient - model string - models []string - messages []chatMessage -} - -func newChatSession(ctx context.Context, client chatClient, requestedModel string) (*chatSession, error) { - models, err := client.ListModels(ctx) - if err != nil { - return nil, fmt.Errorf("list models: %w", err) - } - - model, err := resolveChatModel(requestedModel, models) - if err != nil { - return nil, err - } - - return &chatSession{ - client: client, - model: model, - models: models, - }, nil -} - -func (s *chatSession) CurrentModel() string { - return s.model -} - -func (s *chatSession) Models() []string { - models := make([]string, len(s.models)) - copy(models, s.models) - return models -} - -func (s *chatSession) Clear() { - s.messages = nil -} - -func (s *chatSession) SwitchModel(model string) error { - if !slices.Contains(s.models, model) { - return fmt.Errorf("model %q is not available. Use /models to see installed models", model) - } - s.model = model - s.Clear() - return nil -} - -func (s *chatSession) Send(ctx context.Context, prompt string, out io.Writer) error { - s.messages = append(s.messages, chatMessage{ - Role: chatRoleUser, - Content: prompt, - }) - - answer, err := s.client.StreamChat(ctx, s.model, s.messages, out) - if err != nil { - return err - } - - s.messages = append(s.messages, chatMessage{ - Role: chatRoleAssistant, - Content: answer, - }) - return nil -} - -func resolveChatModel(requested string, models []string) (string, error) { - switch { - case requested == "" && len(models) == 0: - return "", errors.New(`no chat models are installed. - -Install a model first, for example: - local-ai models list - local-ai models install - local-ai run - -Then start a chat session: - local-ai chat --model `) - case requested == "" && len(models) == 1: - return models[0], nil - case requested == "" && len(models) > 1: - var b strings.Builder - b.WriteString("multiple models are available; choose one with --model:\n") - b.WriteString(formatChatModelList(models, "")) - return "", errors.New(b.String()) - case !slices.Contains(models, requested): - return "", fmt.Errorf("model %q is not available. Use `local-ai models list` and `local-ai models install `, or pass an installed model with --model", requested) - default: - return requested, nil - } -} diff --git a/core/cli/chat/session_test.go b/core/cli/chat/session_test.go deleted file mode 100644 index dcf274805e3d..000000000000 --- a/core/cli/chat/session_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package chat - -import ( - "context" - "io" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Chat session", func() { - It("keeps model switching and message history out of the terminal adapter", func() { - client := &fakeChatClient{ - models: []string{"alpha", "beta"}, - answer: "pong", - } - - session, err := newChatSession(context.Background(), client, "alpha") - Expect(err).ToNot(HaveOccurred()) - Expect(session.CurrentModel()).To(Equal("alpha")) - - Expect(session.SwitchModel("beta")).To(Succeed()) - Expect(session.CurrentModel()).To(Equal("beta")) - Expect(session.Send(context.Background(), "ping", io.Discard)).To(Succeed()) - - Expect(client.requests).To(HaveLen(1)) - Expect(client.requests[0].model).To(Equal("beta")) - Expect(client.requests[0].messages).To(HaveLen(1)) - Expect(client.requests[0].messages[0].Content).To(Equal("ping")) - }) -}) - -type fakeChatClient struct { - models []string - answer string - requests []fakeChatRequest -} - -type fakeChatRequest struct { - model string - messages []chatMessage -} - -func (c *fakeChatClient) ListModels(context.Context) ([]string, error) { - return c.models, nil -} - -func (c *fakeChatClient) StreamChat(_ context.Context, model string, messages []chatMessage, out io.Writer) (string, error) { - copied := make([]chatMessage, len(messages)) - copy(copied, messages) - c.requests = append(c.requests, fakeChatRequest{model: model, messages: copied}) - if _, err := io.WriteString(out, c.answer); err != nil { - return "", err - } - return c.answer, nil -} diff --git a/core/cli/chat/terminal.go b/core/cli/chat/terminal.go deleted file mode 100644 index 8d76e1e6ff5a..000000000000 --- a/core/cli/chat/terminal.go +++ /dev/null @@ -1,93 +0,0 @@ -package chat - -import ( - "bufio" - "context" - "fmt" - "io" - "strings" -) - -func runTerminalChat(ctx context.Context, session *chatSession, in io.Reader, out io.Writer) error { - scanner := bufio.NewScanner(in) - scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) - - if err := writeChat(out, "LocalAI chat (%s)\n", session.CurrentModel()); err != nil { - return err - } - if err := writeChat(out, "Type /exit to quit, /clear to reset the conversation, /models to list models.\n"); err != nil { - return err - } - - for { - if err := writeChat(out, "\n> "); err != nil { - return err - } - if !scanner.Scan() { - break - } - - prompt := strings.TrimSpace(scanner.Text()) - switch prompt { - case "": - continue - case "/bye", "/exit", "/quit": - return writeChat(out, "bye\n") - case "/clear": - session.Clear() - if err := writeChat(out, "conversation cleared\n"); err != nil { - return err - } - continue - case "/models": - if err := printChatModels(out, session.Models(), session.CurrentModel()); err != nil { - return err - } - continue - } - - if nextModel, ok := strings.CutPrefix(prompt, "/model "); ok { - nextModel = strings.TrimSpace(nextModel) - if nextModel == "" { - if err := writeChat(out, "usage: /model \n"); err != nil { - return err - } - continue - } - if err := session.SwitchModel(nextModel); err != nil { - if writeErr := writeChat(out, "%s\n", err); writeErr != nil { - return writeErr - } - continue - } - if err := writeChat(out, "switched to %s; conversation cleared\n", session.CurrentModel()); err != nil { - return err - } - continue - } - - if err := writeChat(out, "assistant: "); err != nil { - return err - } - if err := session.Send(ctx, prompt, out); err != nil { - return err - } - if err := writeChat(out, "\n"); err != nil { - return err - } - } - - return scanner.Err() -} - -func printChatModels(out io.Writer, models []string, current string) error { - if len(models) == 0 { - return writeChat(out, "no models installed\n") - } - return writeChat(out, "%s", formatChatModelList(models, current)) -} - -func writeChat(out io.Writer, format string, args ...any) error { - _, err := fmt.Fprintf(out, format, args...) - return err -} diff --git a/core/cli/chat_cmd.go b/core/cli/chat_cmd.go index 65228ff1f511..8c2864a959d1 100644 --- a/core/cli/chat_cmd.go +++ b/core/cli/chat_cmd.go @@ -8,18 +8,72 @@ import ( cliContext "github.com/mudler/LocalAI/core/cli/context" ) +// ChatCMD runs the built-in terminal agent. Everything after the first +// positional argument is forwarded to the agent verbatim, so its own +// subcommands (plugin, skill, mcp) and their flags work unchanged. LocalAI's +// own flags must therefore come first. type ChatCMD struct { - Model string `short:"m" help:"Model name to use. Defaults to the only model returned by the server when exactly one is available"` - Endpoint string `env:"LOCALAI_CHAT_ENDPOINT" default:"http://127.0.0.1:8080" help:"LocalAI server endpoint. The /v1 path is added automatically when omitted"` - APIKey string `env:"LOCALAI_API_KEY,API_KEY" help:"API key to use when the LocalAI server requires authentication"` + Model string `short:"m" help:"Model to use. Defaults to the only model the server offers, or asks when there are several"` + Endpoint string `env:"LOCALAI_CHAT_ENDPOINT" default:"http://127.0.0.1:8080" help:"LocalAI server endpoint. The /v1 path is added automatically when omitted"` + APIKey string `env:"LOCALAI_API_KEY,API_KEY" help:"API key to use when the LocalAI server requires authentication"` + ConfigDir string `env:"LOCALAI_CHAT_CONFIG_DIR" help:"Directory holding the agent's config, plugins, and skills. Defaults to ~/.config/localai/chat" type:"path"` + TraceDir string `env:"LOCALAI_CHAT_TRACE_DIR" help:"Write a session LLM trace (NDJSON) to this directory" type:"path"` + + CLI bool `help:"Run in plain CLI mode instead of the full-screen interface"` + TUI bool `help:"Force the full-screen interface"` + Height string `help:"Run as an inline drop-down of this height, e.g. '40%'"` + Tmux bool `help:"Run in a tmux split"` + NoTmux bool `name:"no-tmux" help:"Never use a tmux split, even inside tmux"` + Init string `help:"Print the shell integration script for Ctrl+Space (zsh, bash, or fish)"` + Yolo bool `env:"LOCALAI_CHAT_YOLO" help:"Auto-approve every tool call without prompting"` + + Args []string `arg:"" optional:"" passthrough:"" help:"Arguments forwarded to the agent, e.g. 'plugin install ', 'skill list', 'mcp add'"` } func (c *ChatCMD) Run(ctx *cliContext.Context) error { - return chatcli.Run(context.Background(), chatcli.Options{ - Model: c.Model, - BaseURL: chatAPIBaseURL(c.Endpoint), - APIKey: c.APIKey, - In: os.Stdin, - Out: os.Stdout, + err := chatcli.Run(context.Background(), chatcli.Options{ + Args: c.agentArgs(), + Endpoint: c.Endpoint, + BaseURL: chatAPIBaseURL(c.Endpoint), + APIKey: c.APIKey, + Model: c.Model, + StateDir: c.ConfigDir, + TraceDir: c.TraceDir, + Yolo: c.Yolo, + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, }) + // The agent explains its own failures on stderr and hands back a code, so + // carry the code out and leave the explanation to stand alone. + if code, reported := chatcli.ExitStatus(err); reported { + return ExitCodeError{Code: code} + } + return err +} + +// agentArgs rebuilds the argument vector the agent expects: LocalAI's mode +// flags are declared here for discoverability and shell completion, so they +// have to be translated back into the agent's own flag names. +func (c *ChatCMD) agentArgs() []string { + var args []string + if c.CLI { + args = append(args, "--cli") + } + if c.TUI { + args = append(args, "--tui") + } + if c.Height != "" { + args = append(args, "--height", c.Height) + } + if c.Tmux { + args = append(args, "--tmux") + } + if c.NoTmux { + args = append(args, "--no-tmux") + } + if c.Init != "" { + args = append(args, "--init", c.Init) + } + return append(args, c.Args...) } diff --git a/core/cli/chat_cmd_test.go b/core/cli/chat_cmd_test.go index 55ce5014b97e..823a5ee61bc9 100644 --- a/core/cli/chat_cmd_test.go +++ b/core/cli/chat_cmd_test.go @@ -1,6 +1,10 @@ package cli import ( + "errors" + "fmt" + + "github.com/alecthomas/kong" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -24,4 +28,70 @@ var _ = Describe("Chat command wiring", func() { Expect(chatAPIBaseURL("http://127.0.0.1:8080/localai")).To(Equal("http://127.0.0.1:8080/localai/v1")) }) }) + + Describe("argument parsing", func() { + parse := func(args ...string) *ChatCMD { + var cli struct { + Chat ChatCMD `cmd:""` + } + parser, err := kong.New(&cli) + Expect(err).ToNot(HaveOccurred()) + _, err = parser.Parse(append([]string{"chat"}, args...)) + Expect(err).ToNot(HaveOccurred()) + return &cli.Chat + } + + It("leaves Args empty for a bare invocation", func() { + Expect(parse().Args).To(BeEmpty()) + }) + + It("binds flags that precede the forwarded arguments", func() { + c := parse("--endpoint", "http://host:9090", "--model", "m", "plugin", "list") + Expect(c.Endpoint).To(Equal("http://host:9090")) + Expect(c.Model).To(Equal("m")) + Expect(c.Args).To(Equal([]string{"plugin", "list"})) + }) + + It("forwards flags that follow the first positional to the agent", func() { + c := parse("plugin", "install", "https://example.invalid/p", "--yes") + Expect(c.Args).To(Equal([]string{"plugin", "install", "https://example.invalid/p", "--yes"})) + }) + + It("parses its own mode flags", func() { + c := parse("--cli") + Expect(c.CLI).To(BeTrue()) + Expect(c.Args).To(BeEmpty()) + }) + }) + + // The agent prints its own diagnosis and hands back a status. main exits + // with that status and prints nothing more, so the user reads one message + // rather than an "exit status 1" stacked under it. + Describe("ExitCodeError", func() { + It("carries the status out", func() { + Expect(ExitCodeError{Code: 2}.Code).To(Equal(2)) + }) + + It("is recognisable after wrapping", func() { + var got ExitCodeError + Expect(errors.As(fmt.Errorf("chat: %w", ExitCodeError{Code: 2}), &got)).To(BeTrue()) + Expect(got.Code).To(Equal(2)) + }) + }) + + Describe("agentArgs", func() { + It("translates mode flags into the agent's own flags", func() { + c := &ChatCMD{CLI: true} + Expect(c.agentArgs()).To(Equal([]string{"--cli"})) + }) + + It("puts forwarded arguments after the translated flags", func() { + c := &ChatCMD{Height: "40%", Args: []string{"plugin", "list"}} + Expect(c.agentArgs()).To(Equal([]string{"--height", "40%", "plugin", "list"})) + }) + + It("returns nothing for a bare invocation", func() { + Expect((&ChatCMD{}).agentArgs()).To(BeEmpty()) + }) + }) }) diff --git a/core/cli/cli.go b/core/cli/cli.go index 8bf4b207a673..77bf128ccce2 100644 --- a/core/cli/cli.go +++ b/core/cli/cli.go @@ -9,7 +9,7 @@ var CLI struct { cliContext.Context `embed:""` Run RunCMD `cmd:"" help:"Run LocalAI, this the default command if no other command is specified. Run 'local-ai run --help' for more information" default:"withargs"` - Chat ChatCMD `cmd:"" help:"Open an interactive chat session against a running LocalAI server"` + Chat ChatCMD `cmd:"" help:"Run the built-in terminal agent against a LocalAI server"` Federated FederatedCLI `cmd:"" help:"Run LocalAI in federated mode"` Models ModelsCMD `cmd:"" help:"Manage LocalAI models and definitions"` Backends BackendsCMD `cmd:"" help:"Manage LocalAI backends and definitions"` diff --git a/core/cli/exit.go b/core/cli/exit.go new file mode 100644 index 000000000000..453807d12624 --- /dev/null +++ b/core/cli/exit.go @@ -0,0 +1,15 @@ +package cli + +import "fmt" + +// ExitCodeError is a failure a command has already reported to the user. It +// carries nothing but the status the process should exit with, and main prints +// nothing more for it. +// +// It exists for commands that hand their terminal to something that does its +// own error reporting. Returning that subordinate's error instead would put a +// bare "exit status 1" underneath the explanation the user has just read, and +// returning nil would tell a script the run succeeded. +type ExitCodeError struct{ Code int } + +func (e ExitCodeError) Error() string { return fmt.Sprintf("exit status %d", e.Code) } diff --git a/docs/content/features/_index.en.md b/docs/content/features/_index.en.md index 46397b4d37b8..37a99ee41813 100644 --- a/docs/content/features/_index.en.md +++ b/docs/content/features/_index.en.md @@ -24,6 +24,7 @@ LocalAI provides a comprehensive set of features for running AI models locally. - **[Agent Actions]({{% relref "features/agent-actions" %}})** - The catalog of actions an agent can run. - **[Model Context Protocol (MCP)]({{% relref "features/mcp" %}})** - Give a model external tools over MCP. - **[LocalAI Assistant]({{% relref "features/localai-assistant" %}})** - Chat to administer your LocalAI instance. +- **[Terminal agent]({{% relref "features/terminal-agent" %}})** - `local-ai chat`, an agent in your shell that runs commands behind an approval gate. ## Audio diff --git a/docs/content/features/agents.md b/docs/content/features/agents.md index ef9d51211946..f406d0d15d8a 100644 --- a/docs/content/features/agents.md +++ b/docs/content/features/agents.md @@ -11,13 +11,7 @@ LocalAI includes a built-in agent platform powered by [LocalAGI](https://github. LocalAGI is embedded in LocalAI. There is nothing separate to install or run. -{{% notice info %}} -**Looking for something else?** LocalAI has three related agentic features that are easy to confuse: - -- **Agents** (this page): autonomous agents you build that reason, use tools, and act on their own. Start here if you want to create an agent. -- **LocalAI Assistant** ({{% relref "features/localai-assistant" %}}): an admin chat modality for administering LocalAI itself (install models, manage backends) by chatting. -- **MCP** ({{% relref "features/mcp" %}}): a way to give a model external tools through the Model Context Protocol. -{{% /notice %}} +{{< agentic-routing current="agents" >}} {{% notice tip %}} New to agents? The [Build your first agent]({{% relref "getting-started/first-agent" %}}) walkthrough takes you from an empty Agents page to an agent that answers a message and uses one tool. diff --git a/docs/content/features/localai-assistant.md b/docs/content/features/localai-assistant.md index ebd3559bd31f..e4886c434376 100644 --- a/docs/content/features/localai-assistant.md +++ b/docs/content/features/localai-assistant.md @@ -5,13 +5,7 @@ weight = 23 url = '/features/localai-assistant' +++ -{{% notice info %}} -**Looking for something else?** LocalAI has three related agentic features that are easy to confuse: - -- **LocalAI Assistant** (this page): an admin chat modality for administering LocalAI itself (install models, manage backends) by chatting. -- **Agents** ({{% relref "features/agents" %}}): autonomous agents you build that reason, use tools, and act on their own. -- **MCP** ({{% relref "features/mcp" %}}): a way to give a model external tools through the Model Context Protocol. -{{% /notice %}} +{{< agentic-routing current="localai-assistant" >}} LocalAI Assistant is an admin-only chat modality. When enabled on a chat session, the conversation is wired to an in-process MCP server that exposes LocalAI's own admin/management surface as tools. You can install models, manage backends, edit model configs and check system status by chatting, with no REST calls or YAML edits. diff --git a/docs/content/features/mcp.md b/docs/content/features/mcp.md index d208524f90d7..e27316cac69c 100644 --- a/docs/content/features/mcp.md +++ b/docs/content/features/mcp.md @@ -12,13 +12,7 @@ categories = ["Features"] LocalAI now supports the **Model Context Protocol (MCP)**, enabling powerful agentic capabilities by connecting AI models to external tools and services. This feature allows your LocalAI models to interact with various MCP servers, providing access to real-time data, APIs, and specialized tools. -{{% notice info %}} -**Looking for something else?** LocalAI has three related agentic features that are easy to confuse: - -- **MCP** (this page): a way to give a model external tools through the Model Context Protocol. -- **Agents** ({{% relref "features/agents" %}}): autonomous agents you build that reason, use tools, and act on their own. -- **LocalAI Assistant** ({{% relref "features/localai-assistant" %}}): an admin chat modality for administering LocalAI itself (install models, manage backends) by chatting. -{{% /notice %}} +{{< agentic-routing current="mcp" >}} ## What is MCP? diff --git a/docs/content/features/terminal-agent.md b/docs/content/features/terminal-agent.md new file mode 100644 index 000000000000..71f5f4c4e7e9 --- /dev/null +++ b/docs/content/features/terminal-agent.md @@ -0,0 +1,152 @@ ++++ +disableToc = false +title = "Terminal agent" +weight = 24 +url = '/features/terminal-agent' ++++ + +{{< agentic-routing current="terminal-agent" >}} + +`local-ai chat` opens a terminal agent that runs against your LocalAI server. It is not a +separate tool: the agent is compiled into the `local-ai` binary, so if you have LocalAI you +already have it. + +It works in the shell you already have open. It reads your files, runs commands on your +machine, delegates to sub-agents, and loads MCP servers, plugins and skills. Read-only work +happens on its own; anything that can change something asks you first. + +The agent is [nib](https://github.com/mudler/nib) embedded in LocalAI, which is why its +plugin, skill and MCP ecosystem works here unchanged. + +{{% notice warning %}} +**Breaking change.** `local-ai chat` used to be a plain chat prompt. It is now an agent that +executes shell commands on the machine it runs on, behind an approval gate you answer. The +old `/clear` command is gone, and `/compact` is the nearest equivalent. +{{% /notice %}} + +## Start a session + +```bash +# Terminal 1 +local-ai run + +# Terminal 2 +local-ai chat +``` + +That opens the full-screen interface. Pass `--cli` for a plain, pipe-friendly session +instead. + +If nothing is listening, an interactive session offers to start a server for you and stops it +again when you exit. Anywhere else, for instance in a script, it fails and points you at +`local-ai run`. + +### How the model is chosen + +The agent picks the model in this order, and stops at the first one that answers: + +1. `--model`, if you passed it. +2. The model saved in the agent's config. +3. The only model the server offers, when there is exactly one. +4. An interactive picker, when there are several and you are at a terminal. Your choice is + saved, so the question is asked once. +5. Otherwise it fails, listing the models the server does offer. + +## Summon it with Ctrl+Space + +`local-ai chat --init` prints a shell widget that binds `Ctrl+Space` to the agent, so you can +call it from a half-typed command line without losing what you were doing: + +```bash +# zsh +echo 'eval "$(local-ai chat --init zsh)"' >> ~/.zshrc +# bash +echo 'eval "$(local-ai chat --init bash)"' >> ~/.bashrc +# fish +echo 'local-ai chat --init fish | source' >> ~/.config/fish/config.fish +``` + +## Inside a session + +- `/models` lists the models the server offers, marking the one you are using. +- `/model ` switches model and keeps the conversation you are in. +- `/compact` summarises the history so far to free up context. + +## Tool approval + +Read-only tools run without asking. That covers reads and searches, and read-only shell +commands such as `ls` and `cat`, so the agent can look around and answer you without +interrupting. Everything else prompts for approval, and you can approve, deny, or trust the +tool for the rest of the session. + +nib's README keeps the +[exact list of what counts as read-only](https://github.com/mudler/nib#what-a-piped-run-may-and-may-not-do), +along with the [approval modes](https://github.com/mudler/nib#tool-approval) you can set in +the agent's own config. + +`--yolo`, or `LOCALAI_CHAT_YOLO=1`, approves every tool call without asking. It is the right +setting for a sandbox and the wrong one for your laptop. + +## Piping and scripting + +`--cli` makes the agent answer a piped question and exit: + +```bash +echo "what is 2+2" | local-ai chat --cli +``` + +That exits `0`. Read-only tools still run, so a piped question can inspect a repository or a +log file to answer you. + +A tool call that is not read-only is a different matter: there is nobody at the keyboard to +approve it, so the call is denied and the session exits `3`. The code is deliberately not `1`, +so a script can tell "I refused to act" apart from "I crashed". + +Pipe a question in without `--cli` and the agent refuses, with a message naming `--cli`. It +will not render a full-screen interface into a pipe that cannot show it. + +Redirecting the output is not the same thing and is not refused: `local-ai chat > out.txt` +from a terminal draws the interface on `/dev/tty` and writes only the command you pick with +`Ctrl+Y` to the file. That is the same capture the `Ctrl+Space` widget is built on, so both +work. It is the stdin that has to be a terminal. + +## Where the agent keeps its state + +Config, plugins and skills live in `~/.config/localai/chat/`, or under `$XDG_CONFIG_HOME` +when you have set it. Override it with `--config-dir`, or with +`LOCALAI_CHAT_CONFIG_DIR`. The directory is user-scoped rather than server-scoped, because +the agent is a client and may be pointed at a remote LocalAI. + +## Plugins, skills and MCP servers + +The agent's own management commands are reached by passing them through: + +```bash +local-ai chat plugin install https://github.com/user/plugin +local-ai chat skill list +local-ai chat mcp add my-server -- npx -y @modelcontextprotocol/server-filesystem /tmp +``` + +Everything after the first positional argument is forwarded verbatim, so LocalAI's own flags +have to come first: + +```bash +local-ai chat --config-dir /srv/agent plugin list +``` + +Claude Code plugins install as they are, so a plugin written for that format needs no +conversion. + +{{% notice warning %}} +**Pass `--yes` in scripts.** The management commands do not read the streams LocalAI hands +them, so `plugin install` without `--yes` in a non-interactive context installs the plugin +and leaves it **disabled**. It prints that it did, on the line +`Plugin "name" installed but left disabled`, but it exits `0` either way, so a script that +only checks the exit code cannot tell the two outcomes apart. Always pass `--yes` when you +are not at a terminal. +{{% /notice %}} + +## Flags + +Every flag, with its environment variable, is in +[Chat flags]({{% relref "reference/cli-reference" %}}#chat-flags). diff --git a/docs/content/getting-started/try-it-out.md b/docs/content/getting-started/try-it-out.md index 54a7d96ebd48..59589ad01465 100644 --- a/docs/content/getting-started/try-it-out.md +++ b/docs/content/getting-started/try-it-out.md @@ -30,7 +30,11 @@ local-ai run local-ai chat --model qwen3-4b ``` -`local-ai chat` connects to a running LocalAI server, opens an interactive chat prompt, and exits when you type `/exit`, `/quit`, or `/bye`. Use `/models` to list installed models, `/model ` to switch models, and `/clear` to reset the current conversation. If the server exposes exactly one model, LocalAI uses that model automatically: +`local-ai chat` connects to a running LocalAI server and opens the built-in terminal agent. It is more than a chat prompt: it can read your files and run commands on your machine, so the first time it wants to do something that changes state it stops and asks you to approve the call. Reads and searches run without asking. Press `Esc` or `Ctrl+C` to end the session. `Esc` is a key of the full-screen interface: in the plain `--cli` mode, leave with `Ctrl+C`, `Ctrl+D`, or by typing `exit`. + +Use `/models` to list installed models, `/model ` to switch models while keeping the conversation, and `/compact` to summarize the history so far when the context fills up. The full picture is on the [Terminal agent]({{% relref "features/terminal-agent" %}}) page. + +If the server exposes exactly one model, LocalAI uses that model automatically: ```bash # Terminal 1 @@ -40,7 +44,7 @@ local-ai run qwen3-4b local-ai chat ``` -When more than one model is configured, pass `--model` with the installed model name to avoid ambiguity. Use `--endpoint` to connect to a non-default server, for example `local-ai chat --endpoint http://127.0.0.1:8081 --model qwen3-4b`. +When more than one model is configured, the agent asks you to pick one and remembers your answer, or you can pass `--model` with the installed model name. Use `--endpoint` to connect to a non-default server, for example `local-ai chat --endpoint http://127.0.0.1:8081 --model qwen3-4b`. You can also test out the API endpoints using `curl`. A few examples are listed below. diff --git a/docs/content/reference/cli-reference.md b/docs/content/reference/cli-reference.md index d0b82ed6c3f9..3548fc09caf5 100644 --- a/docs/content/reference/cli-reference.md +++ b/docs/content/reference/cli-reference.md @@ -123,18 +123,65 @@ See [Authentication & Authorization]({{%relref "features/authentication" %}}) fo ## Chat Flags -Use `local-ai chat` to open an interactive terminal chat session against a running LocalAI server. +Use `local-ai chat` to run the built-in terminal agent against a LocalAI server. +The agent can run shell commands, delegate to sub-agents, and use MCP tools. Read-only +calls run on their own; everything else goes through an approval prompt you answer. +See [Terminal agent]({{% relref "features/terminal-agent" %}}) for the full feature page. | Parameter | Default | Description | Environment Variable | |-----------|---------|-------------|----------------------| | `--endpoint` | `http://127.0.0.1:8080` | LocalAI server endpoint. The `/v1` path is added automatically when omitted. | `$LOCALAI_CHAT_ENDPOINT` | -| `--model` | | Model name to use. If omitted, LocalAI uses the only model returned by the server when exactly one is available. | | +| `--model` | | Model to use. Defaults to the only model the server offers, or asks when there are several. | | | `--api-key` | | API key to use when the LocalAI server requires authentication. | `$LOCALAI_API_KEY`, `$API_KEY` | +| `--config-dir` | `~/.config/localai/chat` | Directory holding the agent's config, plugins, and skills. | `$LOCALAI_CHAT_CONFIG_DIR` | +| `--trace-dir` | | Write a session LLM trace (NDJSON) to this directory. | `$LOCALAI_CHAT_TRACE_DIR` | +| `--cli` | `false` | Plain CLI mode instead of the full-screen interface. Pipe stdin for one-shot use. | | +| `--tui` | `false` | Force the full-screen interface. | | +| `--height` | | Run as an inline drop-down of this height, e.g. `40%`. | | +| `--tmux` / `--no-tmux` | | Control the tmux split. | | +| `--init` | | Print the shell integration script for `Ctrl+Space` (zsh, bash, or fish). | | +| `--yolo` | `false` | Auto-approve every tool call. Use with care. | `$LOCALAI_CHAT_YOLO` | + +The agent's own subcommands are reached by passing them through. LocalAI's flags +must come first, because everything after the first positional argument is +forwarded verbatim: -- Inside the chat prompt: - - Use `/models` to list installed models. - - Use `/model ` to switch to a different model and clear the conversation. - - Use `/clear` to reset the current conversation. +```bash +local-ai chat plugin install https://github.com/user/plugin +local-ai chat skill list +local-ai chat mcp add my-server -- npx -y @modelcontextprotocol/server-filesystem /tmp +local-ai chat --config-dir /srv/agent plugin list # flags first +``` + +{{% notice warning %}} +In a script, pass `--yes` to those subcommands. Without it, a non-interactive +`plugin install` installs the plugin, leaves it disabled, and still exits `0`. +{{% /notice %}} + +Summon the agent from any shell prompt with `Ctrl+Space`: + +```bash +echo 'eval "$(local-ai chat --init zsh)"' >> ~/.zshrc +``` + +Inside a session: +- `/models` lists the models the server offers, marking the current one. +- `/model ` switches model, keeping the conversation. +- `/compact` summarizes the conversation so far to free up context. +- `/skill `, `/agent `, `/attach `, `/goal `. + +If no server is reachable, the agent offers to start one for the session +(interactive terminals only) and otherwise points you at `local-ai run`. + +Piped use needs `--cli`, and exits `0` when it answers: + +```bash +echo "what is 2+2" | local-ai chat --cli +``` + +Read-only tools still run in a piped session. A tool call that is not read-only has +nobody to approve it, so it is denied and the session exits `3`, a code chosen to be +distinct from the `1` a failure reports. ## P2P Flags @@ -153,7 +200,7 @@ LocalAI supports several subcommands beyond `run`: - `local-ai models` - Manage LocalAI models and definitions - `local-ai backends` - Manage LocalAI backends and definitions -- `local-ai chat` - Open an interactive chat session against a running LocalAI server +- `local-ai chat` - Run the built-in terminal agent against a LocalAI server - `local-ai tts` - Convert text to speech - `local-ai sound-generation` - Generate audio files from text or audio - `local-ai transcript` - Convert audio to text diff --git a/docs/layouts/shortcodes/agentic-routing.html b/docs/layouts/shortcodes/agentic-routing.html new file mode 100644 index 000000000000..dc0ef903be98 --- /dev/null +++ b/docs/layouts/shortcodes/agentic-routing.html @@ -0,0 +1,77 @@ +{{- /* + The four agentic surfaces are easy to mistake for each other, so every one of + their pages opens with the same routing block. It lives here rather than being + pasted into each page, because four hand-kept copies drifted apart the last + time and a reader who lands on the wrong one reads a stale description of the + page they actually wanted. + + Call it with angle brackets. The list is built here as markdown and rendered + before it reaches the notice partial, which is what a percent-form `notice` + does with its own inner content; handing the partial raw markdown instead + leaves the asterisks on the page, because it drops its content into an HTML + block that goldmark then declines to look inside. + + `current` names the page it is rendered on. That entry loses its link, is + marked as the current page, and moves to the top, which is what the copies + used to do by hand. Omitting it renders the block as a plain four-way index, + which is what a page outside the four wants. +*/ -}} +{{- $current := .Get "current" | default (.Get 0) -}} +{{- $entries := slice + (dict + "id" "terminal-agent" + "title" "Terminal agent" + "ref" "features/terminal-agent" + "text" "an agent in your own shell. `local-ai chat` reads your files and runs commands on your machine, behind an approval gate you control.") + (dict + "id" "agents" + "title" "Agents" + "ref" "features/agents" + "text" "autonomous agents you build and run inside LocalAI, that reason, use tools, and act on their own.") + (dict + "id" "localai-assistant" + "title" "LocalAI Assistant" + "ref" "features/localai-assistant" + "text" "an admin chat modality for administering LocalAI itself: install models, manage backends and edit configs by chatting.") + (dict + "id" "mcp" + "title" "MCP" + "ref" "features/mcp" + "text" "the Model Context Protocol itself, the way a model is handed external tools. It sits underneath the other three rather than beside them.") +-}} + +{{- /* A typo in `current` would silently render the block with nothing marked + as the current page, which is the one failure a reader cannot spot. */ -}} +{{- if $current -}} + {{- $known := false -}} + {{- range $entries -}} + {{- if eq .id $current -}}{{- $known = true -}}{{- end -}} + {{- end -}} + {{- if not $known -}} + {{- errorf "agentic-routing: current=%q is not one of the four surfaces (terminal-agent, agents, localai-assistant, mcp)" $current -}} + {{- end -}} +{{- end -}} + +{{- $ordered := slice -}} +{{- range $entries -}} + {{- if eq .id $current -}}{{- $ordered = $ordered | append . -}}{{- end -}} +{{- end -}} +{{- range $entries -}} + {{- if ne .id $current -}}{{- $ordered = $ordered | append . -}}{{- end -}} +{{- end -}} + +{{- $lines := slice -}} +{{- range $ordered -}} + {{- if eq .id $current -}} + {{- $lines = $lines | append (printf "- **%s** (this page): %s" .title .text) -}} + {{- else -}} + {{- $lines = $lines | append (printf "- **[%s](%s)**: %s" .title (relref $.Page .ref) .text) -}} + {{- end -}} +{{- end -}} + +{{- $intro := "**Looking for something else?** LocalAI has four agentic surfaces that are easy to confuse:" -}} +{{- partial "shortcodes/notice.html" (dict + "page" .Page + "style" "info" + "content" (printf "%s\n\n%s\n" $intro (delimit $lines "\n") | markdownify) +) -}} diff --git a/go.mod b/go.mod index 8982bf9f70b1..eced8c0916cb 100644 --- a/go.mod +++ b/go.mod @@ -36,10 +36,11 @@ require ( github.com/mholt/archiver/v3 v3.5.1 github.com/microcosm-cc/bluemonday v1.0.27 github.com/modelcontextprotocol/go-sdk v1.5.0 - github.com/mudler/cogito v0.10.1-0.20260609212329-bf4010d31047 + github.com/mudler/cogito v0.11.1-0.20260721122412-6eece18a6bb6 github.com/mudler/edgevpn v0.34.0 github.com/mudler/go-processmanager v0.1.2-0.20260720195933-3d64f5c974fc github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8 + github.com/mudler/nib v0.6.0 github.com/mudler/xlog v0.0.6 github.com/nats-io/jwt/v2 v2.7.4 github.com/nats-io/nats.go v1.52.0 @@ -85,6 +86,7 @@ require ( filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect @@ -103,12 +105,19 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect + github.com/chromedp/chromedp v0.15.1 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/filecoin-project/go-clock v0.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect github.com/go-openapi/analysis v0.24.1 // indirect github.com/go-openapi/errors v0.22.4 // indirect github.com/go-openapi/loads v0.23.2 // indirect @@ -127,6 +136,9 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-openapi/validate v0.25.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/in-toto/attestation v1.1.2 // indirect @@ -136,9 +148,12 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/jolestar/go-commons-pool/v2 v2.1.2 // indirect github.com/klippa-app/go-pdfium v1.19.2 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-sqlite3 v1.14.28 // indirect github.com/moby/moby/api v1.54.2 // indirect github.com/moby/moby/client v0.4.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect @@ -344,7 +359,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/hcsshim v0.11.7 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect diff --git a/go.sum b/go.sum index fbde4ca44da7..61ccede93c39 100644 --- a/go.sum +++ b/go.sum @@ -91,10 +91,12 @@ github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0 h1:C0/TerKdQX9Y9pbYi1EsLr github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0/go.mod h1:OLaKh+giepO8j7teevrNwiy/fwf8LXgoc9g7rwaE1jk= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -142,6 +144,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= @@ -253,6 +257,10 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= @@ -263,14 +271,20 @@ github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvA github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= +github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= +github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -378,6 +392,8 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/eritikass/githubmarkdownconvertergo v0.1.10 h1:mL93ADvYMOeT15DcGtK9AaFFc+RcWcy6kQBC6yS/5f4= github.com/eritikass/githubmarkdownconvertergo v0.1.10/go.mod h1:BdpHs6imOtzE5KorbUtKa6bZ0ZBh1yFcrTTAL8FwDKY= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -441,6 +457,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -518,6 +536,12 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= @@ -823,6 +847,8 @@ github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2ln github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8= +github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= github.com/letsencrypt/boulder v0.20251110.0 h1:J8MnKICeilO91dyQ2n5eBbab24neHzUpYMUIOdOtbjc= github.com/letsencrypt/boulder v0.20251110.0/go.mod h1:ogKCJQwll82m7OVHWyTuf8eeFCjuzdRQlgnZcCl0V+8= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -885,6 +911,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= @@ -968,8 +996,8 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/mudler/LocalAGI v0.0.0-20260606071251-14aed1ae4336 h1:iKBkSnpisOvMVxFoYsAObvAuOqXBakRPMD0PWxWG5EE= github.com/mudler/LocalAGI v0.0.0-20260606071251-14aed1ae4336/go.mod h1:U+g6u8mF2wQxhkdBl3dr8G4db1cv3n7KTKmraoJ7D0c= -github.com/mudler/cogito v0.10.1-0.20260609212329-bf4010d31047 h1:wJ8WbDah1YcpBNRDmovQro8JiR228YFk7TUqPCS4m04= -github.com/mudler/cogito v0.10.1-0.20260609212329-bf4010d31047/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4= +github.com/mudler/cogito v0.11.1-0.20260721122412-6eece18a6bb6 h1:eYTR8od5HdaHlh9AKCkxkRoHs2/wmx24BF5qrUh2TRY= +github.com/mudler/cogito v0.11.1-0.20260721122412-6eece18a6bb6/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4= github.com/mudler/edgevpn v0.34.0 h1:qDrD/rCPFY/FdURbXudIZWihVKY4VOX3nMn3CcbeQEU= github.com/mudler/edgevpn v0.34.0/go.mod h1:yki7uMi5LR9gSMrw8PdPieuxsrk8BLV2Ui7VBEmbbIA= github.com/mudler/go-piper v0.0.0-20241023091659-2494246fd9fc h1:RxwneJl1VgvikiX28EkpdAyL4yQVnJMrbquKospjHyA= @@ -980,12 +1008,18 @@ github.com/mudler/localrecall v0.6.3 h1:uXOrP9JmetzxgVKzSrawviyBHZfAcvPBBIrvVUdZ github.com/mudler/localrecall v0.6.3/go.mod h1:28k5n19raUrkuwXkacdNsBlj8yuSnGhpT16tu+2+4dU= github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8 h1:Ry8RiWy8fZ6Ff4E7dPmjRsBrnHOnPeOOj2LhCgyjQu0= github.com/mudler/memory v0.0.0-20260406210934-424c1ecf2cf8/go.mod h1:EA8Ashhd56o32qN7ouPKFSRUs/Z+LrRCF4v6R2Oarm8= +github.com/mudler/nib v0.6.0 h1:6l2bQJkgHT5+o6S0wmmk/ipTCbFzmiSp1pM/tGQv4Eo= +github.com/mudler/nib v0.6.0/go.mod h1:d+Ymgi7PxDLnGxpYAkugv+mwRDtR1CMcgyykKNipwWA= github.com/mudler/skillserver v0.0.7-0.20260520220837-a7317cbf9145 h1:z59tA3IDYPt71nzH1jpxeaA1LuDw8aZfpTQFNU43Zb8= github.com/mudler/skillserver v0.0.7-0.20260520220837-a7317cbf9145/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU= github.com/mudler/water v0.0.0-20250808092830-dd90dcf09025 h1:WFLP5FHInarYGXi6B/Ze204x7Xy6q/I4nCZnWEyPHK0= github.com/mudler/water v0.0.0-20250808092830-dd90dcf09025/go.mod h1:QuIFdRstyGJt+MTTkWY+mtD7U6xwjOR6SwKUjmLZtR4= github.com/mudler/xlog v0.0.6 h1:3nBV4THK8kY0Y8FDXXvWAnuAJoOyO7EAXteJeAoHUC0= github.com/mudler/xlog v0.0.6/go.mod h1:3pO/Dsp3ViWl1QLyNtK7VQDJqMlntVu+rm5lP5PkX1g= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -1054,6 +1088,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= @@ -1666,6 +1702,7 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/website/layouts/index.html b/website/layouts/index.html index 5269e1b717d3..3c6467f0d4d1 100644 --- a/website/layouts/index.html +++ b/website/layouts/index.html @@ -146,9 +146,9 @@

Nobody was at the keyboard.

-

Every part of this clip came out of LocalAI, and nib drove the machine that made it. The agent opened the app, ran the demo and captured the screen, while a local model wrote the script, a cloned voice read it, and the video endpoint generated and lip-synced the presenter. No human touched the keyboard, and nothing left the building.

+

Every part of this clip came out of LocalAI, and the agent in the binary drove the machine that made it. It opened the app, ran the demo and captured the screen, while a local model wrote the script, a cloned voice read it, and the video endpoint generated and lip-synced the presenter. No human touched the keyboard, and nothing left the building.

-
Directionnib, our agent harness, drove the machine end to end
+
DirectionThe agent in LocalAI drove the machine end to end
ScriptWritten by a local language model
VoiceCloned from a few seconds of reference audio
PresenterGenerated and lip-synced through the video endpoint
@@ -343,18 +343,22 @@

1,585 models. No notebook, no conversi
-

nib

+

the agent

-

An agent you can drop on any box you SSH into.

-

One Go binary, about 20 MB, no runtime and no daemon. Press Ctrl+Space anywhere and it opens. Point it at any OpenAI-compatible endpoint, including a model running on your own laptop, and it works. Tool calls go through an approval gate you control, and Claude Code plugins load as they are.

-
Gozero dependenciesMCPpluginsskillssub-agents
-

nib on GitHub ↗

+

The agent is already in the binary.

+

Run local-ai chat and you are talking to an agent that already knows where your models are. It runs shell commands behind an approval gate you control, delegates to sub-agents, and loads MCP servers, plugins and skills. Claude Code plugins load as they are.

+

It is also nib, a single ~20 MB Go binary with no runtime and no daemon, so you can drop the same agent on any box you SSH into and press Ctrl+Space.

+
local-ai chatMCPpluginsskillssub-agentszero dependencies
+
-
nib ~20 MB, one binary
- +
local-ai chat the agent, in the binary
+