mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
refactor(cmd): delete legacy agentic loop dead code from root.go (TAS-28)
Remove runAgenticStep, runAgenticLoop, runInteractiveLoop, addMessagesToHistory, replaceMessagesHistory, AgenticLoopConfig, executeStopHook, runNonInteractiveMode, and runInteractiveMode from root.go. Functions still needed by the script command are moved to script.go.
This commit is contained in:
-526
@@ -789,532 +789,6 @@ func runNormalMode(ctx context.Context) error {
|
||||
return runInteractiveModeBubbleTea(ctx, appInstance, modelName)
|
||||
}
|
||||
|
||||
// AgenticLoopConfig configures the behavior of the unified agentic loop.
|
||||
// This struct controls how the main interaction loop operates, whether in
|
||||
// interactive or non-interactive mode, and manages various UI and session options.
|
||||
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)
|
||||
ApproveToolRun bool // only used in interactive mode
|
||||
|
||||
// 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
|
||||
SessionManager *session.Manager // for session persistence
|
||||
}
|
||||
|
||||
// addMessagesToHistory adds messages to the conversation history and saves to session if available
|
||||
func addMessagesToHistory(messages *[]fantasy.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages ...fantasy.Message) {
|
||||
// Add to local history
|
||||
*messages = append(*messages, newMessages...)
|
||||
|
||||
// Save to session if session manager is available
|
||||
if sessionManager != nil {
|
||||
// Use ReplaceAllMessages to ensure session matches local history exactly
|
||||
if err := sessionManager.ReplaceAllMessages(*messages); err != nil {
|
||||
// Log error but don't fail the operation
|
||||
if cli != nil {
|
||||
cli.DisplayError(fmt.Errorf("failed to save messages to session: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replaceMessagesHistory replaces the conversation history and saves to session if available
|
||||
func replaceMessagesHistory(messages *[]fantasy.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages []fantasy.Message) {
|
||||
// Replace local history
|
||||
*messages = newMessages
|
||||
|
||||
// Save to session if session manager is available
|
||||
if sessionManager != nil {
|
||||
// Use ReplaceAllMessages to ensure session matches local history exactly
|
||||
if err := sessionManager.ReplaceAllMessages(*messages); err != nil {
|
||||
// Log error but don't fail the operation
|
||||
if cli != nil {
|
||||
cli.DisplayError(fmt.Errorf("failed to save messages to session: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runAgenticLoop handles all execution modes with a single unified loop
|
||||
func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
|
||||
// Handle initial prompt for non-interactive modes
|
||||
if !config.IsInteractive && config.InitialPrompt != "" {
|
||||
// Execute UserPromptSubmit hooks for non-interactive mode
|
||||
if hookExecutor != nil {
|
||||
input := &hooks.UserPromptSubmitInput{
|
||||
CommonInput: hookExecutor.PopulateCommonFields(hooks.UserPromptSubmit),
|
||||
Prompt: config.InitialPrompt,
|
||||
}
|
||||
|
||||
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.UserPromptSubmit, input)
|
||||
if err != nil {
|
||||
// Log error but don't fail
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "UserPromptSubmit hook execution error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if hook blocked the prompt
|
||||
if hookOutput != nil && hookOutput.Decision == "block" {
|
||||
return fmt.Errorf("prompt blocked by hook: %s", hookOutput.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Display user message (skip if quiet)
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayUserMessage(config.InitialPrompt)
|
||||
}
|
||||
|
||||
// Create temporary messages with user input for processing (don't add to history yet)
|
||||
tempMessages := append(messages, fantasy.NewUserMessage(config.InitialPrompt))
|
||||
|
||||
// Process the initial prompt with tool calls
|
||||
_, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config, hookExecutor)
|
||||
if err != nil {
|
||||
// Check if this was a user cancellation
|
||||
if err.Error() == "generation cancelled by user" && cli != nil {
|
||||
cli.DisplayCancellation()
|
||||
// On cancellation, continue to interactive mode (like --no-exit)
|
||||
// Don't add the cancelled message to history
|
||||
config.IsInteractive = true
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Only add to history after successful completion
|
||||
// conversationMessages already includes the user message, tool calls, and final response
|
||||
replaceMessagesHistory(&messages, config.SessionManager, cli, conversationMessages)
|
||||
|
||||
// If not continuing to interactive mode, exit here
|
||||
if !config.ContinueAfterRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update config for interactive mode continuation
|
||||
config.IsInteractive = true
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive loop (or continuation after non-interactive)
|
||||
if config.IsInteractive {
|
||||
return runInteractiveLoop(ctx, mcpAgent, cli, messages, config, hookExecutor)
|
||||
}
|
||||
|
||||
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 []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) (*fantasy.Response, []fantasy.Message, error) {
|
||||
var currentSpinner *ui.Spinner
|
||||
|
||||
// Start initial spinner (skip if quiet)
|
||||
if !config.Quiet && cli != nil {
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
}
|
||||
|
||||
// Create streaming callback for real-time display
|
||||
var streamingCallback agent.StreamingResponseHandler
|
||||
var responseWasStreamed bool
|
||||
var lastDisplayedContent string
|
||||
var streamingContent strings.Builder
|
||||
var streamingStarted bool
|
||||
if cli != nil && !config.Quiet {
|
||||
streamingCallback = func(chunk string) {
|
||||
// Stop spinner before first chunk if still running
|
||||
if currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
// Mark that this response is being streamed
|
||||
responseWasStreamed = true
|
||||
|
||||
// Accumulate content and update message
|
||||
if !streamingStarted {
|
||||
streamingStarted = true
|
||||
streamingContent.Reset() // Reset content for new streaming session
|
||||
}
|
||||
streamingContent.WriteString(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset streaming state before agent execution
|
||||
responseWasStreamed = false
|
||||
streamingStarted = false
|
||||
streamingContent.Reset()
|
||||
|
||||
// Variables to store tool information for hooks
|
||||
var currentToolName string
|
||||
var currentToolArgs string
|
||||
var toolIsBlocked bool
|
||||
var blockReason string
|
||||
|
||||
result, err := mcpAgent.GenerateWithLoopAndStreaming(ctx, messages,
|
||||
// Tool call handler - called when a tool is about to be executed
|
||||
func(toolName, toolArgs string) {
|
||||
// Store tool info for use in execution handler
|
||||
currentToolName = toolName
|
||||
currentToolArgs = toolArgs
|
||||
|
||||
if !config.Quiet && cli != nil {
|
||||
// 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 {
|
||||
// Execute PreToolUse hooks
|
||||
if hookExecutor != nil {
|
||||
input := &hooks.PreToolUseInput{
|
||||
CommonInput: hookExecutor.PopulateCommonFields(hooks.PreToolUse),
|
||||
ToolName: currentToolName,
|
||||
ToolInput: json.RawMessage(currentToolArgs),
|
||||
}
|
||||
|
||||
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.PreToolUse, input)
|
||||
if err != nil {
|
||||
// Log error but don't fail the tool execution
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "Hook execution error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if hook blocked the execution
|
||||
if hookOutput != nil && hookOutput.Decision == "block" {
|
||||
toolIsBlocked = true
|
||||
blockReason = hookOutput.Reason
|
||||
if blockReason == "" {
|
||||
blockReason = "Tool execution blocked by security policy"
|
||||
}
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayInfo(fmt.Sprintf("Tool execution blocked by hook: %s", blockReason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !config.Quiet && cli != nil {
|
||||
// Start spinner for tool execution
|
||||
currentSpinner = ui.NewSpinner(fmt.Sprintf("Executing %s...", toolName))
|
||||
currentSpinner.Start()
|
||||
}
|
||||
} else {
|
||||
// Stop spinner when tool execution completes
|
||||
if !config.Quiet && cli != nil && currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
}
|
||||
},
|
||||
// Tool result handler - called when a tool execution completes
|
||||
func(toolName, toolArgs, result string, isError bool) {
|
||||
// Check if this tool was blocked
|
||||
if toolIsBlocked {
|
||||
// Reset the flag for next tool
|
||||
toolIsBlocked = false
|
||||
|
||||
// Display the blocked message
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayToolMessage(toolName, toolArgs, fmt.Sprintf("Tool execution blocked: %s", blockReason), true)
|
||||
}
|
||||
|
||||
// Reset block reason
|
||||
blockReason = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Execute PostToolUse hooks
|
||||
var postToolHookOutput *hooks.HookOutput
|
||||
if hookExecutor != nil && result != "" {
|
||||
input := &hooks.PostToolUseInput{
|
||||
CommonInput: hookExecutor.PopulateCommonFields(hooks.PostToolUse),
|
||||
ToolName: currentToolName,
|
||||
ToolInput: json.RawMessage(currentToolArgs),
|
||||
ToolResponse: json.RawMessage(result),
|
||||
}
|
||||
|
||||
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.PostToolUse, input)
|
||||
if err != nil {
|
||||
// Log error but don't fail
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "PostToolUse hook execution error: %v\n", err)
|
||||
}
|
||||
}
|
||||
postToolHookOutput = hookOutput
|
||||
}
|
||||
|
||||
// Check if hook wants to suppress output
|
||||
if postToolHookOutput != nil && postToolHookOutput.SuppressOutput {
|
||||
// Skip displaying tool result to user
|
||||
// Note: Result still goes to LLM unless ModifyOutput is used
|
||||
return
|
||||
}
|
||||
|
||||
if !config.Quiet && cli != nil {
|
||||
// Parse tool result content - it might be JSON-encoded MCP content
|
||||
resultContent := result
|
||||
|
||||
// Try to parse as MCP content structure
|
||||
var mcpContent struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
|
||||
// First try to unmarshal as-is
|
||||
if err := json.Unmarshal([]byte(result), &mcpContent); err == nil {
|
||||
// Extract text from MCP content structure
|
||||
if len(mcpContent.Content) > 0 && mcpContent.Content[0].Type == "text" {
|
||||
resultContent = mcpContent.Content[0].Text
|
||||
}
|
||||
} else {
|
||||
// If that fails, try unquoting first (in case it's double-encoded)
|
||||
var unquoted string
|
||||
if err := json.Unmarshal([]byte(result), &unquoted); err == nil {
|
||||
if err := json.Unmarshal([]byte(unquoted), &mcpContent); err == nil {
|
||||
if len(mcpContent.Content) > 0 && mcpContent.Content[0].Type == "text" {
|
||||
resultContent = mcpContent.Content[0].Text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli.DisplayToolMessage(toolName, toolArgs, resultContent, isError)
|
||||
// Reset streaming state for next LLM call
|
||||
responseWasStreamed = false
|
||||
streamingStarted = false
|
||||
// Start spinner again for next LLM call
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
}
|
||||
},
|
||||
// Response handler - called when the LLM generates a response
|
||||
func(content string) {
|
||||
if !config.Quiet && cli != nil {
|
||||
// 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) {
|
||||
if !config.Quiet && cli != nil && !responseWasStreamed {
|
||||
// Only display if content wasn't already streamed
|
||||
// Stop spinner before displaying content
|
||||
if currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
_ = cli.DisplayAssistantMessageWithModel(content, config.ModelName)
|
||||
lastDisplayedContent = content
|
||||
// Start spinner again for tool calls
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
} else if responseWasStreamed {
|
||||
// Content was already streamed, just track it and manage spinner
|
||||
lastDisplayedContent = content
|
||||
if currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
// Start spinner again for tool calls
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
}
|
||||
},
|
||||
// Add streaming callback handler
|
||||
streamingCallback,
|
||||
// Tool call approval handler - called before tool execution to get user approval
|
||||
func(toolName, toolArgs string) (bool, error) {
|
||||
if !config.IsInteractive || !config.ApproveToolRun {
|
||||
return true, nil
|
||||
}
|
||||
if currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
// Tool approval via CLI is no longer supported; always approve in legacy path.
|
||||
// Start spinner again for tool calls
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
|
||||
return true, nil
|
||||
},
|
||||
)
|
||||
|
||||
// Make sure spinner is stopped if still running
|
||||
if !config.Quiet && cli != nil && currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayError(fmt.Errorf("agent error: %v", err))
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Get the final response and conversation messages
|
||||
response := result.FinalResponse
|
||||
conversationMessages := result.ConversationMessages
|
||||
|
||||
// Extract the last user message for usage tracking (do this once)
|
||||
lastUserMessage := ""
|
||||
if len(messages) > 0 {
|
||||
// Find the last user message
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == fantasy.MessageRoleUser {
|
||||
// Extract text from message parts
|
||||
for _, part := range messages[i].Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
lastUserMessage = tp.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get text content from response
|
||||
responseText := response.Content.Text()
|
||||
|
||||
// Update usage tracking for ALL responses (streaming and non-streaming)
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.UpdateUsageFromResponse(response, lastUserMessage)
|
||||
}
|
||||
|
||||
// Display assistant response with model name
|
||||
// Skip if: quiet mode, same content already displayed, or if streaming completed the full response
|
||||
streamedFullResponse := responseWasStreamed && streamingContent.String() == responseText
|
||||
if !config.Quiet && cli != nil && responseText != lastDisplayedContent && responseText != "" && !streamedFullResponse {
|
||||
if err := cli.DisplayAssistantMessageWithModel(responseText, config.ModelName); err != nil {
|
||||
cli.DisplayError(fmt.Errorf("display error: %v", err))
|
||||
return nil, nil, err
|
||||
}
|
||||
} else if config.Quiet {
|
||||
// In quiet mode, only output the final response content to stdout
|
||||
fmt.Print(responseText)
|
||||
}
|
||||
|
||||
// Display usage information immediately after the response (for both streaming and non-streaming)
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayUsageAfterResponse()
|
||||
}
|
||||
|
||||
// Execute Stop hook after agent has finished responding
|
||||
executeStopHook(hookExecutor, response, "completed", config.ModelName)
|
||||
|
||||
// Return the final response and all conversation messages
|
||||
return response, conversationMessages, nil
|
||||
}
|
||||
|
||||
// executeStopHook executes the Stop hook if a hook executor is available
|
||||
func executeStopHook(hookExecutor *hooks.Executor, response *fantasy.Response, stopReason string, modelName string) {
|
||||
if hookExecutor != nil {
|
||||
// Prepare metadata
|
||||
var meta json.RawMessage
|
||||
if response != nil {
|
||||
metaData := map[string]any{
|
||||
"model": modelName,
|
||||
"has_tool_calls": len(response.Content.ToolCalls()) > 0,
|
||||
}
|
||||
if metaBytes, err := json.Marshal(metaData); err == nil {
|
||||
meta = json.RawMessage(metaBytes)
|
||||
}
|
||||
}
|
||||
|
||||
responseContent := ""
|
||||
if response != nil {
|
||||
responseContent = response.Content.Text()
|
||||
}
|
||||
|
||||
input := &hooks.StopInput{
|
||||
CommonInput: hookExecutor.PopulateCommonFields(hooks.Stop),
|
||||
StopHookActive: true,
|
||||
Response: responseContent,
|
||||
StopReason: stopReason,
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
// Execute Stop hook (ignore errors as we're exiting anyway)
|
||||
_, _ = hookExecutor.ExecuteHooks(context.Background(), hooks.Stop, input)
|
||||
}
|
||||
}
|
||||
|
||||
// runInteractiveLoop handles the interactive portion of the agentic loop.
|
||||
// Deprecated: replaced by runInteractiveModeBubbleTea; will be deleted in TAS-28.
|
||||
func runInteractiveLoop(_ context.Context, _ *agent.Agent, _ *ui.CLI, _ []fantasy.Message, _ AgenticLoopConfig, _ *hooks.Executor) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runNonInteractiveMode handles the non-interactive mode execution
|
||||
func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, prompt, modelName string, messages []fantasy.Message, quiet, noExit bool, mcpConfig *config.Config, sessionManager *session.Manager, hookExecutor *hooks.Executor) 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 {
|
||||
info := tool.Info()
|
||||
toolNames = append(toolNames, info.Name)
|
||||
}
|
||||
|
||||
// Configure and run unified agentic loop
|
||||
config := AgenticLoopConfig{
|
||||
IsInteractive: false,
|
||||
InitialPrompt: prompt,
|
||||
ContinueAfterRun: noExit,
|
||||
ApproveToolRun: false,
|
||||
Quiet: quiet,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
ModelName: modelName,
|
||||
MCPConfig: mcpConfig,
|
||||
SessionManager: sessionManager,
|
||||
}
|
||||
|
||||
return runAgenticLoop(ctx, mcpAgent, cli, messages, config, hookExecutor)
|
||||
}
|
||||
|
||||
// runInteractiveMode handles the interactive mode execution (legacy path, kept for reference)
|
||||
func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, serverNames, toolNames []string, modelName string, messages []fantasy.Message, sessionManager *session.Manager, hookExecutor *hooks.Executor, approveToolRun bool) error {
|
||||
// Configure and run unified agentic loop
|
||||
config := AgenticLoopConfig{
|
||||
IsInteractive: true,
|
||||
InitialPrompt: "",
|
||||
ContinueAfterRun: false,
|
||||
ApproveToolRun: approveToolRun,
|
||||
Quiet: false,
|
||||
ServerNames: serverNames,
|
||||
ToolNames: toolNames,
|
||||
ModelName: modelName,
|
||||
MCPConfig: nil, // Not needed for pure interactive mode
|
||||
SessionManager: sessionManager,
|
||||
}
|
||||
|
||||
return runAgenticLoop(ctx, mcpAgent, cli, messages, config, hookExecutor)
|
||||
}
|
||||
|
||||
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
|
||||
// or transitions to the interactive BubbleTea TUI when --no-exit is set.
|
||||
//
|
||||
|
||||
+450
@@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/mark3labs/mcphost/internal/config"
|
||||
"github.com/mark3labs/mcphost/internal/hooks"
|
||||
"github.com/mark3labs/mcphost/internal/models"
|
||||
"github.com/mark3labs/mcphost/internal/session"
|
||||
"github.com/mark3labs/mcphost/internal/tools"
|
||||
"github.com/mark3labs/mcphost/internal/ui"
|
||||
|
||||
@@ -718,3 +720,451 @@ func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string,
|
||||
|
||||
return runAgenticLoop(ctx, mcpAgent, cli, messages, config, hookExecutor)
|
||||
}
|
||||
|
||||
// AgenticLoopConfig configures the behavior of the unified agentic loop.
|
||||
// This struct controls how the main interaction loop operates, whether in
|
||||
// interactive or non-interactive mode, and manages various UI and session options.
|
||||
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)
|
||||
ApproveToolRun bool // only used in interactive mode
|
||||
|
||||
// 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
|
||||
SessionManager *session.Manager // for session persistence
|
||||
}
|
||||
|
||||
// replaceMessagesHistory replaces the conversation history and saves to session if available
|
||||
func replaceMessagesHistory(messages *[]fantasy.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages []fantasy.Message) {
|
||||
// Replace local history
|
||||
*messages = newMessages
|
||||
|
||||
// Save to session if session manager is available
|
||||
if sessionManager != nil {
|
||||
// Use ReplaceAllMessages to ensure session matches local history exactly
|
||||
if err := sessionManager.ReplaceAllMessages(*messages); err != nil {
|
||||
// Log error but don't fail the operation
|
||||
if cli != nil {
|
||||
cli.DisplayError(fmt.Errorf("failed to save messages to session: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runAgenticLoop handles all execution modes with a single unified loop
|
||||
func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
|
||||
// Handle initial prompt for non-interactive modes
|
||||
if !config.IsInteractive && config.InitialPrompt != "" {
|
||||
// Execute UserPromptSubmit hooks for non-interactive mode
|
||||
if hookExecutor != nil {
|
||||
input := &hooks.UserPromptSubmitInput{
|
||||
CommonInput: hookExecutor.PopulateCommonFields(hooks.UserPromptSubmit),
|
||||
Prompt: config.InitialPrompt,
|
||||
}
|
||||
|
||||
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.UserPromptSubmit, input)
|
||||
if err != nil {
|
||||
// Log error but don't fail
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "UserPromptSubmit hook execution error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if hook blocked the prompt
|
||||
if hookOutput != nil && hookOutput.Decision == "block" {
|
||||
return fmt.Errorf("prompt blocked by hook: %s", hookOutput.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Display user message (skip if quiet)
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayUserMessage(config.InitialPrompt)
|
||||
}
|
||||
|
||||
// Create temporary messages with user input for processing (don't add to history yet)
|
||||
tempMessages := append(messages, fantasy.NewUserMessage(config.InitialPrompt))
|
||||
|
||||
// Process the initial prompt with tool calls
|
||||
_, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config, hookExecutor)
|
||||
if err != nil {
|
||||
// Check if this was a user cancellation
|
||||
if err.Error() == "generation cancelled by user" && cli != nil {
|
||||
cli.DisplayCancellation()
|
||||
// On cancellation, continue to interactive mode (like --no-exit)
|
||||
// Don't add the cancelled message to history
|
||||
config.IsInteractive = true
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Only add to history after successful completion
|
||||
// conversationMessages already includes the user message, tool calls, and final response
|
||||
replaceMessagesHistory(&messages, config.SessionManager, cli, conversationMessages)
|
||||
|
||||
// If not continuing to interactive mode, exit here
|
||||
if !config.ContinueAfterRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update config for interactive mode continuation
|
||||
config.IsInteractive = true
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive loop (or continuation after non-interactive): not supported in script mode.
|
||||
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 []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) (*fantasy.Response, []fantasy.Message, error) {
|
||||
var currentSpinner *ui.Spinner
|
||||
|
||||
// Start initial spinner (skip if quiet)
|
||||
if !config.Quiet && cli != nil {
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
}
|
||||
|
||||
// Create streaming callback for real-time display
|
||||
var streamingCallback agent.StreamingResponseHandler
|
||||
var responseWasStreamed bool
|
||||
var lastDisplayedContent string
|
||||
var streamingContent strings.Builder
|
||||
var streamingStarted bool
|
||||
if cli != nil && !config.Quiet {
|
||||
streamingCallback = func(chunk string) {
|
||||
// Stop spinner before first chunk if still running
|
||||
if currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
// Mark that this response is being streamed
|
||||
responseWasStreamed = true
|
||||
|
||||
// Accumulate content and update message
|
||||
if !streamingStarted {
|
||||
streamingStarted = true
|
||||
streamingContent.Reset() // Reset content for new streaming session
|
||||
}
|
||||
streamingContent.WriteString(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset streaming state before agent execution
|
||||
responseWasStreamed = false
|
||||
streamingStarted = false
|
||||
streamingContent.Reset()
|
||||
|
||||
// Variables to store tool information for hooks
|
||||
var currentToolName string
|
||||
var currentToolArgs string
|
||||
var toolIsBlocked bool
|
||||
var blockReason string
|
||||
|
||||
result, err := mcpAgent.GenerateWithLoopAndStreaming(ctx, messages,
|
||||
// Tool call handler - called when a tool is about to be executed
|
||||
func(toolName, toolArgs string) {
|
||||
// Store tool info for use in execution handler
|
||||
currentToolName = toolName
|
||||
currentToolArgs = toolArgs
|
||||
|
||||
if !config.Quiet && cli != nil {
|
||||
// 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 {
|
||||
// Execute PreToolUse hooks
|
||||
if hookExecutor != nil {
|
||||
input := &hooks.PreToolUseInput{
|
||||
CommonInput: hookExecutor.PopulateCommonFields(hooks.PreToolUse),
|
||||
ToolName: currentToolName,
|
||||
ToolInput: json.RawMessage(currentToolArgs),
|
||||
}
|
||||
|
||||
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.PreToolUse, input)
|
||||
if err != nil {
|
||||
// Log error but don't fail the tool execution
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "Hook execution error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if hook blocked the execution
|
||||
if hookOutput != nil && hookOutput.Decision == "block" {
|
||||
toolIsBlocked = true
|
||||
blockReason = hookOutput.Reason
|
||||
if blockReason == "" {
|
||||
blockReason = "Tool execution blocked by security policy"
|
||||
}
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayInfo(fmt.Sprintf("Tool execution blocked by hook: %s", blockReason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !config.Quiet && cli != nil {
|
||||
// Start spinner for tool execution
|
||||
currentSpinner = ui.NewSpinner(fmt.Sprintf("Executing %s...", toolName))
|
||||
currentSpinner.Start()
|
||||
}
|
||||
} else {
|
||||
// Stop spinner when tool execution completes
|
||||
if !config.Quiet && cli != nil && currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
}
|
||||
},
|
||||
// Tool result handler - called when a tool execution completes
|
||||
func(toolName, toolArgs, result string, isError bool) {
|
||||
// Check if this tool was blocked
|
||||
if toolIsBlocked {
|
||||
// Reset the flag for next tool
|
||||
toolIsBlocked = false
|
||||
|
||||
// Display the blocked message
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayToolMessage(toolName, toolArgs, fmt.Sprintf("Tool execution blocked: %s", blockReason), true)
|
||||
}
|
||||
|
||||
// Reset block reason
|
||||
blockReason = ""
|
||||
return
|
||||
}
|
||||
|
||||
// Execute PostToolUse hooks
|
||||
var postToolHookOutput *hooks.HookOutput
|
||||
if hookExecutor != nil && result != "" {
|
||||
input := &hooks.PostToolUseInput{
|
||||
CommonInput: hookExecutor.PopulateCommonFields(hooks.PostToolUse),
|
||||
ToolName: currentToolName,
|
||||
ToolInput: json.RawMessage(currentToolArgs),
|
||||
ToolResponse: json.RawMessage(result),
|
||||
}
|
||||
|
||||
hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.PostToolUse, input)
|
||||
if err != nil {
|
||||
// Log error but don't fail
|
||||
if debugMode {
|
||||
fmt.Fprintf(os.Stderr, "PostToolUse hook execution error: %v\n", err)
|
||||
}
|
||||
}
|
||||
postToolHookOutput = hookOutput
|
||||
}
|
||||
|
||||
// Check if hook wants to suppress output
|
||||
if postToolHookOutput != nil && postToolHookOutput.SuppressOutput {
|
||||
// Skip displaying tool result to user
|
||||
// Note: Result still goes to LLM unless ModifyOutput is used
|
||||
return
|
||||
}
|
||||
|
||||
if !config.Quiet && cli != nil {
|
||||
// Parse tool result content - it might be JSON-encoded MCP content
|
||||
resultContent := result
|
||||
|
||||
// Try to parse as MCP content structure
|
||||
var mcpContent struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
|
||||
// First try to unmarshal as-is
|
||||
if err := json.Unmarshal([]byte(result), &mcpContent); err == nil {
|
||||
// Extract text from MCP content structure
|
||||
if len(mcpContent.Content) > 0 && mcpContent.Content[0].Type == "text" {
|
||||
resultContent = mcpContent.Content[0].Text
|
||||
}
|
||||
} else {
|
||||
// If that fails, try unquoting first (in case it's double-encoded)
|
||||
var unquoted string
|
||||
if err := json.Unmarshal([]byte(result), &unquoted); err == nil {
|
||||
if err := json.Unmarshal([]byte(unquoted), &mcpContent); err == nil {
|
||||
if len(mcpContent.Content) > 0 && mcpContent.Content[0].Type == "text" {
|
||||
resultContent = mcpContent.Content[0].Text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli.DisplayToolMessage(toolName, toolArgs, resultContent, isError)
|
||||
// Reset streaming state for next LLM call
|
||||
responseWasStreamed = false
|
||||
streamingStarted = false
|
||||
// Start spinner again for next LLM call
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
}
|
||||
},
|
||||
// Response handler - called when the LLM generates a response
|
||||
func(content string) {
|
||||
if !config.Quiet && cli != nil {
|
||||
// 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) {
|
||||
if !config.Quiet && cli != nil && !responseWasStreamed {
|
||||
// Only display if content wasn't already streamed
|
||||
// Stop spinner before displaying content
|
||||
if currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
_ = cli.DisplayAssistantMessageWithModel(content, config.ModelName)
|
||||
lastDisplayedContent = content
|
||||
// Start spinner again for tool calls
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
} else if responseWasStreamed {
|
||||
// Content was already streamed, just track it and manage spinner
|
||||
lastDisplayedContent = content
|
||||
if currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
// Start spinner again for tool calls
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
}
|
||||
},
|
||||
// Add streaming callback handler
|
||||
streamingCallback,
|
||||
// Tool call approval handler - called before tool execution to get user approval
|
||||
func(toolName, toolArgs string) (bool, error) {
|
||||
if !config.IsInteractive || !config.ApproveToolRun {
|
||||
return true, nil
|
||||
}
|
||||
if currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
currentSpinner = nil
|
||||
}
|
||||
// Tool approval via CLI is no longer supported; always approve in legacy path.
|
||||
// Start spinner again for tool calls
|
||||
currentSpinner = ui.NewSpinner("")
|
||||
currentSpinner.Start()
|
||||
|
||||
return true, nil
|
||||
},
|
||||
)
|
||||
|
||||
// Make sure spinner is stopped if still running
|
||||
if !config.Quiet && cli != nil && currentSpinner != nil {
|
||||
currentSpinner.Stop()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayError(fmt.Errorf("agent error: %v", err))
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Get the final response and conversation messages
|
||||
response := result.FinalResponse
|
||||
conversationMessages := result.ConversationMessages
|
||||
|
||||
// Extract the last user message for usage tracking (do this once)
|
||||
lastUserMessage := ""
|
||||
if len(messages) > 0 {
|
||||
// Find the last user message
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == fantasy.MessageRoleUser {
|
||||
// Extract text from message parts
|
||||
for _, part := range messages[i].Content {
|
||||
if tp, ok := part.(fantasy.TextPart); ok {
|
||||
lastUserMessage = tp.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get text content from response
|
||||
responseText := response.Content.Text()
|
||||
|
||||
// Update usage tracking for ALL responses (streaming and non-streaming)
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.UpdateUsageFromResponse(response, lastUserMessage)
|
||||
}
|
||||
|
||||
// Display assistant response with model name
|
||||
// Skip if: quiet mode, same content already displayed, or if streaming completed the full response
|
||||
streamedFullResponse := responseWasStreamed && streamingContent.String() == responseText
|
||||
if !config.Quiet && cli != nil && responseText != lastDisplayedContent && responseText != "" && !streamedFullResponse {
|
||||
if err := cli.DisplayAssistantMessageWithModel(responseText, config.ModelName); err != nil {
|
||||
cli.DisplayError(fmt.Errorf("display error: %v", err))
|
||||
return nil, nil, err
|
||||
}
|
||||
} else if config.Quiet {
|
||||
// In quiet mode, only output the final response content to stdout
|
||||
fmt.Print(responseText)
|
||||
}
|
||||
|
||||
// Display usage information immediately after the response (for both streaming and non-streaming)
|
||||
if !config.Quiet && cli != nil {
|
||||
cli.DisplayUsageAfterResponse()
|
||||
}
|
||||
|
||||
// Execute Stop hook after agent has finished responding
|
||||
executeStopHook(hookExecutor, response, "completed", config.ModelName)
|
||||
|
||||
// Return the final response and all conversation messages
|
||||
return response, conversationMessages, nil
|
||||
}
|
||||
|
||||
// executeStopHook executes the Stop hook if a hook executor is available
|
||||
func executeStopHook(hookExecutor *hooks.Executor, response *fantasy.Response, stopReason string, modelName string) {
|
||||
if hookExecutor != nil {
|
||||
// Prepare metadata
|
||||
var meta json.RawMessage
|
||||
if response != nil {
|
||||
metaData := map[string]any{
|
||||
"model": modelName,
|
||||
"has_tool_calls": len(response.Content.ToolCalls()) > 0,
|
||||
}
|
||||
if metaBytes, err := json.Marshal(metaData); err == nil {
|
||||
meta = json.RawMessage(metaBytes)
|
||||
}
|
||||
}
|
||||
|
||||
responseContent := ""
|
||||
if response != nil {
|
||||
responseContent = response.Content.Text()
|
||||
}
|
||||
|
||||
input := &hooks.StopInput{
|
||||
CommonInput: hookExecutor.PopulateCommonFields(hooks.Stop),
|
||||
StopHookActive: true,
|
||||
Response: responseContent,
|
||||
StopReason: stopReason,
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
// Execute Stop hook (ignore errors as we're exiting anyway)
|
||||
_, _ = hookExecutor.ExecuteHooks(context.Background(), hooks.Stop, input)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user