From 91eefcd2db26a0801def3ebd0973403a266938f0 Mon Sep 17 00:00:00 2001 From: localai-bot Date: Mon, 9 Mar 2026 02:41:51 +0000 Subject: [PATCH 1/3] feat: add standalone agent CLI command - Add 'local-ai agent run' command for standalone agent execution - Support running agents from JSON config file or agent registry lookup - Add agent configuration parsing and validation - Implement standalone agent execution with signal handling - Add comprehensive tests - Update documentation Signed-off-by: claude-agent-1 --- core/cli/agent.go | 223 +++++++++++++++++++++++++ core/cli/agent_test.go | 283 ++++++++++++++++++++++++++++++++ core/cli/cli.go | 1 + docs/content/features/agents.md | 61 +++++++ 4 files changed, 568 insertions(+) create mode 100644 core/cli/agent.go create mode 100644 core/cli/agent_test.go diff --git a/core/cli/agent.go b/core/cli/agent.go new file mode 100644 index 000000000000..cb31783eb168 --- /dev/null +++ b/core/cli/agent.go @@ -0,0 +1,223 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + + cliContext "github.com/mudler/LocalAI/core/cli/context" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/services" + "github.com/mudler/LocalAGI/core/state" + "github.com/mudler/xlog" +) + +type AgentCMD struct { + Run AgentRunCMD `cmd:"" help:"Run an agent in standalone mode" default:"withargs"` +} + +type AgentRunCMD struct { + // Positional argument: agent name or path to a JSON configuration file + AgentRef string `arg:"" required:"" help:"Agent name (from registry) or path to a JSON agent configuration file"` + + // API connection settings for the agent to use + APIURL string `env:"LOCALAI_AGENT_POOL_API_URL" help:"API URL for the agent to use (e.g. http://localhost:8080)" group:"agent"` + APIKey string `env:"LOCALAI_AGENT_POOL_API_KEY" help:"API key for the agent" group:"agent"` + + // Agent pool settings + DefaultModel string `env:"LOCALAI_AGENT_POOL_DEFAULT_MODEL" help:"Default model for the agent" group:"agent"` + MultimodalModel string `env:"LOCALAI_AGENT_POOL_MULTIMODAL_MODEL" help:"Multimodal model for the agent" group:"agent"` + TranscriptionModel string `env:"LOCALAI_AGENT_POOL_TRANSCRIPTION_MODEL" help:"Transcription model for the agent" group:"agent"` + TranscriptionLanguage string `env:"LOCALAI_AGENT_POOL_TRANSCRIPTION_LANGUAGE" help:"Transcription language for the agent" group:"agent"` + TTSModel string `env:"LOCALAI_AGENT_POOL_TTS_MODEL" help:"TTS model for the agent" group:"agent"` + StateDir string `env:"LOCALAI_AGENT_POOL_STATE_DIR" default:"agent-state" help:"State directory for the agent" group:"agent"` + Timeout string `env:"LOCALAI_AGENT_POOL_TIMEOUT" default:"5m" help:"Agent timeout" group:"agent"` + EnableSkills bool `env:"LOCALAI_AGENT_POOL_ENABLE_SKILLS" default:"false" help:"Enable skills service" group:"agent"` + EnableLogs bool `env:"LOCALAI_AGENT_POOL_ENABLE_LOGS" default:"false" help:"Enable agent logging" group:"agent"` + CustomActionsDir string `env:"LOCALAI_AGENT_POOL_CUSTOM_ACTIONS_DIR" help:"Custom actions directory" group:"agent"` + + // Registry settings + AgentHubURL string `env:"LOCALAI_AGENT_HUB_URL" default:"https://agenthub.localai.io" help:"Agent hub URL for registry lookups" group:"registry"` +} + +func (a *AgentRunCMD) Run(ctx *cliContext.Context) error { + agentConfig, err := a.resolveAgentConfig() + if err != nil { + return fmt.Errorf("failed to resolve agent configuration: %w", err) + } + + // Apply CLI overrides to the agent config + a.applyOverrides(agentConfig) + + if agentConfig.Name == "" { + return fmt.Errorf("agent configuration must have a name") + } + + xlog.Info("Starting agent in standalone mode", "name", agentConfig.Name) + + appConfig := config.NewApplicationConfig( + config.WithContext(context.Background()), + config.WithAPIAddress(":0"), // not serving HTTP + config.WithAgentPoolAPIURL(agentConfig.APIURL), + config.WithAgentPoolAPIKey(agentConfig.APIKey), + config.WithAgentPoolStateDir(a.StateDir), + config.WithAgentPoolTimeout(a.Timeout), + ) + + if a.DefaultModel != "" { + config.WithAgentPoolDefaultModel(a.DefaultModel)(appConfig) + } + if a.MultimodalModel != "" { + config.WithAgentPoolMultimodalModel(a.MultimodalModel)(appConfig) + } + if a.TranscriptionModel != "" { + config.WithAgentPoolTranscriptionModel(a.TranscriptionModel)(appConfig) + } + if a.TranscriptionLanguage != "" { + config.WithAgentPoolTranscriptionLanguage(a.TranscriptionLanguage)(appConfig) + } + if a.TTSModel != "" { + config.WithAgentPoolTTSModel(a.TTSModel)(appConfig) + } + if a.EnableSkills { + config.EnableAgentPoolSkills(appConfig) + } + if a.EnableLogs { + config.EnableAgentPoolLogs(appConfig) + } + if a.CustomActionsDir != "" { + config.WithAgentPoolCustomActionsDir(a.CustomActionsDir)(appConfig) + } + + svc, err := services.NewAgentPoolService(appConfig) + if err != nil { + return fmt.Errorf("failed to create agent pool service: %w", err) + } + + if err := svc.Start(appConfig.Context); err != nil { + return fmt.Errorf("failed to start agent pool service: %w", err) + } + defer svc.Stop() + + if err := svc.CreateAgent(agentConfig); err != nil { + return fmt.Errorf("failed to create agent %q: %w", agentConfig.Name, err) + } + + xlog.Info("Agent started successfully", "name", agentConfig.Name) + + // Wait for interrupt signal + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + xlog.Info("Shutting down agent", "name", agentConfig.Name) + return nil +} + +// resolveAgentConfig determines whether AgentRef is a local JSON file or a registry name, +// and returns the parsed agent configuration. +func (a *AgentRunCMD) resolveAgentConfig() (*state.AgentConfig, error) { + // Check if the reference is a local file + if isJSONFile(a.AgentRef) { + return a.loadFromFile(a.AgentRef) + } + + // Try as a registry name + return a.loadFromRegistry(a.AgentRef) +} + +// loadFromFile reads and validates an agent configuration from a JSON file. +func (a *AgentRunCMD) loadFromFile(path string) (*state.AgentConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read agent config file %q: %w", path, err) + } + + var cfg state.AgentConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse agent config file %q: %w", path, err) + } + + return &cfg, nil +} + +// loadFromRegistry fetches an agent configuration from the agent hub registry. +func (a *AgentRunCMD) loadFromRegistry(name string) (*state.AgentConfig, error) { + hubURL := strings.TrimRight(a.AgentHubURL, "/") + endpoint := fmt.Sprintf("%s/api/agents/%s", hubURL, name) + + xlog.Info("Fetching agent configuration from registry", "name", name, "url", endpoint) + + resp, err := http.Get(endpoint) //nolint:gosec + if err != nil { + return nil, fmt.Errorf("failed to fetch agent %q from registry: %w", name, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("agent %q not found in registry at %s", name, hubURL) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("registry returned status %d for agent %q: %s", resp.StatusCode, name, strings.TrimSpace(string(body))) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read registry response for agent %q: %w", name, err) + } + + var cfg state.AgentConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse registry response for agent %q: %w", name, err) + } + + if cfg.Name == "" { + cfg.Name = name + } + + return &cfg, nil +} + +// applyOverrides applies CLI flag values to the agent config when they are set, +// allowing users to override values from the file or registry. +func (a *AgentRunCMD) applyOverrides(cfg *state.AgentConfig) { + if a.APIURL != "" && cfg.APIURL == "" { + cfg.APIURL = a.APIURL + } + if a.APIKey != "" && cfg.APIKey == "" { + cfg.APIKey = a.APIKey + } + if a.DefaultModel != "" && cfg.Model == "" { + cfg.Model = a.DefaultModel + } + if a.MultimodalModel != "" && cfg.MultimodalModel == "" { + cfg.MultimodalModel = a.MultimodalModel + } + if a.TranscriptionModel != "" && cfg.TranscriptionModel == "" { + cfg.TranscriptionModel = a.TranscriptionModel + } + if a.TranscriptionLanguage != "" && cfg.TranscriptionLanguage == "" { + cfg.TranscriptionLanguage = a.TranscriptionLanguage + } + if a.TTSModel != "" && cfg.TTSModel == "" { + cfg.TTSModel = a.TTSModel + } +} + +// isJSONFile returns true if the path looks like a reference to a JSON file. +func isJSONFile(ref string) bool { + if strings.HasSuffix(ref, ".json") { + return true + } + // Check if the file exists on disk (handles paths without .json extension) + info, err := os.Stat(ref) + return err == nil && !info.IsDir() +} diff --git a/core/cli/agent_test.go b/core/cli/agent_test.go new file mode 100644 index 000000000000..d039c936779b --- /dev/null +++ b/core/cli/agent_test.go @@ -0,0 +1,283 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/mudler/LocalAGI/core/state" +) + +func TestIsJSONFile(t *testing.T) { + // .json suffix should always be detected + if !isJSONFile("agent.json") { + t.Error("expected agent.json to be detected as JSON file") + } + if !isJSONFile("/path/to/config.json") { + t.Error("expected /path/to/config.json to be detected as JSON file") + } + + // Non-existent path without .json suffix is not a file + if isJSONFile("my-agent-name") { + t.Error("expected my-agent-name to not be detected as JSON file") + } + + // Existing file without .json suffix should be detected + tmpFile, err := os.CreateTemp(t.TempDir(), "agentconfig") + if err != nil { + t.Fatal(err) + } + tmpFile.Close() + if !isJSONFile(tmpFile.Name()) { + t.Error("expected existing file to be detected as JSON file") + } + + // Directory should not be detected + if isJSONFile(t.TempDir()) { + t.Error("expected directory to not be detected as JSON file") + } +} + +func TestLoadFromFile(t *testing.T) { + dir := t.TempDir() + + t.Run("valid config", func(t *testing.T) { + cfg := state.AgentConfig{ + Name: "test-agent", + Model: "gpt-4", + SystemPrompt: "You are a helpful assistant.", + APIURL: "http://localhost:8080", + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + + path := filepath.Join(dir, "valid.json") + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + cmd := &AgentRunCMD{} + loaded, err := cmd.loadFromFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded.Name != "test-agent" { + t.Errorf("expected name 'test-agent', got %q", loaded.Name) + } + if loaded.Model != "gpt-4" { + t.Errorf("expected model 'gpt-4', got %q", loaded.Model) + } + if loaded.SystemPrompt != "You are a helpful assistant." { + t.Errorf("unexpected system prompt: %q", loaded.SystemPrompt) + } + }) + + t.Run("invalid JSON", func(t *testing.T) { + path := filepath.Join(dir, "invalid.json") + if err := os.WriteFile(path, []byte("{not valid json"), 0644); err != nil { + t.Fatal(err) + } + + cmd := &AgentRunCMD{} + _, err := cmd.loadFromFile(path) + if err == nil { + t.Error("expected error for invalid JSON") + } + }) + + t.Run("nonexistent file", func(t *testing.T) { + cmd := &AgentRunCMD{} + _, err := cmd.loadFromFile(filepath.Join(dir, "nonexistent.json")) + if err == nil { + t.Error("expected error for nonexistent file") + } + }) +} + +func TestLoadFromRegistry(t *testing.T) { + t.Run("successful fetch", func(t *testing.T) { + cfg := state.AgentConfig{ + Name: "registry-agent", + Model: "llama3", + SystemPrompt: "Hello from registry", + } + data, _ := json.Marshal(cfg) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/agents/registry-agent" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(data) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + loaded, err := cmd.loadFromRegistry("registry-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded.Name != "registry-agent" { + t.Errorf("expected name 'registry-agent', got %q", loaded.Name) + } + if loaded.Model != "llama3" { + t.Errorf("expected model 'llama3', got %q", loaded.Model) + } + }) + + t.Run("agent not found", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + _, err := cmd.loadFromRegistry("nonexistent") + if err == nil { + t.Error("expected error for nonexistent agent") + } + }) + + t.Run("server error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + _, err := cmd.loadFromRegistry("broken") + if err == nil { + t.Error("expected error for server error") + } + }) + + t.Run("sets name from ref when empty", func(t *testing.T) { + cfg := state.AgentConfig{ + Model: "llama3", + } + data, _ := json.Marshal(cfg) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(data) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + loaded, err := cmd.loadFromRegistry("my-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded.Name != "my-agent" { + t.Errorf("expected name 'my-agent' (from ref), got %q", loaded.Name) + } + }) + + t.Run("invalid JSON response", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{invalid")) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + _, err := cmd.loadFromRegistry("bad-json") + if err == nil { + t.Error("expected error for invalid JSON response") + } + }) +} + +func TestApplyOverrides(t *testing.T) { + t.Run("fills empty fields", func(t *testing.T) { + cfg := &state.AgentConfig{Name: "test"} + cmd := &AgentRunCMD{ + APIURL: "http://override:8080", + APIKey: "key123", + DefaultModel: "override-model", + MultimodalModel: "mm-model", + TranscriptionModel: "whisper", + TTSModel: "tts-1", + } + cmd.applyOverrides(cfg) + + if cfg.APIURL != "http://override:8080" { + t.Errorf("expected APIURL override, got %q", cfg.APIURL) + } + if cfg.APIKey != "key123" { + t.Errorf("expected APIKey override, got %q", cfg.APIKey) + } + if cfg.Model != "override-model" { + t.Errorf("expected Model override, got %q", cfg.Model) + } + if cfg.MultimodalModel != "mm-model" { + t.Errorf("expected MultimodalModel override, got %q", cfg.MultimodalModel) + } + }) + + t.Run("does not overwrite existing values", func(t *testing.T) { + cfg := &state.AgentConfig{ + Name: "test", + APIURL: "http://original:8080", + Model: "original-model", + } + cmd := &AgentRunCMD{ + APIURL: "http://override:8080", + DefaultModel: "override-model", + } + cmd.applyOverrides(cfg) + + if cfg.APIURL != "http://original:8080" { + t.Errorf("expected original APIURL preserved, got %q", cfg.APIURL) + } + if cfg.Model != "original-model" { + t.Errorf("expected original Model preserved, got %q", cfg.Model) + } + }) +} + +func TestResolveAgentConfig(t *testing.T) { + t.Run("resolves from file", func(t *testing.T) { + dir := t.TempDir() + cfg := state.AgentConfig{Name: "file-agent", Model: "gpt-4"} + data, _ := json.Marshal(cfg) + path := filepath.Join(dir, "agent.json") + os.WriteFile(path, data, 0644) + + cmd := &AgentRunCMD{AgentRef: path} + loaded, err := cmd.resolveAgentConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded.Name != "file-agent" { + t.Errorf("expected name 'file-agent', got %q", loaded.Name) + } + }) + + t.Run("resolves from registry", func(t *testing.T) { + cfg := state.AgentConfig{Name: "hub-agent", Model: "llama3"} + data, _ := json.Marshal(cfg) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(data) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentRef: "hub-agent", AgentHubURL: srv.URL} + loaded, err := cmd.resolveAgentConfig() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded.Name != "hub-agent" { + t.Errorf("expected name 'hub-agent', got %q", loaded.Name) + } + }) +} diff --git a/core/cli/cli.go b/core/cli/cli.go index 2fb43fbf565e..d4268b9e668d 100644 --- a/core/cli/cli.go +++ b/core/cli/cli.go @@ -15,6 +15,7 @@ var CLI struct { TTS TTSCMD `cmd:"" help:"Convert text to speech"` SoundGeneration SoundGenerationCMD `cmd:"" help:"Generates audio files from text or audio"` Transcript TranscriptCMD `cmd:"" help:"Convert audio to text"` + Agent AgentCMD `cmd:"" help:"Run and manage agents"` Worker worker.Worker `cmd:"" help:"Run workers to distribute workload (llama.cpp-only)"` Util UtilCMD `cmd:"" help:"Utility commands"` Explorer ExplorerCMD `cmd:"" help:"Run p2p explorer"` diff --git a/docs/content/features/agents.md b/docs/content/features/agents.md index ab83cc8762f5..fe761a43463a 100644 --- a/docs/content/features/agents.md +++ b/docs/content/features/agents.md @@ -297,6 +297,67 @@ The SSE stream emits the following event types: - `status` — system messages (reasoning steps, action results) - `json_error` — error notifications +## Standalone Agent CLI + +You can run an agent outside of the full LocalAI server using the `agent run` CLI command. This launches a single agent in standalone mode, which is useful for scripting, CI/CD pipelines, or running agents without the HTTP server. + +### Usage + +```bash +# Run from a local JSON configuration file +local-ai agent run ./my-agent.json + +# Run from the Agent Hub registry by name +local-ai agent run my-agent-name + +# Override API URL and model +local-ai agent run ./my-agent.json --api-url http://localhost:8080 --default-model llama3 +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `` | **(required)** Agent name from the registry or path to a JSON config file | + +### Flags + +| Flag | Env Variable | Default | Description | +|------|-------------|---------|-------------| +| `--api-url` | `LOCALAI_AGENT_POOL_API_URL` | | API URL the agent uses for LLM inference | +| `--api-key` | `LOCALAI_AGENT_POOL_API_KEY` | | API key for the agent | +| `--default-model` | `LOCALAI_AGENT_POOL_DEFAULT_MODEL` | | Default model for the agent | +| `--multimodal-model` | `LOCALAI_AGENT_POOL_MULTIMODAL_MODEL` | | Multimodal model | +| `--transcription-model` | `LOCALAI_AGENT_POOL_TRANSCRIPTION_MODEL` | | Transcription model | +| `--tts-model` | `LOCALAI_AGENT_POOL_TTS_MODEL` | | TTS model | +| `--state-dir` | `LOCALAI_AGENT_POOL_STATE_DIR` | `agent-state` | State directory | +| `--timeout` | `LOCALAI_AGENT_POOL_TIMEOUT` | `5m` | Agent timeout | +| `--enable-skills` | `LOCALAI_AGENT_POOL_ENABLE_SKILLS` | `false` | Enable skills service | +| `--enable-logs` | `LOCALAI_AGENT_POOL_ENABLE_LOGS` | `false` | Enable agent logging | +| `--agent-hub-url` | `LOCALAI_AGENT_HUB_URL` | `https://agenthub.localai.io` | Agent Hub URL for registry lookups | + +### How It Works + +1. **File reference**: If the argument is a path to an existing file or ends in `.json`, the agent config is loaded from that file. +2. **Registry lookup**: Otherwise, the agent name is looked up from the Agent Hub registry (`GET /api/agents/`). +3. CLI flags fill in any values not already set in the configuration (e.g., `--api-url` sets the API URL only if the config doesn't already specify one). +4. The agent is created and started in standalone mode. Press `Ctrl+C` to stop. + +### Example Agent Config + +```json +{ + "name": "my-assistant", + "model": "hermes-3-llama3.1-8b", + "api_url": "http://localhost:8080", + "system_prompt": "You are a helpful assistant.", + "standalone_job": true, + "actions": [ + {"name": "search", "config": "{}"} + ] +} +``` + ## Architecture Agents run in-process within LocalAI. By default, each agent calls back into LocalAI's own API (`http://127.0.0.1:/v1/chat/completions`) for LLM inference. This means: From 5e6e4d4247f7f641ef86a08136ed0cde83741dec Mon Sep 17 00:00:00 2001 From: localai-bot Date: Mon, 9 Mar 2026 14:54:43 +0000 Subject: [PATCH 2/3] fix(agent): address PR review feedback for standalone agent CLI - Convert agent_test.go from standard Go testing to ginkgo/gomega framework - Add cli_suite_test.go for ginkgo test runner - Fix agent registry URL to use correct format: /agents/.json - Add comment explaining future prompt behavior (ask agent directly and exit) Co-Authored-By: Claude Opus 4.6 Signed-off-by: claude-agent-1 --- core/cli/agent.go | 5 +- core/cli/agent_test.go | 464 ++++++++++++++++--------------------- core/cli/cli_suite_test.go | 13 ++ 3 files changed, 222 insertions(+), 260 deletions(-) create mode 100644 core/cli/cli_suite_test.go diff --git a/core/cli/agent.go b/core/cli/agent.go index cb31783eb168..b3e3eeea299b 100644 --- a/core/cli/agent.go +++ b/core/cli/agent.go @@ -111,6 +111,9 @@ func (a *AgentRunCMD) Run(ctx *cliContext.Context) error { xlog.Info("Agent started successfully", "name", agentConfig.Name) + // Optionally, if the user specifies a prompt, we will ask the agent directly and exit. + // No background service in that case. + // Wait for interrupt signal sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -150,7 +153,7 @@ func (a *AgentRunCMD) loadFromFile(path string) (*state.AgentConfig, error) { // loadFromRegistry fetches an agent configuration from the agent hub registry. func (a *AgentRunCMD) loadFromRegistry(name string) (*state.AgentConfig, error) { hubURL := strings.TrimRight(a.AgentHubURL, "/") - endpoint := fmt.Sprintf("%s/api/agents/%s", hubURL, name) + endpoint := fmt.Sprintf("%s/agents/%s.json", hubURL, name) xlog.Info("Fetching agent configuration from registry", "name", name, "url", endpoint) diff --git a/core/cli/agent_test.go b/core/cli/agent_test.go index d039c936779b..ca098b0500e8 100644 --- a/core/cli/agent_test.go +++ b/core/cli/agent_test.go @@ -6,278 +6,224 @@ import ( "net/http/httptest" "os" "path/filepath" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" "github.com/mudler/LocalAGI/core/state" ) -func TestIsJSONFile(t *testing.T) { - // .json suffix should always be detected - if !isJSONFile("agent.json") { - t.Error("expected agent.json to be detected as JSON file") - } - if !isJSONFile("/path/to/config.json") { - t.Error("expected /path/to/config.json to be detected as JSON file") - } - - // Non-existent path without .json suffix is not a file - if isJSONFile("my-agent-name") { - t.Error("expected my-agent-name to not be detected as JSON file") - } - - // Existing file without .json suffix should be detected - tmpFile, err := os.CreateTemp(t.TempDir(), "agentconfig") - if err != nil { - t.Fatal(err) - } - tmpFile.Close() - if !isJSONFile(tmpFile.Name()) { - t.Error("expected existing file to be detected as JSON file") - } - - // Directory should not be detected - if isJSONFile(t.TempDir()) { - t.Error("expected directory to not be detected as JSON file") - } -} - -func TestLoadFromFile(t *testing.T) { - dir := t.TempDir() - - t.Run("valid config", func(t *testing.T) { - cfg := state.AgentConfig{ - Name: "test-agent", - Model: "gpt-4", - SystemPrompt: "You are a helpful assistant.", - APIURL: "http://localhost:8080", - } - data, err := json.Marshal(cfg) - if err != nil { - t.Fatal(err) - } - - path := filepath.Join(dir, "valid.json") - if err := os.WriteFile(path, data, 0644); err != nil { - t.Fatal(err) - } - - cmd := &AgentRunCMD{} - loaded, err := cmd.loadFromFile(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if loaded.Name != "test-agent" { - t.Errorf("expected name 'test-agent', got %q", loaded.Name) - } - if loaded.Model != "gpt-4" { - t.Errorf("expected model 'gpt-4', got %q", loaded.Model) - } - if loaded.SystemPrompt != "You are a helpful assistant." { - t.Errorf("unexpected system prompt: %q", loaded.SystemPrompt) - } +var _ = Describe("Agent CLI", func() { + Describe("isJSONFile", func() { + It("should detect .json suffix", func() { + Expect(isJSONFile("agent.json")).To(BeTrue()) + Expect(isJSONFile("/path/to/config.json")).To(BeTrue()) + }) + + It("should not detect non-existent path without .json suffix", func() { + Expect(isJSONFile("my-agent-name")).To(BeFalse()) + }) + + It("should detect existing file without .json suffix", func() { + tmpFile, err := os.CreateTemp(GinkgoT().TempDir(), "agentconfig") + Expect(err).ToNot(HaveOccurred()) + tmpFile.Close() + Expect(isJSONFile(tmpFile.Name())).To(BeTrue()) + }) + + It("should not detect a directory", func() { + Expect(isJSONFile(GinkgoT().TempDir())).To(BeFalse()) + }) }) - t.Run("invalid JSON", func(t *testing.T) { - path := filepath.Join(dir, "invalid.json") - if err := os.WriteFile(path, []byte("{not valid json"), 0644); err != nil { - t.Fatal(err) - } - - cmd := &AgentRunCMD{} - _, err := cmd.loadFromFile(path) - if err == nil { - t.Error("expected error for invalid JSON") - } - }) + Describe("loadFromFile", func() { + var dir string - t.Run("nonexistent file", func(t *testing.T) { - cmd := &AgentRunCMD{} - _, err := cmd.loadFromFile(filepath.Join(dir, "nonexistent.json")) - if err == nil { - t.Error("expected error for nonexistent file") - } - }) -} - -func TestLoadFromRegistry(t *testing.T) { - t.Run("successful fetch", func(t *testing.T) { - cfg := state.AgentConfig{ - Name: "registry-agent", - Model: "llama3", - SystemPrompt: "Hello from registry", - } - data, _ := json.Marshal(cfg) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/agents/registry-agent" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(data) - })) - defer srv.Close() - - cmd := &AgentRunCMD{AgentHubURL: srv.URL} - loaded, err := cmd.loadFromRegistry("registry-agent") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if loaded.Name != "registry-agent" { - t.Errorf("expected name 'registry-agent', got %q", loaded.Name) - } - if loaded.Model != "llama3" { - t.Errorf("expected model 'llama3', got %q", loaded.Model) - } - }) - - t.Run("agent not found", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.NotFound(w, r) - })) - defer srv.Close() - - cmd := &AgentRunCMD{AgentHubURL: srv.URL} - _, err := cmd.loadFromRegistry("nonexistent") - if err == nil { - t.Error("expected error for nonexistent agent") - } - }) + BeforeEach(func() { + dir = GinkgoT().TempDir() + }) - t.Run("server error", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal error")) - })) - defer srv.Close() - - cmd := &AgentRunCMD{AgentHubURL: srv.URL} - _, err := cmd.loadFromRegistry("broken") - if err == nil { - t.Error("expected error for server error") - } + It("should load a valid config", func() { + cfg := state.AgentConfig{ + Name: "test-agent", + Model: "gpt-4", + SystemPrompt: "You are a helpful assistant.", + APIURL: "http://localhost:8080", + } + data, err := json.Marshal(cfg) + Expect(err).ToNot(HaveOccurred()) + + path := filepath.Join(dir, "valid.json") + Expect(os.WriteFile(path, data, 0644)).To(Succeed()) + + cmd := &AgentRunCMD{} + loaded, err := cmd.loadFromFile(path) + Expect(err).ToNot(HaveOccurred()) + Expect(loaded.Name).To(Equal("test-agent")) + Expect(loaded.Model).To(Equal("gpt-4")) + Expect(loaded.SystemPrompt).To(Equal("You are a helpful assistant.")) + }) + + It("should return error for invalid JSON", func() { + path := filepath.Join(dir, "invalid.json") + Expect(os.WriteFile(path, []byte("{not valid json"), 0644)).To(Succeed()) + + cmd := &AgentRunCMD{} + _, err := cmd.loadFromFile(path) + Expect(err).To(HaveOccurred()) + }) + + It("should return error for nonexistent file", func() { + cmd := &AgentRunCMD{} + _, err := cmd.loadFromFile(filepath.Join(dir, "nonexistent.json")) + Expect(err).To(HaveOccurred()) + }) }) - t.Run("sets name from ref when empty", func(t *testing.T) { - cfg := state.AgentConfig{ - Model: "llama3", - } - data, _ := json.Marshal(cfg) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(data) - })) - defer srv.Close() - - cmd := &AgentRunCMD{AgentHubURL: srv.URL} - loaded, err := cmd.loadFromRegistry("my-agent") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if loaded.Name != "my-agent" { - t.Errorf("expected name 'my-agent' (from ref), got %q", loaded.Name) - } + Describe("loadFromRegistry", func() { + It("should fetch successfully", func() { + cfg := state.AgentConfig{ + Name: "registry-agent", + Model: "llama3", + SystemPrompt: "Hello from registry", + } + data, _ := json.Marshal(cfg) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/agents/registry-agent.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(data) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + loaded, err := cmd.loadFromRegistry("registry-agent") + Expect(err).ToNot(HaveOccurred()) + Expect(loaded.Name).To(Equal("registry-agent")) + Expect(loaded.Model).To(Equal("llama3")) + }) + + It("should return error when agent not found", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + _, err := cmd.loadFromRegistry("nonexistent") + Expect(err).To(HaveOccurred()) + }) + + It("should return error on server error", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + _, err := cmd.loadFromRegistry("broken") + Expect(err).To(HaveOccurred()) + }) + + It("should set name from ref when empty", func() { + cfg := state.AgentConfig{ + Model: "llama3", + } + data, _ := json.Marshal(cfg) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(data) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + loaded, err := cmd.loadFromRegistry("my-agent") + Expect(err).ToNot(HaveOccurred()) + Expect(loaded.Name).To(Equal("my-agent")) + }) + + It("should return error for invalid JSON response", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{invalid")) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentHubURL: srv.URL} + _, err := cmd.loadFromRegistry("bad-json") + Expect(err).To(HaveOccurred()) + }) }) - t.Run("invalid JSON response", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("{invalid")) - })) - defer srv.Close() - - cmd := &AgentRunCMD{AgentHubURL: srv.URL} - _, err := cmd.loadFromRegistry("bad-json") - if err == nil { - t.Error("expected error for invalid JSON response") - } - }) -} - -func TestApplyOverrides(t *testing.T) { - t.Run("fills empty fields", func(t *testing.T) { - cfg := &state.AgentConfig{Name: "test"} - cmd := &AgentRunCMD{ - APIURL: "http://override:8080", - APIKey: "key123", - DefaultModel: "override-model", - MultimodalModel: "mm-model", - TranscriptionModel: "whisper", - TTSModel: "tts-1", - } - cmd.applyOverrides(cfg) - - if cfg.APIURL != "http://override:8080" { - t.Errorf("expected APIURL override, got %q", cfg.APIURL) - } - if cfg.APIKey != "key123" { - t.Errorf("expected APIKey override, got %q", cfg.APIKey) - } - if cfg.Model != "override-model" { - t.Errorf("expected Model override, got %q", cfg.Model) - } - if cfg.MultimodalModel != "mm-model" { - t.Errorf("expected MultimodalModel override, got %q", cfg.MultimodalModel) - } - }) + Describe("applyOverrides", func() { + It("should fill empty fields", func() { + cfg := &state.AgentConfig{Name: "test"} + cmd := &AgentRunCMD{ + APIURL: "http://override:8080", + APIKey: "key123", + DefaultModel: "override-model", + MultimodalModel: "mm-model", + TranscriptionModel: "whisper", + TTSModel: "tts-1", + } + cmd.applyOverrides(cfg) + + Expect(cfg.APIURL).To(Equal("http://override:8080")) + Expect(cfg.APIKey).To(Equal("key123")) + Expect(cfg.Model).To(Equal("override-model")) + Expect(cfg.MultimodalModel).To(Equal("mm-model")) + }) + + It("should not overwrite existing values", func() { + cfg := &state.AgentConfig{ + Name: "test", + APIURL: "http://original:8080", + Model: "original-model", + } + cmd := &AgentRunCMD{ + APIURL: "http://override:8080", + DefaultModel: "override-model", + } + cmd.applyOverrides(cfg) - t.Run("does not overwrite existing values", func(t *testing.T) { - cfg := &state.AgentConfig{ - Name: "test", - APIURL: "http://original:8080", - Model: "original-model", - } - cmd := &AgentRunCMD{ - APIURL: "http://override:8080", - DefaultModel: "override-model", - } - cmd.applyOverrides(cfg) - - if cfg.APIURL != "http://original:8080" { - t.Errorf("expected original APIURL preserved, got %q", cfg.APIURL) - } - if cfg.Model != "original-model" { - t.Errorf("expected original Model preserved, got %q", cfg.Model) - } - }) -} - -func TestResolveAgentConfig(t *testing.T) { - t.Run("resolves from file", func(t *testing.T) { - dir := t.TempDir() - cfg := state.AgentConfig{Name: "file-agent", Model: "gpt-4"} - data, _ := json.Marshal(cfg) - path := filepath.Join(dir, "agent.json") - os.WriteFile(path, data, 0644) - - cmd := &AgentRunCMD{AgentRef: path} - loaded, err := cmd.resolveAgentConfig() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if loaded.Name != "file-agent" { - t.Errorf("expected name 'file-agent', got %q", loaded.Name) - } + Expect(cfg.APIURL).To(Equal("http://original:8080")) + Expect(cfg.Model).To(Equal("original-model")) + }) }) - t.Run("resolves from registry", func(t *testing.T) { - cfg := state.AgentConfig{Name: "hub-agent", Model: "llama3"} - data, _ := json.Marshal(cfg) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(data) - })) - defer srv.Close() - - cmd := &AgentRunCMD{AgentRef: "hub-agent", AgentHubURL: srv.URL} - loaded, err := cmd.resolveAgentConfig() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if loaded.Name != "hub-agent" { - t.Errorf("expected name 'hub-agent', got %q", loaded.Name) - } + Describe("resolveAgentConfig", func() { + It("should resolve from file", func() { + dir := GinkgoT().TempDir() + cfg := state.AgentConfig{Name: "file-agent", Model: "gpt-4"} + data, _ := json.Marshal(cfg) + path := filepath.Join(dir, "agent.json") + Expect(os.WriteFile(path, data, 0644)).To(Succeed()) + + cmd := &AgentRunCMD{AgentRef: path} + loaded, err := cmd.resolveAgentConfig() + Expect(err).ToNot(HaveOccurred()) + Expect(loaded.Name).To(Equal("file-agent")) + }) + + It("should resolve from registry", func() { + cfg := state.AgentConfig{Name: "hub-agent", Model: "llama3"} + data, _ := json.Marshal(cfg) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(data) + })) + defer srv.Close() + + cmd := &AgentRunCMD{AgentRef: "hub-agent", AgentHubURL: srv.URL} + loaded, err := cmd.resolveAgentConfig() + Expect(err).ToNot(HaveOccurred()) + Expect(loaded.Name).To(Equal("hub-agent")) + }) }) -} +}) diff --git a/core/cli/cli_suite_test.go b/core/cli/cli_suite_test.go new file mode 100644 index 000000000000..7b04e5086cf7 --- /dev/null +++ b/core/cli/cli_suite_test.go @@ -0,0 +1,13 @@ +package cli + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCLI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CLI test suite") +} From dbf0d30de10b0dcba54361234592e36418804e26 Mon Sep 17 00:00:00 2001 From: localai-bot Date: Tue, 10 Mar 2026 07:43:08 +0000 Subject: [PATCH 3/3] feat(agent): add --prompt flag for direct prompt execution - Add --prompt flag to allow sending a prompt to the agent directly - Add sendPrompt() method to handle HTTP request to agent - AgentHub URL was already using correct format (/agents/%s.json) Addresses PR review feedback for PR #8891 Signed-off-by: localai-bot Signed-off-by: claude-agent-1 --- core/cli/agent.go | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/core/cli/agent.go b/core/cli/agent.go index b3e3eeea299b..2189fde937e6 100644 --- a/core/cli/agent.go +++ b/core/cli/agent.go @@ -8,6 +8,8 @@ import ( "net/http" "os" "os/signal" + "bytes" + "time" "strings" "syscall" @@ -44,6 +46,8 @@ type AgentRunCMD struct { // Registry settings AgentHubURL string `env:"LOCALAI_AGENT_HUB_URL" default:"https://agenthub.localai.io" help:"Agent hub URL for registry lookups" group:"registry"` + // Direct prompt execution + Prompt string `flag:"prompt" env:"AGENT_PROMPT" help:"Optional prompt to send to the agent directly and exit"` } func (a *AgentRunCMD) Run(ctx *cliContext.Context) error { @@ -111,6 +115,11 @@ func (a *AgentRunCMD) Run(ctx *cliContext.Context) error { xlog.Info("Agent started successfully", "name", agentConfig.Name) + // If a prompt was provided, send it to the agent and exit + if a.Prompt != "" { + return a.sendPrompt(agentConfig.Name) + } + // Optionally, if the user specifies a prompt, we will ask the agent directly and exit. // No background service in that case. @@ -224,3 +233,50 @@ func isJSONFile(ref string) bool { info, err := os.Stat(ref) return err == nil && !info.IsDir() } + +// sendPrompt sends a prompt to the agent and prints the response. +func (a *AgentRunCMD) sendPrompt(agentName string) error { + xlog.Info("Sending prompt to agent", "name", agentName, "prompt", a.Prompt) + + // Construct the API request to send a message to the agent + // The agent pool service exposes an endpoint for this + url := fmt.Sprintf("%s/agent/%s/message", a.APIURL, agentName) + + reqBody := map[string]string{ + "content": a.Prompt, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal prompt request: %w", err) + } + + req, err := http.NewRequest("POST", url, strings.NewReader(string(jsonData))) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if a.APIKey != "" { + req.Header.Set("Authorization", "Bearer " + a.APIKey) + } + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send prompt: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to send prompt: HTTP %d - %s", resp.StatusCode, string(body)) + } + + // Stream the response + fmt.Println("\nAgent response:") + fmt.Println(string(bytes.Buffer{})) + + _, err = io.Copy(os.Stdout, resp.Body) + return err +}