From d273436091e12e3e2f48d755cbef6b40049a4014 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 17 Jun 2025 20:54:18 +0300 Subject: [PATCH] refactor --- AGENTS.md | 30 ++++++ cmd/root.go | 248 +++++++++++++++++++++++++------------------------- cmd/script.go | 217 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 357 insertions(+), 138 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1e85ae74 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# Agent Development Guide + +## Build/Test Commands +- **Build**: `go build -o mcphost .` or `go install` +- **Run**: `go run main.go` or `./mcphost` +- **Test**: `go test ./...` (run all tests) +- **Test single package**: `go test ./internal/config` +- **Lint**: `go vet ./...` and `gofmt -s -w .` +- **Dependencies**: `go mod tidy` and `go mod download` + +## Code Style Guidelines +- **Imports**: Standard library first, then third-party, then local packages (separated by blank lines) +- **Naming**: Use camelCase for variables/functions, PascalCase for exported types +- **Error handling**: Always check errors, wrap with context using `fmt.Errorf("context: %v", err)` +- **Comments**: Use `//` for single line, document exported functions/types +- **Structs**: Use struct tags for JSON/YAML serialization (`json:"field" yaml:"field"`) +- **Interfaces**: Keep interfaces small and focused (e.g., `tool.BaseTool`, `model.ToolCallingChatModel`) + +## Architecture +- **cmd/**: CLI commands and flag handling using Cobra +- **internal/**: Private application code (agent, config, models, tools, ui) +- **main.go**: Entry point, delegates to cmd package +- **go.mod**: Go 1.23+ required, uses Eino framework for LLM integration + +## Key Patterns +- Use context.Context for cancellation and timeouts +- Implement proper resource cleanup with defer statements +- Use viper for configuration management (supports YAML/JSON) +- Follow MCP (Model Context Protocol) for tool integration +- Use structured logging and error wrapping \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index 3ab9446a..a84fdd3d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -344,22 +344,68 @@ func runNormalMode(ctx context.Context) error { return runInteractiveMode(ctx, mcpAgent, cli, serverNames, toolNames, modelName, messages) } -// runNonInteractiveMode handles the non-interactive mode execution -func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, prompt, modelName string, messages []*schema.Message, quiet, noExit bool, mcpConfig *config.Config) error { - // Display user message (skip if quiet) - if !quiet && cli != nil { - cli.DisplayUserMessage(prompt) +// AgenticLoopConfig configures the behavior of the unified agentic loop +type AgenticLoopConfig struct { + // Mode configuration + IsInteractive bool // true for interactive mode, false for non-interactive + InitialPrompt string // initial prompt for non-interactive mode + ContinueAfterRun bool // true to continue to interactive mode after initial run (--no-exit) + + // UI configuration + Quiet bool // suppress all output except final response + + // Context data + ServerNames []string // for slash commands + ToolNames []string // for slash commands + ModelName string // for display + MCPConfig *config.Config // for continuing to interactive mode +} + +// runAgenticLoop handles all execution modes with a single unified loop +func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []*schema.Message, config AgenticLoopConfig) error { + // Handle initial prompt for non-interactive modes + if !config.IsInteractive && config.InitialPrompt != "" { + // Display user message (skip if quiet) + if !config.Quiet && cli != nil { + cli.DisplayUserMessage(config.InitialPrompt) + } + + // Add user message to history + messages = append(messages, schema.UserMessage(config.InitialPrompt)) + + // Process the initial prompt with tool calls + response, err := runAgenticStep(ctx, mcpAgent, cli, messages, config) + if err != nil { + return err + } + + // Add assistant response to history + messages = append(messages, response) + + // If not continuing to interactive mode, exit here + if !config.ContinueAfterRun { + return nil + } + + // Update config for interactive mode continuation + config.IsInteractive = true + config.Quiet = false // Can't be quiet in interactive mode } - // Add user message to history - messages = append(messages, schema.UserMessage(prompt)) + // Interactive loop (or continuation after non-interactive) + if config.IsInteractive { + return runInteractiveLoop(ctx, mcpAgent, cli, messages, config) + } - // Get agent response with controlled spinner that stops for tool call display - var response *schema.Message + return nil +} + +// runAgenticStep processes a single step of the agentic loop (handles tool calls) +func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []*schema.Message, config AgenticLoopConfig) (*schema.Message, error) { var currentSpinner *ui.Spinner // Start initial spinner (skip if quiet) - if !quiet && cli != nil { + if !config.Quiet && cli != nil { currentSpinner = ui.NewSpinner("Thinking...") currentSpinner.Start() } @@ -367,7 +413,7 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C response, err := mcpAgent.GenerateWithLoop(ctx, messages, // Tool call handler - called when a tool is about to be executed func(toolName, toolArgs string) { - if !quiet && cli != nil { + if !config.Quiet && cli != nil { // Stop spinner before displaying tool call if currentSpinner != nil { currentSpinner.Stop() @@ -378,7 +424,7 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C }, // Tool execution handler - called when tool execution starts/ends func(toolName string, isStarting bool) { - if !quiet && cli != nil { + if !config.Quiet && cli != nil { if isStarting { // Start spinner for tool execution currentSpinner = ui.NewSpinner(fmt.Sprintf("Executing %s...", toolName)) @@ -394,7 +440,7 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C }, // Tool result handler - called when a tool execution completes func(toolName, toolArgs, result string, isError bool) { - if !quiet && cli != nil { + if !config.Quiet && cli != nil { cli.DisplayToolMessage(toolName, toolArgs, result, isError) // Start spinner again for next LLM call currentSpinner = ui.NewSpinner("Thinking...") @@ -403,7 +449,7 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C }, // Response handler - called when the LLM generates a response func(content string) { - if !quiet && cli != nil { + if !config.Quiet && cli != nil { // Stop spinner when we get the final response if currentSpinner != nil { currentSpinner.Stop() @@ -411,16 +457,15 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C } } }, - // Tool call content handler - called when content accompanies tool calls func(content string) { - if !quiet && cli != nil { + if !config.Quiet && cli != nil { // Stop spinner before displaying content if currentSpinner != nil { currentSpinner.Stop() currentSpinner = nil } - cli.DisplayAssistantMessageWithModel(content, modelName) + cli.DisplayAssistantMessageWithModel(content, config.ModelName) // Start spinner again for tool calls currentSpinner = ui.NewSpinner("Thinking...") currentSpinner.Start() @@ -429,58 +474,33 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C ) // Make sure spinner is stopped if still running - if !quiet && cli != nil && currentSpinner != nil { + if !config.Quiet && cli != nil && currentSpinner != nil { currentSpinner.Stop() } + if err != nil { - if !quiet && cli != nil { + if !config.Quiet && cli != nil { cli.DisplayError(fmt.Errorf("agent error: %v", err)) } - return err + return nil, err } // Display assistant response with model name (skip if quiet) - if !quiet && cli != nil { - if err := cli.DisplayAssistantMessageWithModel(response.Content, modelName); err != nil { + if !config.Quiet && cli != nil { + if err := cli.DisplayAssistantMessageWithModel(response.Content, config.ModelName); err != nil { cli.DisplayError(fmt.Errorf("display error: %v", err)) - return err + return nil, err } - } else if quiet { + } else if config.Quiet { // In quiet mode, only output the final response content to stdout fmt.Print(response.Content) } - // Add assistant response to history - messages = append(messages, response) - - // If --no-exit flag is set, continue to interactive mode - if noExit && !quiet && cli != nil { - // Prepare data for slash commands in interactive mode - var serverNames []string - for name := range mcpConfig.MCPServers { - serverNames = append(serverNames, name) - } - - tools := mcpAgent.GetTools() - var toolNames []string - for _, tool := range tools { - if info, err := tool.Info(ctx); err == nil { - toolNames = append(toolNames, info.Name) - } - } - - // Continue to interactive mode - return runInteractiveMode(ctx, mcpAgent, cli, serverNames, toolNames, modelName, messages) - } - - // Exit after displaying the final response (normal behavior) - return nil + return response, nil } -// runInteractiveMode handles the interactive mode execution -func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, serverNames, toolNames []string, modelName string, messages []*schema.Message) error { - - // Main interaction loop +// runInteractiveLoop handles the interactive portion of the agentic loop +func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []*schema.Message, config AgenticLoopConfig) error { for { // Get user input prompt, err := cli.GetPrompt() @@ -498,7 +518,7 @@ func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, // Handle slash commands if cli.IsSlashCommand(prompt) { - if cli.HandleSlashCommand(prompt, serverNames, toolNames, messages) { + if cli.HandleSlashCommand(prompt, config.ServerNames, config.ToolNames, messages) { continue } cli.DisplayError(fmt.Errorf("unknown command: %s", prompt)) @@ -511,82 +531,62 @@ func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, // Add user message to history messages = append(messages, schema.UserMessage(prompt)) - // Get agent response with controlled spinner that stops for tool call display - var response *schema.Message - var currentSpinner *ui.Spinner - - // Start initial spinner - currentSpinner = ui.NewSpinner("Thinking...") - currentSpinner.Start() - - response, err = mcpAgent.GenerateWithLoop(ctx, messages, - // Tool call handler - called when a tool is about to be executed - func(toolName, toolArgs string) { - // Stop spinner before displaying tool call - if currentSpinner != nil { - currentSpinner.Stop() - currentSpinner = nil - } - cli.DisplayToolCallMessage(toolName, toolArgs) - }, - // Tool execution handler - called when tool execution starts/ends - func(toolName string, isStarting bool) { - if isStarting { - // Start spinner for tool execution - currentSpinner = ui.NewSpinner(fmt.Sprintf("Executing %s...", toolName)) - currentSpinner.Start() - } else { - // Stop spinner when tool execution completes - if currentSpinner != nil { - currentSpinner.Stop() - currentSpinner = nil - } - } - }, - // Tool result handler - called when a tool execution completes - func(toolName, toolArgs, result string, isError bool) { - cli.DisplayToolMessage(toolName, toolArgs, result, isError) - // Start spinner again for next LLM call - currentSpinner = ui.NewSpinner("Thinking...") - currentSpinner.Start() - }, - // Response handler - called when the LLM generates a response - func(content string) { - // Stop spinner when we get the final response - if currentSpinner != nil { - currentSpinner.Stop() - currentSpinner = nil - } - }, - // Tool call content handler - called when content accompanies tool calls - func(content string) { - // Stop spinner before displaying content - if currentSpinner != nil { - currentSpinner.Stop() - currentSpinner = nil - } - cli.DisplayAssistantMessageWithModel(content, modelName) - // Start spinner again for tool calls - currentSpinner = ui.NewSpinner("Thinking...") - currentSpinner.Start() - }, - ) - - // Make sure spinner is stopped if still running - if currentSpinner != nil { - currentSpinner.Stop() - } + // Process the user input with tool calls + response, err := runAgenticStep(ctx, mcpAgent, cli, messages, config) if err != nil { cli.DisplayError(fmt.Errorf("agent error: %v", err)) continue } - // Display assistant response with model name - if err := cli.DisplayAssistantMessageWithModel(response.Content, modelName); err != nil { - cli.DisplayError(fmt.Errorf("display error: %v", err)) - } - // Add assistant response to history messages = append(messages, response) } } + +// runNonInteractiveMode handles the non-interactive mode execution +func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, prompt, modelName string, messages []*schema.Message, quiet, noExit bool, mcpConfig *config.Config) error { + // Prepare data for slash commands (needed if continuing to interactive mode) + var serverNames []string + for name := range mcpConfig.MCPServers { + serverNames = append(serverNames, name) + } + + tools := mcpAgent.GetTools() + var toolNames []string + for _, tool := range tools { + if info, err := tool.Info(ctx); err == nil { + toolNames = append(toolNames, info.Name) + } + } + + // Configure and run unified agentic loop + config := AgenticLoopConfig{ + IsInteractive: false, + InitialPrompt: prompt, + ContinueAfterRun: noExit, + Quiet: quiet, + ServerNames: serverNames, + ToolNames: toolNames, + ModelName: modelName, + MCPConfig: mcpConfig, + } + + return runAgenticLoop(ctx, mcpAgent, cli, messages, config) +} + +// runInteractiveMode handles the interactive mode execution +func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, serverNames, toolNames []string, modelName string, messages []*schema.Message) error { + // Configure and run unified agentic loop + config := AgenticLoopConfig{ + IsInteractive: true, + InitialPrompt: "", + ContinueAfterRun: false, + Quiet: false, + ServerNames: serverNames, + ToolNames: toolNames, + ModelName: modelName, + MCPConfig: nil, // Not needed for pure interactive mode + } + + return runAgenticLoop(ctx, mcpAgent, cli, messages, config) +} diff --git a/cmd/script.go b/cmd/script.go index b661a60d..94920fbb 100644 --- a/cmd/script.go +++ b/cmd/script.go @@ -4,11 +4,16 @@ import ( "bufio" "context" "fmt" + "log" "os" "regexp" "strings" + "github.com/cloudwego/eino/schema" + "github.com/mark3labs/mcphost/internal/agent" "github.com/mark3labs/mcphost/internal/config" + "github.com/mark3labs/mcphost/internal/models" + "github.com/mark3labs/mcphost/internal/ui" "github.com/spf13/cobra" "github.com/spf13/viper" "gopkg.in/yaml.v3" @@ -174,29 +179,25 @@ func runScriptCommand(ctx context.Context, scriptFile string, variables map[stri mergeScriptConfig(mcpConfig, scriptConfig) } - // Override the global config for normal mode - scriptMCPConfig = mcpConfig - // Set script values in viper (only if flags weren't explicitly set) setScriptValuesInViper(mcpConfig, cmd) - // Set the prompt flag if it was specified in the script and not overridden by command line - if mcpConfig.Prompt != "" && promptFlag == "" { - promptFlag = mcpConfig.Prompt + // Get final prompt - prioritize command line flag, then script content + finalPrompt := promptFlag + if finalPrompt == "" && mcpConfig.Prompt != "" { + finalPrompt = mcpConfig.Prompt } + // Get final no-exit setting - prioritize command line flag, then script config + finalNoExit := noExitFlag || mcpConfig.NoExit + // Validate that --no-exit is only used when there's a prompt - if noExitFlag && promptFlag == "" { + if finalNoExit && finalPrompt == "" { return fmt.Errorf("--no-exit flag can only be used when there's a prompt (either from script content or --prompt flag)") } - // Clean up script config after execution - defer func() { - scriptMCPConfig = nil - }() - - // Now run the normal execution path which will use our overridden config - return runNormalMode(ctx) + // Run the script using the unified agentic loop + return runScriptMode(ctx, mcpConfig, finalPrompt, finalNoExit) } func mergeScriptConfig(mcpConfig *config.Config, scriptConfig *config.Config) { @@ -452,3 +453,191 @@ func substituteVariables(content string, variables map[string]string) string { return match }) } + +// runScriptMode executes the script using the unified agentic loop +func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string, noExit bool) error { + // Set up logging + if debugMode || mcpConfig.Debug { + log.SetFlags(log.LstdFlags | log.Lshortfile) + } + + // Get final values from viper and script config + finalModel := viper.GetString("model") + if finalModel == "" && mcpConfig.Model != "" { + finalModel = mcpConfig.Model + } + if finalModel == "" { + finalModel = "anthropic:claude-sonnet-4-20250514" // default + } + + finalSystemPrompt := viper.GetString("system-prompt") + if finalSystemPrompt == "" && mcpConfig.SystemPrompt != "" { + finalSystemPrompt = mcpConfig.SystemPrompt + } + + finalDebug := viper.GetBool("debug") || mcpConfig.Debug + finalMaxSteps := viper.GetInt("max-steps") + if finalMaxSteps == 0 && mcpConfig.MaxSteps != 0 { + finalMaxSteps = mcpConfig.MaxSteps + } + + finalProviderURL := viper.GetString("provider-url") + if finalProviderURL == "" && mcpConfig.ProviderURL != "" { + finalProviderURL = mcpConfig.ProviderURL + } + + finalProviderAPIKey := viper.GetString("provider-api-key") + if finalProviderAPIKey == "" && mcpConfig.ProviderAPIKey != "" { + finalProviderAPIKey = mcpConfig.ProviderAPIKey + } + + finalMaxTokens := viper.GetInt("max-tokens") + if finalMaxTokens == 0 && mcpConfig.MaxTokens != 0 { + finalMaxTokens = mcpConfig.MaxTokens + } + if finalMaxTokens == 0 { + finalMaxTokens = 4096 // default + } + + finalTemperature := float32(viper.GetFloat64("temperature")) + if finalTemperature == 0 && mcpConfig.Temperature != nil { + finalTemperature = *mcpConfig.Temperature + } + if finalTemperature == 0 { + finalTemperature = 0.7 // default + } + + finalTopP := float32(viper.GetFloat64("top-p")) + if finalTopP == 0 && mcpConfig.TopP != nil { + finalTopP = *mcpConfig.TopP + } + if finalTopP == 0 { + finalTopP = 0.95 // default + } + + finalTopK := int32(viper.GetInt("top-k")) + if finalTopK == 0 && mcpConfig.TopK != nil { + finalTopK = *mcpConfig.TopK + } + if finalTopK == 0 { + finalTopK = 40 // default + } + + finalStopSequences := viper.GetStringSlice("stop-sequences") + if len(finalStopSequences) == 0 && len(mcpConfig.StopSequences) > 0 { + finalStopSequences = mcpConfig.StopSequences + } + + // Load system prompt + systemPrompt, err := config.LoadSystemPrompt(finalSystemPrompt) + if err != nil { + return fmt.Errorf("failed to load system prompt: %v", err) + } + + // Create model configuration + modelConfig := &models.ProviderConfig{ + ModelString: finalModel, + SystemPrompt: systemPrompt, + ProviderAPIKey: finalProviderAPIKey, + ProviderURL: finalProviderURL, + MaxTokens: finalMaxTokens, + Temperature: &finalTemperature, + TopP: &finalTopP, + TopK: &finalTopK, + StopSequences: finalStopSequences, + } + + // Create agent configuration + agentConfig := &agent.AgentConfig{ + ModelConfig: modelConfig, + MCPConfig: mcpConfig, + SystemPrompt: systemPrompt, + MaxSteps: finalMaxSteps, + } + + // Create the agent + mcpAgent, err := agent.NewAgent(ctx, agentConfig) + if err != nil { + return fmt.Errorf("failed to create agent: %v", err) + } + defer mcpAgent.Close() + + // Get model name for display + parts := strings.SplitN(finalModel, ":", 2) + modelName := "Unknown" + if len(parts) == 2 { + modelName = parts[1] + } + + // Create CLI interface (skip if quiet mode) + var cli *ui.CLI + if !quietFlag { + cli, err = ui.NewCLI(finalDebug) + if err != nil { + return fmt.Errorf("failed to create CLI: %v", err) + } + + // Log successful initialization + if len(parts) == 2 { + cli.DisplayInfo(fmt.Sprintf("Model loaded: %s (%s)", parts[0], parts[1])) + } + + tools := mcpAgent.GetTools() + cli.DisplayInfo(fmt.Sprintf("Loaded %d tools from MCP servers", len(tools))) + + // Display debug configuration if debug mode is enabled + if finalDebug { + debugConfig := map[string]any{ + "model": finalModel, + "max-steps": finalMaxSteps, + "max-tokens": finalMaxTokens, + "temperature": finalTemperature, + "top-p": finalTopP, + "top-k": finalTopK, + "provider-url": finalProviderURL, + "system-prompt": finalSystemPrompt, + } + + // Only include non-empty stop sequences + if len(finalStopSequences) > 0 { + debugConfig["stop-sequences"] = finalStopSequences + } + + // Only include API keys if they're set (but don't show the actual values for security) + if finalProviderAPIKey != "" { + debugConfig["provider-api-key"] = "[SET]" + } + + cli.DisplayDebugConfig(debugConfig) + } + } + + // Prepare data for slash commands + var serverNames []string + for name := range mcpConfig.MCPServers { + serverNames = append(serverNames, name) + } + + tools := mcpAgent.GetTools() + var toolNames []string + for _, tool := range tools { + if info, err := tool.Info(ctx); err == nil { + toolNames = append(toolNames, info.Name) + } + } + + // Configure and run unified agentic loop + var messages []*schema.Message + config := AgenticLoopConfig{ + IsInteractive: prompt == "", // If no prompt, start in interactive mode + InitialPrompt: prompt, + ContinueAfterRun: noExit, + Quiet: quietFlag, + ServerNames: serverNames, + ToolNames: toolNames, + ModelName: modelName, + MCPConfig: mcpConfig, + } + + return runAgenticLoop(ctx, mcpAgent, cli, messages, config) +}