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:
Ed Zynda
2026-02-26 02:04:57 +03:00
parent 154d693a8e
commit ee66477498
2 changed files with 450 additions and 526 deletions
-526
View File
@@ -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
View File
@@ -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)
}
}