mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
0703dd1602
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.
429 lines
13 KiB
Go
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
|
|
}
|
|
}
|