Files
kit/internal/agent/agent.go
T
Ed Zynda 0703dd1602 fix: eliminate escape sequence leak from spinner tea.Program instances
Each spinner created a new tea.NewProgram which sent DECRQM queries for
synchronized output mode 2026. When the program exited and restored
cooked terminal mode, the terminal's DECRPM response leaked as visible
^[[?2026;2$y characters. Replace Bubble Tea spinner with a simple
goroutine animation loop writing directly to stderr via lipgloss.
2026-02-25 18:17:25 +03:00

429 lines
13 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"github.com/mark3labs/mcphost/internal/config"
"github.com/mark3labs/mcphost/internal/models"
"github.com/mark3labs/mcphost/internal/tools"
)
// AgentConfig holds configuration options for creating a new Agent.
type AgentConfig struct {
ModelConfig *models.ProviderConfig
MCPConfig *config.Config
SystemPrompt string
MaxSteps int
StreamingEnabled bool
DebugLogger tools.DebugLogger
}
// ToolCallHandler is a function type for handling tool calls as they happen.
type ToolCallHandler func(toolName, toolArgs string)
// ToolExecutionHandler is a function type for handling tool execution start/end events.
type ToolExecutionHandler func(toolName string, isStarting bool)
// ToolResultHandler is a function type for handling tool results.
type ToolResultHandler func(toolName, toolArgs, result string, isError bool)
// ResponseHandler is a function type for handling LLM responses.
type ResponseHandler func(content string)
// StreamingResponseHandler is a function type for handling streaming LLM responses.
type StreamingResponseHandler func(content string)
// ToolCallContentHandler is a function type for handling content that accompanies tool calls.
type ToolCallContentHandler func(content string)
// ToolApprovalHandler is a function type for handling user approval of tool calls.
type ToolApprovalHandler func(toolName, toolArgs string) (bool, error)
// Agent represents an AI agent with MCP tool integration using the fantasy library.
// It manages the interaction between an LLM and various tools through the MCP protocol.
type Agent struct {
toolManager *tools.MCPToolManager
fantasyAgent fantasy.Agent
model fantasy.LanguageModel
maxSteps int
systemPrompt string
loadingMessage string
providerType string
streamingEnabled bool
}
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
type GenerateWithLoopResult struct {
// FinalResponse is the last message generated by the model
FinalResponse *fantasy.Response
// ConversationMessages contains all messages in the conversation including tool calls and results
ConversationMessages []fantasy.Message
// TotalUsage contains aggregate token usage across all steps
TotalUsage fantasy.Usage
}
// NewAgent creates a new Agent with MCP tool integration and streaming support.
func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
// Create the LLM provider via fantasy
providerResult, err := models.CreateProvider(ctx, agentConfig.ModelConfig)
if err != nil {
return nil, fmt.Errorf("failed to create model provider: %v", err)
}
// Create and load MCP tools
toolManager := tools.NewMCPToolManager()
toolManager.SetModel(providerResult.Model)
if agentConfig.DebugLogger != nil {
toolManager.SetDebugLogger(agentConfig.DebugLogger)
}
if err := toolManager.LoadTools(ctx, agentConfig.MCPConfig); err != nil {
return nil, fmt.Errorf("failed to load MCP tools: %v", err)
}
// Build fantasy agent options
var agentOpts []fantasy.AgentOption
if agentConfig.SystemPrompt != "" {
agentOpts = append(agentOpts, fantasy.WithSystemPrompt(agentConfig.SystemPrompt))
}
// Register all MCP tools with the fantasy agent
mcpTools := toolManager.GetTools()
if len(mcpTools) > 0 {
agentOpts = append(agentOpts, fantasy.WithTools(mcpTools...))
}
// Set max steps as stop condition
if agentConfig.MaxSteps > 0 {
agentOpts = append(agentOpts, fantasy.WithStopConditions(
fantasy.StepCountIs(agentConfig.MaxSteps),
))
}
// Create the fantasy agent
fantasyAgent := fantasy.NewAgent(providerResult.Model, agentOpts...)
// Determine provider type from model string
providerType := "default"
if agentConfig.ModelConfig != nil && agentConfig.ModelConfig.ModelString != "" {
parts := strings.SplitN(agentConfig.ModelConfig.ModelString, ":", 2)
if len(parts) >= 1 {
providerType = parts[0]
}
}
return &Agent{
toolManager: toolManager,
fantasyAgent: fantasyAgent,
model: providerResult.Model,
maxSteps: agentConfig.MaxSteps,
systemPrompt: agentConfig.SystemPrompt,
loadingMessage: providerResult.Message,
providerType: providerType,
streamingEnabled: agentConfig.StreamingEnabled,
}, nil
}
// GenerateWithLoop processes messages with a custom loop that displays tool calls in real-time.
func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler, onToolApproval ToolApprovalHandler,
) (*GenerateWithLoopResult, error) {
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
onResponse, onToolCallContent, nil, onToolApproval)
}
// GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks.
// Fantasy handles the tool call loop internally. We map fantasy's rich callback system
// to mcphost's existing callback interface for UI integration.
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
onStreamingResponse StreamingResponseHandler, onToolApproval ToolApprovalHandler,
) (*GenerateWithLoopResult, error) {
// Fantasy requires the current user input as Prompt, with prior messages as history.
// Extract the last user message text as the prompt, and pass everything before it as Messages.
prompt, history := splitPromptAndHistory(messages)
// Track current tool call info for callbacks
var currentToolName string
var currentToolArgs string
if a.streamingEnabled {
// Use fantasy's streaming agent
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: prompt,
Messages: history,
// Text streaming callback
OnTextDelta: func(id, text string) error {
if onStreamingResponse != nil {
onStreamingResponse(text)
}
return nil
},
// Tool call complete - the tool has been parsed and is about to execute
OnToolCall: func(tc fantasy.ToolCallContent) error {
currentToolName = tc.ToolName
currentToolArgs = tc.Input
// Check approval if handler is set
if onToolApproval != nil {
approved, err := onToolApproval(tc.ToolName, tc.Input)
if err != nil {
return err
}
if !approved {
return fmt.Errorf("tool call %s rejected by user", tc.ToolName)
}
}
// Notify about the tool call
if onToolCall != nil {
onToolCall(tc.ToolName, tc.Input)
}
// Notify tool execution starting
if onToolExecution != nil {
onToolExecution(tc.ToolName, true)
}
return nil
},
// Tool result - tool execution completed
OnToolResult: func(tr fantasy.ToolResultContent) error {
// Notify tool execution finished
if onToolExecution != nil {
onToolExecution(tr.ToolName, false)
}
if onToolResult != nil {
// Extract result text and error status
resultText, isError := extractToolResultText(tr)
onToolResult(tr.ToolName, currentToolArgs, resultText, isError)
}
return nil
},
// Step callbacks for content that accompanies tool calls
OnStepFinish: func(step fantasy.StepResult) error {
// Check if step has text content alongside tool calls
text := step.Content.Text()
toolCalls := step.Content.ToolCalls()
if text != "" && len(toolCalls) > 0 && onToolCallContent != nil {
onToolCallContent(text)
}
return nil
},
})
if err != nil {
return nil, err
}
return convertAgentResult(result, messages), nil
}
// Non-streaming path
result, err := a.fantasyAgent.Generate(ctx, fantasy.AgentCall{
Prompt: prompt,
Messages: history,
})
if err != nil {
return nil, err
}
// For non-streaming, fire the response callback with the final text
if onResponse != nil && result.Response.Content.Text() != "" {
onResponse(result.Response.Content.Text())
}
_ = currentToolName // satisfy compiler for non-streaming path
return convertAgentResult(result, messages), nil
}
// splitPromptAndHistory extracts the last user message as the prompt string,
// and returns everything before it as conversation history. Fantasy's agent
// requires the current turn's input as Prompt (string), with prior messages
// passed separately as Messages (history).
func splitPromptAndHistory(messages []fantasy.Message) (string, []fantasy.Message) {
if len(messages) == 0 {
return "", nil
}
// Walk backwards to find the last user message
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == fantasy.MessageRoleUser {
// Extract text from the user message parts
var prompt string
for _, part := range messages[i].Content {
if tp, ok := part.(fantasy.TextPart); ok {
prompt = tp.Text
break
}
}
// History is everything except this last user message
history := make([]fantasy.Message, 0, len(messages)-1)
history = append(history, messages[:i]...)
history = append(history, messages[i+1:]...)
return prompt, history
}
}
// No user message found — use the last message's text as prompt
last := messages[len(messages)-1]
for _, part := range last.Content {
if tp, ok := part.(fantasy.TextPart); ok {
return tp.Text, messages[:len(messages)-1]
}
}
return "", messages
}
// convertAgentResult converts a fantasy AgentResult to our GenerateWithLoopResult.
func convertAgentResult(result *fantasy.AgentResult, originalMessages []fantasy.Message) *GenerateWithLoopResult {
// Collect all conversation messages: original + all step messages
var allMessages []fantasy.Message
allMessages = append(allMessages, originalMessages...)
for _, step := range result.Steps {
allMessages = append(allMessages, step.Messages...)
}
return &GenerateWithLoopResult{
FinalResponse: &result.Response,
ConversationMessages: allMessages,
TotalUsage: result.TotalUsage,
}
}
// extractToolResultText extracts the text and error status from a fantasy ToolResultContent.
func extractToolResultText(tr fantasy.ToolResultContent) (string, bool) {
if tr.Result == nil {
return "", false
}
// Marshal the result to JSON for display
resultBytes, err := json.Marshal(tr.Result)
if err != nil {
return fmt.Sprintf("%v", tr.Result), false
}
resultText := string(resultBytes)
// Check if this is an error result by examining the type
if errResult, ok := tr.Result.(fantasy.ToolResultOutputContentError); ok {
return errResult.Error.Error(), true
}
return resultText, false
}
// GetTools returns the list of available tools loaded in the agent.
func (a *Agent) GetTools() []fantasy.AgentTool {
return a.toolManager.GetTools()
}
// GetLoadingMessage returns the loading message from provider creation.
func (a *Agent) GetLoadingMessage() string {
return a.loadingMessage
}
// GetLoadedServerNames returns the names of successfully loaded MCP servers.
func (a *Agent) GetLoadedServerNames() []string {
return a.toolManager.GetLoadedServerNames()
}
// GetModel returns the underlying fantasy LanguageModel.
func (a *Agent) GetModel() fantasy.LanguageModel {
return a.model
}
// Close closes the agent and cleans up resources.
func (a *Agent) Close() error {
return a.toolManager.Close()
}
// escListenerModel is a simple Bubble Tea model for ESC key detection
type escListenerModel struct {
escPressed chan bool
}
func (m escListenerModel) Init() tea.Cmd {
return nil
}
func (m escListenerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyPressMsg); ok {
if msg.String() == "esc" {
select {
case m.escPressed <- true:
default:
}
return m, tea.Quit
}
}
return m, nil
}
func (m escListenerModel) View() tea.View {
return tea.NewView("")
}
// listenForESC listens for ESC key press using Bubble Tea and returns true if detected
func (a *Agent) listenForESC(stopChan chan bool, readyChan chan bool) bool {
escPressed := make(chan bool, 1)
model := escListenerModel{
escPressed: escPressed,
}
p := tea.NewProgram(model, tea.WithoutRenderer())
go func() {
if _, err := p.Run(); err != nil {
select {
case escPressed <- false:
default:
}
}
}()
go func() {
time.Sleep(10 * time.Millisecond)
select {
case readyChan <- true:
default:
}
}()
select {
case <-stopChan:
p.Kill()
time.Sleep(50 * time.Millisecond)
return false
case pressed := <-escPressed:
p.Kill()
time.Sleep(50 * time.Millisecond)
return pressed
case <-time.After(30 * time.Second):
p.Kill()
time.Sleep(50 * time.Millisecond)
return false
}
}