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.
This commit is contained in:
Ed Zynda
2026-02-25 18:17:25 +03:00
parent ce32cea7ee
commit 0703dd1602
47 changed files with 1524 additions and 38880 deletions
+9
View File
@@ -0,0 +1,9 @@
version: "2"
linters:
enable:
- modernize
formatters:
enable:
- gofmt
+12
View File
@@ -23,6 +23,18 @@
"type": "git",
"name": "glamour",
"url": "https://github.com/charmbracelet/glamour",
"branch": "v2-exp"
},
{
"type": "git",
"name": "fantasy",
"url": "https://github.com/charmbracelet/fantasy",
"branch": "main"
},
{
"type": "git",
"name": "catwalk",
"url": "https://github.com/charmbracelet/catwalk",
"branch": "main"
}
],
+38 -30
View File
@@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/cloudwego/eino/schema"
"charm.land/fantasy"
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/config"
"github.com/mark3labs/mcphost/internal/hooks"
@@ -608,13 +608,12 @@ func runNormalMode(ctx context.Context) error {
tools := mcpAgent.GetTools()
var toolNames []string
for _, tool := range tools {
if info, err := tool.Info(ctx); err == nil {
info := tool.Info()
toolNames = append(toolNames, info.Name)
}
}
// Main interaction logic
var messages []*schema.Message
var messages []fantasy.Message
var sessionManager *session.Manager
if sessionPath != "" {
_, err := os.Stat(sessionPath)
@@ -637,7 +636,8 @@ func runNormalMode(ctx context.Context) error {
// Convert session messages to schema messages
for _, msg := range loadedSession.Messages {
messages = append(messages, msg.ConvertToSchemaMessage())
fantasyMsg := msg.ConvertToFantasyMessage()
messages = append(messages, fantasyMsg)
}
// If we're also saving, use the loaded session with the session manager
@@ -658,9 +658,10 @@ func runNormalMode(ctx context.Context) error {
// Display all previous messages as they would have appeared
for _, sessionMsg := range loadedSession.Messages {
if sessionMsg.Role == "user" {
switch sessionMsg.Role {
case "user":
cli.DisplayUserMessage(sessionMsg.Content)
} else if sessionMsg.Role == "assistant" {
case "assistant":
// Display tool calls if present
if len(sessionMsg.ToolCalls) > 0 {
for _, tc := range sessionMsg.ToolCalls {
@@ -679,7 +680,7 @@ func runNormalMode(ctx context.Context) error {
if sessionMsg.Content != "" {
cli.DisplayAssistantMessage(sessionMsg.Content)
}
} else if sessionMsg.Role == "tool" {
case "tool":
// Display tool result
if sessionMsg.ToolCallID != "" {
if toolCall, exists := toolCallMap[sessionMsg.ToolCallID]; exists {
@@ -773,7 +774,7 @@ type AgenticLoopConfig struct {
}
// addMessagesToHistory adds messages to the conversation history and saves to session if available
func addMessagesToHistory(messages *[]*schema.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages ...*schema.Message) {
func addMessagesToHistory(messages *[]fantasy.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages ...fantasy.Message) {
// Add to local history
*messages = append(*messages, newMessages...)
@@ -790,7 +791,7 @@ func addMessagesToHistory(messages *[]*schema.Message, sessionManager *session.M
}
// replaceMessagesHistory replaces the conversation history and saves to session if available
func replaceMessagesHistory(messages *[]*schema.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages []*schema.Message) {
func replaceMessagesHistory(messages *[]fantasy.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages []fantasy.Message) {
// Replace local history
*messages = newMessages
@@ -807,7 +808,7 @@ func replaceMessagesHistory(messages *[]*schema.Message, sessionManager *session
}
// runAgenticLoop handles all execution modes with a single unified loop
func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []*schema.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
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
@@ -837,7 +838,7 @@ func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
}
// Create temporary messages with user input for processing (don't add to history yet)
tempMessages := append(messages, schema.UserMessage(config.InitialPrompt))
tempMessages := append(messages, fantasy.NewUserMessage(config.InitialPrompt))
// Process the initial prompt with tool calls
_, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config, hookExecutor)
@@ -875,7 +876,7 @@ func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
}
// runAgenticStep processes a single step of the agentic loop (handles tool calls)
func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []*schema.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) (*schema.Message, []*schema.Message, error) {
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)
@@ -1153,13 +1154,22 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
if len(messages) > 0 {
// Find the last user message
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == schema.User {
lastUserMessage = messages[i].Content
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)
@@ -1167,15 +1177,15 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
// 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() == response.Content
if !config.Quiet && cli != nil && response.Content != lastDisplayedContent && response.Content != "" && !streamedFullResponse {
if err := cli.DisplayAssistantMessageWithModel(response.Content, config.ModelName); err != nil {
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(response.Content)
fmt.Print(responseText)
}
// Display usage information immediately after the response (for both streaming and non-streaming)
@@ -1191,15 +1201,14 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
}
// executeStopHook executes the Stop hook if a hook executor is available
func executeStopHook(hookExecutor *hooks.Executor, response *schema.Message, stopReason string, modelName string) {
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]interface{}{
metaData := map[string]any{
"model": modelName,
"role": string(response.Role),
"has_tool_calls": len(response.ToolCalls) > 0,
"has_tool_calls": len(response.Content.ToolCalls()) > 0,
}
if metaBytes, err := json.Marshal(metaData); err == nil {
meta = json.RawMessage(metaBytes)
@@ -1208,7 +1217,7 @@ func executeStopHook(hookExecutor *hooks.Executor, response *schema.Message, sto
responseContent := ""
if response != nil {
responseContent = response.Content
responseContent = response.Content.Text()
}
input := &hooks.StopInput{
@@ -1225,7 +1234,7 @@ func executeStopHook(hookExecutor *hooks.Executor, response *schema.Message, sto
}
// runInteractiveLoop handles the interactive portion of the agentic loop
func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []*schema.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
for {
// Get user input
prompt, err := cli.GetPrompt()
@@ -1292,7 +1301,7 @@ func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI,
cli.DisplayUserMessage(prompt)
// Create temporary messages with user input for processing
tempMessages := append(messages, schema.UserMessage(prompt))
tempMessages := append(messages, fantasy.NewUserMessage(prompt))
// Process the user input with tool calls
_, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config, hookExecutor)
if err != nil {
@@ -1312,7 +1321,7 @@ func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI,
}
// runNonInteractiveMode handles the non-interactive mode execution
func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, prompt, modelName string, messages []*schema.Message, quiet, noExit bool, mcpConfig *config.Config, sessionManager *session.Manager, hookExecutor *hooks.Executor) error {
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 {
@@ -1322,10 +1331,9 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C
tools := mcpAgent.GetTools()
var toolNames []string
for _, tool := range tools {
if info, err := tool.Info(ctx); err == nil {
info := tool.Info()
toolNames = append(toolNames, info.Name)
}
}
// Configure and run unified agentic loop
config := AgenticLoopConfig{
@@ -1345,7 +1353,7 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C
}
// runInteractiveMode handles the interactive mode execution
func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, serverNames, toolNames []string, modelName string, messages []*schema.Message, sessionManager *session.Manager, hookExecutor *hooks.Executor, approveToolRun bool) error {
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,
+6 -7
View File
@@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/cloudwego/eino/schema"
"charm.land/fantasy"
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/config"
"github.com/mark3labs/mcphost/internal/hooks"
@@ -177,8 +177,8 @@ func parseCustomVariables(_ *cobra.Command) map[string]string {
}
// Parse custom variables with --args: prefix
if strings.HasPrefix(arg, "--args:") {
varName := strings.TrimPrefix(arg, "--args:")
if after, ok := strings.CutPrefix(arg, "--args:"); ok {
varName := after
if varName == "" {
continue // Skip malformed --args: without name
}
@@ -312,7 +312,7 @@ func parseScriptContent(content string, variables map[string]string) (*config.Co
var promptLines []string
var inFrontmatter bool
var foundFrontmatter bool
var frontmatterEnd int = -1
var frontmatterEnd = -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
@@ -699,13 +699,12 @@ func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string,
tools := mcpAgent.GetTools()
var toolNames []string
for _, tool := range tools {
if info, err := tool.Info(ctx); err == nil {
info := tool.Info()
toolNames = append(toolNames, info.Name)
}
}
// Configure and run unified agentic loop
var messages []*schema.Message
var messages []fantasy.Message
config := AgenticLoopConfig{
IsInteractive: prompt == "", // If no prompt, start in interactive mode
InitialPrompt: prompt,
+1 -1
View File
@@ -82,7 +82,7 @@ Working directory is ${env://WORK_DIR:-/tmp}.
t.Fatal("Filesystem server not found in script config")
}
allowedDirs, ok := fsServer.Options["allowed_directories"].([]interface{})
allowedDirs, ok := fsServer.Options["allowed_directories"].([]any)
if !ok {
t.Fatal("allowed_directories should be an array")
}
+56 -78
View File
@@ -1,150 +1,129 @@
module github.com/mark3labs/mcphost
go 1.24.2
toolchain go1.24.5
go 1.26.0
require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.0
charm.land/catwalk v0.22.1
charm.land/fantasy v0.10.0
charm.land/lipgloss/v2 v2.0.0
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/PuerkitoBio/goquery v1.10.3
github.com/bytedance/sonic v1.15.0
github.com/charmbracelet/fang v0.4.4
github.com/cloudwego/eino v0.7.13
github.com/cloudwego/eino-ext/components/model/claude v0.1.12
github.com/cloudwego/eino-ext/components/model/ollama v0.1.8
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250903035842-96774a3ec845
github.com/eino-contrib/jsonschema v1.0.3
github.com/getkin/kin-openapi v0.131.0
github.com/mark3labs/mcp-filesystem-server v0.11.1
github.com/mark3labs/mcp-go v0.44.0-beta.2
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.20.1
github.com/tidwall/gjson v1.18.0
golang.org/x/term v0.37.0
google.golang.org/genai v1.22.0
golang.org/x/term v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
require (
cloud.google.com/go v0.121.6 // indirect
cloud.google.com/go/auth v0.16.5 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.8.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/anthropics/anthropic-sdk-go v1.10.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
github.com/aws/smithy-go v1.23.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.9 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/etag v0.2.0 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250902204034-1cdc10c66d5b // indirect
github.com/charmbracelet/x/exp/color v0.0.0-20250902204034-1cdc10c66d5b // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250902204034-1cdc10c66d5b // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250826113018-8c6f6358d4bb // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eino-contrib/ollama v0.1.0 // indirect
github.com/evanphx/json-patch v0.5.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.22.0 // indirect
github.com/go-openapi/swag/jsonname v0.24.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/goph/emperror v0.17.2 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kaptinlin/go-i18n v0.2.11 // indirect
github.com/kaptinlin/jsonpointer v0.4.16 // indirect
github.com/kaptinlin/jsonschema v0.7.3 // indirect
github.com/kaptinlin/messageformat-go v0.4.18 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/meguminnnnnnnnn/go-openai v0.0.0-20250821095446-07791bea23a0 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/nikolalohinski/gonja v1.5.3 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/openai/openai-go/v2 v2.7.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sagikazarmark/locafero v0.10.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/api v0.246.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect
google.golang.org/grpc v1.75.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.267.0 // indirect
google.golang.org/genai v1.47.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/grpc v1.79.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
require (
@@ -160,8 +139,7 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/text v0.34.0 // indirect
)
+143 -224
View File
@@ -2,16 +2,28 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/catwalk v0.22.1 h1:i7nxxYyEzgWqDD3ifAZ8SQR/cEaPbviiBbxq+ZGhk6M=
charm.land/catwalk v0.22.1/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s=
charm.land/fantasy v0.10.0 h1:6PD+1rrsCgLIG1n+PAZp/gHiC0dltU0cvb7c8zUKyu8=
charm.land/fantasy v0.10.0/go.mod h1:KIeNQUpJTswwpY0P6HJsr3LBFgfTDb8FDpOdVQMsKqY=
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI=
cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI=
cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@@ -19,7 +31,6 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
@@ -29,38 +40,38 @@ github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anthropics/anthropic-sdk-go v1.10.0 h1:jDKQTfC0miIEj21eMmPrNSLKTNdNa3nHZOhd4wZz1cI=
github.com/anthropics/anthropic-sdk-go v1.10.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk=
github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo=
github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ=
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg=
github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg=
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI=
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c=
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
@@ -69,21 +80,12 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/mockey v1.2.14 h1:KZaFgPdiUwW+jOWFieo3Lr7INM1P+6adO3hxZhDswY8=
github.com/bytedance/mockey v1.2.14/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY=
@@ -100,14 +102,18 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04=
github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250902204034-1cdc10c66d5b h1:U9SnQTnrxy8y3gEpxhpBS3ztHAR7IvL0CjvFHOR4sbE=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250902204034-1cdc10c66d5b/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/color v0.0.0-20250902204034-1cdc10c66d5b h1:x4wRlDV7e7qM6yYS06W6wMKh6z1NeD1+DTjvOm2grzo=
github.com/charmbracelet/x/exp/color v0.0.0-20250902204034-1cdc10c66d5b/go.mod h1:hk/GyTELmEgX54pBAOHcFvH8Xed53JWo/g8kJXFo/PI=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/slice v0.0.0-20250902204034-1cdc10c66d5b h1:DZ2Li1O0j+wWw6AgEUDrODB7PAIKpmOy65yu1UBPYc4=
github.com/charmbracelet/x/exp/slice v0.0.0-20250902204034-1cdc10c66d5b/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 h1:DTSZxdV9qQagD4iGcAt9RgaRBZtJl01bfKgdLzUzUPI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -118,105 +124,78 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.7.13 h1:Ku7hY+83gGJJjf4On3UgqjC57UcA+DXe0tqAZiNDDew=
github.com/cloudwego/eino v0.7.13/go.mod h1:nA8Vacmuqv3pqKBQbTWENBLQ8MmGmPt/WqiyLeB8ohQ=
github.com/cloudwego/eino-ext/components/model/claude v0.1.12 h1:c66gFH9J5Ku2/v1f7jPwI9R4CYw5TiAlIVzsfzjsF1g=
github.com/cloudwego/eino-ext/components/model/claude v0.1.12/go.mod h1:a9oQkf4Ib+/VqjsLRdRETytt2m/C4fbcvfjPNu6nVAg=
github.com/cloudwego/eino-ext/components/model/ollama v0.1.8 h1:+BStnQlkRxWMV9jsPopLmmut2ARG88e9hDSMaDNAI/w=
github.com/cloudwego/eino-ext/components/model/ollama v0.1.8/go.mod h1:C3rf3yy2nEoXFP/CQJne4gbiu1pREKplHKmFlhuOzPE=
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250903035842-96774a3ec845 h1:nxflfiBwWNPoKS9X4SMhmT+si7rtYv+lQzIyPJik4DM=
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250903035842-96774a3ec845/go.mod h1:QQhCuQxuBAVWvu/YAZBhs/RsR76mUigw59Tl0kh04C8=
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250826113018-8c6f6358d4bb h1:RMslzyijc3bi9EkqCulpS0hZupTl1y/wayR3+fVRN/c=
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250826113018-8c6f6358d4bb/go.mod h1:fHn/6OqPPY1iLLx9wzz+MEVT5Dl9gwuZte1oLEnCoYw=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
github.com/eino-contrib/ollama v0.1.0 h1:z1NaMdKW6X1ftP8g5xGGR5zDRPUtuTKFq35vBQgxsN4=
github.com/eino-contrib/ollama v0.1.0/go.mod h1:mYsQ7b3DeqY8bHPuD3MZJYTqkgyL6LoemxoP/B7ZNhA=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.131.0 h1:NO2UeHnFKRYhZ8wg6Nyh5Cq7dHk4suQQr72a4pMrDxE=
github.com/getkin/kin-openapi v0.131.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k=
github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kaptinlin/go-i18n v0.2.11 h1:OayNt8mWt8nDaqAOp09/C1VG9Y5u8LpQnnxbyGARDV4=
github.com/kaptinlin/go-i18n v0.2.11/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
github.com/kaptinlin/jsonpointer v0.4.16 h1:Ux4w4FY+uLv+K+TxaCJtM/TpPv+1+eS6gH4Z9/uhOuA=
github.com/kaptinlin/jsonpointer v0.4.16/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=
github.com/kaptinlin/jsonschema v0.7.3 h1:kyIydij76ORiSxmfy0xFYy0cOx8MwG6pyyaSoQshsK4=
github.com/kaptinlin/jsonschema v0.7.3/go.mod h1:Ys6zr+W6/1330FzZEouFrAYImK+AmYt5HQVTHQQXQo8=
github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=
github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -224,6 +203,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
@@ -232,26 +213,13 @@ github.com/mark3labs/mcp-filesystem-server v0.11.1 h1:7uKIZRMaKWfgvtDj/uLAvo0+7M
github.com/mark3labs/mcp-filesystem-server v0.11.1/go.mod h1:xDqJizVYWZ5a31Mt4xuYbVku2AR/kT56H3O0SbpANoQ=
github.com/mark3labs/mcp-go v0.44.0-beta.2 h1:gfUT0m77E4odfgiHkqV/E+MQVaQ06rbutW7Ln0JRkBA=
github.com/mark3labs/mcp-go v0.44.0-beta.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/meguminnnnnnnnn/go-openai v0.0.0-20250821095446-07791bea23a0 h1:nIohpHs1ViKR0SVgW/cbBstHjmnqFZDM9RqgX9m9Xu8=
github.com/meguminnnnnnnnn/go-openai v0.0.0-20250821095446-07791bea23a0/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
@@ -266,32 +234,24 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/openai/openai-go/v2 v2.7.1 h1:/tfvTJhfv7hTSL8mWwc5VL4WLLSDL5yn9VqVykdu9r8=
github.com/openai/openai-go/v2 v2.7.1/go.mod h1:jrJs23apqJKKbT+pqtFgNKpRju/KP9zpUTZhz3GElQE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
@@ -300,19 +260,10 @@ github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvK
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
@@ -323,18 +274,8 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -349,18 +290,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -369,27 +302,22 @@ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -397,16 +325,15 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -418,11 +345,10 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -432,14 +358,11 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -463,8 +386,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -474,10 +397,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -487,26 +410,22 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.246.0 h1:H0ODDs5PnMZVZAEtdLMn2Ul2eQi7QNjqM2DIFp8TlTM=
google.golang.org/api v0.246.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
google.golang.org/genai v1.22.0 h1:5hrEhXXWJQZa3tdPocl4vQ/0w6myEAxdNns2Kmx0f4Y=
google.golang.org/genai v1.22.0/go.mod h1:QPj5NGJw+3wEOHg+PrsWwJKvG6UC84ex5FR7qAYsN/M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE=
google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0=
google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo=
google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+220 -330
View File
@@ -8,102 +8,114 @@ import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"github.com/mark3labs/mcp-go/mcp"
"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.
// It includes model configuration, MCP settings, and various behavioral options.
type AgentConfig struct {
// ModelConfig specifies the LLM provider and model to use
ModelConfig *models.ProviderConfig
// MCPConfig contains MCP server configurations
MCPConfig *config.Config
// SystemPrompt is the initial system message for the agent
SystemPrompt string
// MaxSteps limits the number of tool calls (0 for unlimited)
MaxSteps int
// StreamingEnabled controls whether responses are streamed
StreamingEnabled bool
// DebugLogger is an optional logger for debugging MCP communications
DebugLogger tools.DebugLogger // Optional debug logger
DebugLogger tools.DebugLogger
}
// ToolCallHandler is a function type for handling tool calls as they happen.
// It receives the tool name and its arguments when a tool is about to be invoked.
type ToolCallHandler func(toolName, toolArgs string)
// ToolExecutionHandler is a function type for handling tool execution start/end events.
// The isStarting parameter indicates whether the tool is starting (true) or finished (false).
type ToolExecutionHandler func(toolName string, isStarting bool)
// ToolResultHandler is a function type for handling tool results.
// It receives the tool name, arguments, result, and whether the result is an error.
type ToolResultHandler func(toolName, toolArgs, result string, isError bool)
// ResponseHandler is a function type for handling LLM responses.
// It receives the complete response content from the model.
type ResponseHandler func(content string)
// StreamingResponseHandler is a function type for handling streaming LLM responses.
// It receives content chunks as they are streamed from the model.
type StreamingResponseHandler func(content string)
// ToolCallContentHandler is a function type for handling content that accompanies tool calls.
// It receives any text content that the model generates alongside tool calls.
type ToolCallContentHandler func(content string)
// ToolApprovalHandler is a function type for handling user approval of tool calls.
// It receives the tool name and arguments, and returns true if the user approves.
type ToolApprovalHandler func(toolName, toolArgs string) (bool, error)
// Agent represents an AI agent with MCP tool integration and real-time tool call display.
// 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
model model.ToolCallingChatModel
fantasyAgent fantasy.Agent
model fantasy.LanguageModel
maxSteps int
systemPrompt string
loadingMessage string // Message from provider loading (e.g., GPU fallback info)
providerType string // Provider type for streaming behavior
streamingEnabled bool // Whether streaming is enabled
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.
// It initializes the LLM provider, loads MCP tools, and configures the agent
// based on the provided configuration. Returns an error if provider creation
// or tool loading fails.
func NewAgent(ctx context.Context, config *AgentConfig) (*Agent, error) {
// Create the LLM provider
providerResult, err := models.CreateProvider(ctx, config.ModelConfig)
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()
// Set the model for sampling support
toolManager.SetModel(providerResult.Model)
// Set the debug logger if provided
if config.DebugLogger != nil {
toolManager.SetDebugLogger(config.DebugLogger)
if agentConfig.DebugLogger != nil {
toolManager.SetDebugLogger(agentConfig.DebugLogger)
}
if err := toolManager.LoadTools(ctx, config.MCPConfig); err != nil {
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 config.ModelConfig != nil && config.ModelConfig.ModelString != "" {
parts := strings.SplitN(config.ModelConfig.ModelString, ":", 2)
if agentConfig.ModelConfig != nil && agentConfig.ModelConfig.ModelString != "" {
parts := strings.SplitN(agentConfig.ModelConfig.ModelString, ":", 2)
if len(parts) >= 1 {
providerType = parts[0]
}
@@ -111,345 +123,239 @@ func NewAgent(ctx context.Context, config *AgentConfig) (*Agent, error) {
return &Agent{
toolManager: toolManager,
fantasyAgent: fantasyAgent,
model: providerResult.Model,
maxSteps: config.MaxSteps, // Keep 0 for infinite, handle in loop
systemPrompt: config.SystemPrompt,
maxSteps: agentConfig.MaxSteps,
systemPrompt: agentConfig.SystemPrompt,
loadingMessage: providerResult.Message,
providerType: providerType,
streamingEnabled: config.StreamingEnabled,
streamingEnabled: agentConfig.StreamingEnabled,
}, nil
}
// GenerateWithLoopResult contains the result and conversation history from an agent interaction.
// It includes both the final response and the complete message history with tool interactions.
type GenerateWithLoopResult struct {
// FinalResponse is the last message generated by the model
FinalResponse *schema.Message
// ConversationMessages contains all messages in the conversation including tool calls and results
ConversationMessages []*schema.Message // All messages in the conversation (including tool calls and results)
}
// GenerateWithLoop processes messages with a custom loop that displays tool calls in real-time.
// It handles the conversation flow, executing tools as needed and invoking callbacks for various events.
// This method does not support streaming responses; use GenerateWithLoopAndStreaming for streaming support.
func (a *Agent) GenerateWithLoop(ctx context.Context, messages []*schema.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler, onResponse ResponseHandler, onToolCallContent ToolCallContentHandler, onToolApproval ToolApprovalHandler,
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)
return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult,
onResponse, onToolCallContent, nil, onToolApproval)
}
// GenerateWithLoopAndStreaming processes messages with a custom loop that displays tool calls in real-time and supports streaming callbacks.
// It handles the conversation flow, executing tools as needed and invoking callbacks for various events including streaming chunks.
// The onStreamingResponse callback is invoked for each content chunk during streaming if streaming is enabled.
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []*schema.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler, onResponse ResponseHandler, onToolCallContent ToolCallContentHandler, onStreamingResponse StreamingResponseHandler, onToolApproval ToolApprovalHandler,
// 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) {
// Create a copy of messages to avoid modifying the original
workingMessages := make([]*schema.Message, len(messages))
copy(workingMessages, messages)
// Add system prompt if provided
if a.systemPrompt != "" {
hasSystemMessage := false
if len(workingMessages) > 0 && workingMessages[0].Role == schema.System {
hasSystemMessage = true
}
// 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)
if !hasSystemMessage {
systemMsg := schema.SystemMessage(a.systemPrompt)
workingMessages = append([]*schema.Message{systemMsg}, workingMessages...)
}
}
// Track current tool call info for callbacks
var currentToolName string
var currentToolArgs string
// Get available tools
availableTools := a.toolManager.GetTools()
var toolInfos []*schema.ToolInfo
toolMap := make(map[string]tool.BaseTool)
if a.streamingEnabled {
// Use fantasy's streaming agent
result, err := a.fantasyAgent.Stream(ctx, fantasy.AgentStreamCall{
Prompt: prompt,
Messages: history,
for _, t := range availableTools {
info, err := t.Info(ctx)
if err != nil {
continue
}
if info == nil {
continue
}
toolInfos = append(toolInfos, info)
toolMap[info.Name] = t
// Text streaming callback
OnTextDelta: func(id, text string) error {
if onStreamingResponse != nil {
onStreamingResponse(text)
}
return nil
},
// Main loop
for step := 0; a.maxSteps == 0 || step < a.maxSteps; step++ {
// Check if context was cancelled before making LLM call
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// 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
// Call the LLM with cancellation support
response, err := a.generateWithCancellationAndStreaming(ctx, workingMessages, toolInfos, onStreamingResponse)
if err != nil {
return nil, err
}
// Add response to working messages
workingMessages = append(workingMessages, response)
// Check if this is a tool call or final response
if len(response.ToolCalls) > 0 {
// Display any content that accompanies the tool calls
if response.Content != "" && onToolCallContent != nil {
onToolCallContent(response.Content)
}
// Handle tool calls
for _, toolCall := range response.ToolCalls {
// Check approval if handler is set
if onToolApproval != nil {
approved, err := onToolApproval(toolCall.Function.Name, toolCall.Function.Arguments)
approved, err := onToolApproval(tc.ToolName, tc.Input)
if err != nil {
return nil, err
return err
}
if !approved {
rejectedMsg := fmt.Sprintf("The user did not allow tool call %s. Reason: User cancelled.", toolCall.Function.Name)
toolMessage := schema.ToolMessage(rejectedMsg, toolCall.ID)
workingMessages = append(workingMessages, toolMessage)
continue
return fmt.Errorf("tool call %s rejected by user", tc.ToolName)
}
}
// Notify about tool call
// Notify about the tool call
if onToolCall != nil {
onToolCall(toolCall.Function.Name, toolCall.Function.Arguments)
onToolCall(tc.ToolName, tc.Input)
}
// Execute the tool
if selectedTool, exists := toolMap[toolCall.Function.Name]; exists {
// Notify tool execution start
// Notify tool execution starting
if onToolExecution != nil {
onToolExecution(toolCall.Function.Name, true)
onToolExecution(tc.ToolName, true)
}
// Sanitize arguments for common LLM junk like "}{"
arguments := toolCall.Function.Arguments
if len(arguments) > 0 && strings.Trim(arguments, " \t\n\r{}") == "" {
arguments = "{}"
}
return nil
},
output, err := selectedTool.(tool.InvokableTool).InvokableRun(ctx, arguments)
// Notify tool execution end
// Tool result - tool execution completed
OnToolResult: func(tr fantasy.ToolResultContent) error {
// Notify tool execution finished
if onToolExecution != nil {
onToolExecution(toolCall.Function.Name, false)
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 {
errorMsg := fmt.Sprintf("Tool execution error: %v", err)
toolMessage := schema.ToolMessage(errorMsg, toolCall.ID)
workingMessages = append(workingMessages, toolMessage)
if onToolResult != nil {
onToolResult(toolCall.Function.Name, toolCall.Function.Arguments, errorMsg, true)
return nil, err
}
} else {
// Check if this is an MCP tool response with an error
isError := false
if output != "" {
var mcpResult mcp.CallToolResult
if err := json.Unmarshal([]byte(output), &mcpResult); err == nil && mcpResult.IsError {
isError = true
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
}
}
toolMessage := schema.ToolMessage(output, toolCall.ID)
workingMessages = append(workingMessages, toolMessage)
// 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]
}
}
if onToolResult != nil {
onToolResult(toolCall.Function.Name, toolCall.Function.Arguments, output, isError)
}
}
} else {
errorMsg := fmt.Sprintf("Tool not found: %s", toolCall.Function.Name)
toolMessage := schema.ToolMessage(errorMsg, toolCall.ID)
workingMessages = append(workingMessages, toolMessage)
return "", messages
}
if onToolResult != nil {
onToolResult(toolCall.Function.Name, toolCall.Function.Arguments, errorMsg, true)
}
}
}
} else {
// This is a final response
if onResponse != nil && response.Content != "" {
onResponse(response.Content)
// 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: response,
ConversationMessages: workingMessages,
}, nil
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
}
// If we reach here, we've exceeded max steps
finalResponse := schema.AssistantMessage("Maximum number of steps reached.", nil)
return &GenerateWithLoopResult{
FinalResponse: finalResponse,
ConversationMessages: workingMessages,
}, nil
// 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.
// These tools are available for the model to use during interactions.
func (a *Agent) GetTools() []tool.BaseTool {
func (a *Agent) GetTools() []fantasy.AgentTool {
return a.toolManager.GetTools()
}
// GetLoadingMessage returns the loading message from provider creation.
// This may contain information about GPU fallback or other provider-specific initialization details.
func (a *Agent) GetLoadingMessage() string {
return a.loadingMessage
}
// GetLoadedServerNames returns the names of successfully loaded MCP servers.
// This includes both builtin servers and external MCP server configurations.
func (a *Agent) GetLoadedServerNames() []string {
return a.toolManager.GetLoadedServerNames()
}
// generateWithCancellationAndStreaming calls the LLM with ESC key cancellation support and streaming callbacks
func (a *Agent) generateWithCancellationAndStreaming(ctx context.Context, messages []*schema.Message, toolInfos []*schema.ToolInfo, streamingCallback StreamingResponseHandler) (*schema.Message, error) {
// Check if streaming is enabled
if !a.streamingEnabled {
// Use traditional non-streaming approach
return a.generateWithoutStreaming(ctx, messages, toolInfos)
}
// Try streaming first if no tools are expected or if we can detect tool calls early
if len(toolInfos) == 0 {
// No tools available, use streaming directly
return a.generateWithStreamingAndCallback(ctx, messages, toolInfos, streamingCallback)
}
// Try streaming with tool call detection
return a.generateWithStreamingFirstAndCallback(ctx, messages, toolInfos, streamingCallback)
// GetModel returns the underlying fantasy LanguageModel.
func (a *Agent) GetModel() fantasy.LanguageModel {
return a.model
}
// generateWithStreamingAndCallback uses streaming for responses without tool calls with real-time callbacks
func (a *Agent) generateWithStreamingAndCallback(ctx context.Context, messages []*schema.Message, toolInfos []*schema.ToolInfo, callback StreamingResponseHandler) (*schema.Message, error) {
// Try streaming first
reader, err := a.model.Stream(ctx, messages, model.WithTools(toolInfos))
if err != nil {
// Fallback to non-streaming if streaming fails
return a.model.Generate(ctx, messages, model.WithTools(toolInfos))
}
// Use streaming with callback for real-time display
response, err := StreamWithCallback(ctx, reader, func(chunk string) {
if callback != nil {
callback(chunk)
}
})
if err != nil {
// Fallback to non-streaming on error
return a.model.Generate(ctx, messages, model.WithTools(toolInfos))
}
// Return the complete streamed response (with tool calls if any)
return response, nil
}
// generateWithStreamingFirstAndCallback attempts streaming first with provider-aware tool call detection and callbacks
func (a *Agent) generateWithStreamingFirstAndCallback(ctx context.Context, messages []*schema.Message, toolInfos []*schema.ToolInfo, callback StreamingResponseHandler) (*schema.Message, error) {
// Try streaming first
reader, err := a.model.Stream(ctx, messages, model.WithTools(toolInfos))
if err != nil {
// Fallback to non-streaming if streaming fails
return a.model.Generate(ctx, messages, model.WithTools(toolInfos))
}
// Use streaming with callback for real-time display
response, err := StreamWithCallback(ctx, reader, func(chunk string) {
if callback != nil {
callback(chunk)
}
})
if err != nil {
// Fallback to non-streaming on error
return a.model.Generate(ctx, messages, model.WithTools(toolInfos))
}
// Return the complete streamed response (with tool calls if any)
// No need to restart - we have everything we need!
return response, nil
}
// generateWithoutStreaming uses the traditional non-streaming approach
func (a *Agent) generateWithoutStreaming(ctx context.Context, messages []*schema.Message, toolInfos []*schema.ToolInfo) (*schema.Message, error) {
// Create a cancellable context for just this LLM call
llmCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Channel to receive the LLM result
resultChan := make(chan struct {
message *schema.Message
err error
}, 1)
// Start ESC key listener first and wait for it to be ready
escChan := make(chan bool, 1)
stopListening := make(chan bool, 1)
escReady := make(chan bool, 1)
go func() {
if a.listenForESC(stopListening, escReady) {
escChan <- true
} else {
escChan <- false
}
}()
// Wait for ESC listener to be ready before starting LLM
select {
case <-escReady:
// ESC listener is ready, proceed
case <-time.After(100 * time.Millisecond):
// Timeout waiting for ESC listener, proceed anyway
case <-ctx.Done():
close(stopListening)
return nil, ctx.Err()
}
// Now start the LLM generation
go func() {
message, err := a.model.Generate(llmCtx, messages, model.WithTools(toolInfos))
if err != nil {
err = fmt.Errorf("failed to generate response: %v", err)
}
resultChan <- struct {
message *schema.Message
err error
}{message, err}
}()
// Wait for either LLM completion or ESC key
select {
case result := <-resultChan:
// Stop the ESC listener
close(stopListening)
return result.message, result.err
case escPressed := <-escChan:
if escPressed {
cancel() // Cancel the LLM context
return nil, fmt.Errorf("generation cancelled by user")
}
// ESC listener stopped normally, wait for LLM result
result := <-resultChan
return result.message, result.err
case <-ctx.Done():
// Stop the ESC listener
close(stopListening)
return nil, ctx.Err()
}
// 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
@@ -462,10 +368,8 @@ func (m escListenerModel) Init() tea.Cmd {
}
func (m escListenerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg, ok := msg.(tea.KeyPressMsg); ok {
if msg.String() == "esc" {
// Signal ESC was pressed
select {
case m.escPressed <- true:
default:
@@ -477,7 +381,7 @@ func (m escListenerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m escListenerModel) View() tea.View {
return tea.NewView("") // No visual output needed
return tea.NewView("")
}
// listenForESC listens for ESC key press using Bubble Tea and returns true if detected
@@ -488,13 +392,10 @@ func (a *Agent) listenForESC(stopChan chan bool, readyChan chan bool) bool {
escPressed: escPressed,
}
// Create a Bubble Tea program
p := tea.NewProgram(model, tea.WithoutRenderer())
// Start the program in a goroutine
go func() {
if _, err := p.Run(); err != nil {
// Program failed, try to signal completion
select {
case escPressed <- false:
default:
@@ -502,7 +403,6 @@ func (a *Agent) listenForESC(stopChan chan bool, readyChan chan bool) bool {
}
}()
// Give the program a moment to initialize, then signal ready
go func() {
time.Sleep(10 * time.Millisecond)
select {
@@ -511,28 +411,18 @@ func (a *Agent) listenForESC(stopChan chan bool, readyChan chan bool) bool {
}
}()
// Wait for either ESC key or stop signal
select {
case <-stopChan:
p.Kill()
// Give the program time to fully terminate
time.Sleep(50 * time.Millisecond)
return false
case pressed := <-escPressed:
p.Kill()
// Give the program time to fully terminate
time.Sleep(50 * time.Millisecond)
return pressed
case <-time.After(30 * time.Second):
// Timeout after 30 seconds to prevent hanging
p.Kill()
time.Sleep(50 * time.Millisecond)
return false
}
}
// Close closes the agent and cleans up resources.
// It ensures all MCP connections are properly closed and resources are released.
func (a *Agent) Close() error {
return a.toolManager.Close()
}
-147
View File
@@ -1,147 +0,0 @@
package agent
import (
"context"
"io"
"strings"
"github.com/cloudwego/eino/schema"
)
// StreamWithCallback streams content with real-time callbacks and returns the complete response.
// It accumulates content and tool calls from the stream, invoking the callback for each content chunk.
// IMPORTANT: Tool calls are only processed after EOF is reached to ensure we have the complete
// and final tool call information. This prevents premature tool execution on partial data.
// Handles different provider streaming patterns:
// - Anthropic: Text content first, then tool calls streamed incrementally
// - OpenAI/Others: Tool calls first or alone
// - Mixed: Tool calls and content interleaved
func StreamWithCallback(ctx context.Context, reader *schema.StreamReader[*schema.Message], callback func(string)) (*schema.Message, error) {
defer reader.Close()
var content strings.Builder
var accumulatedToolCalls map[string]*schema.ToolCall // Track tool calls by ID to handle incremental updates
var streamComplete bool
var finalResponseMeta *schema.ResponseMeta // Accumulate response metadata from all chunks
accumulatedToolCalls = make(map[string]*schema.ToolCall)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
msg, err := reader.Recv()
if err == io.EOF {
// Stream is complete - now we can safely process tool calls
streamComplete = true
break
}
if err != nil {
return nil, err
}
// Call callback for each chunk if provided (for real-time display)
if callback != nil && msg.Content != "" {
callback(msg.Content)
}
// Accumulate content from all chunks
content.WriteString(msg.Content)
// Accumulate response metadata - merge from multiple chunks for accuracy
if msg.ResponseMeta != nil {
if finalResponseMeta == nil {
// First metadata we've seen - use as base
finalResponseMeta = &schema.ResponseMeta{}
if msg.ResponseMeta.Usage != nil {
finalResponseMeta.Usage = &schema.TokenUsage{}
}
}
// Merge metadata intelligently to handle Anthropic's streaming behavior
if msg.ResponseMeta.Usage != nil && finalResponseMeta.Usage != nil {
usage := msg.ResponseMeta.Usage
// Take PromptTokens from first chunk that has them (usually non-zero)
if finalResponseMeta.Usage.PromptTokens == 0 && usage.PromptTokens > 0 {
finalResponseMeta.Usage.PromptTokens = usage.PromptTokens
}
// Always take the latest CompletionTokens (accumulates over chunks)
if usage.CompletionTokens > 0 {
finalResponseMeta.Usage.CompletionTokens = usage.CompletionTokens
}
// Calculate TotalTokens from the components
finalResponseMeta.Usage.TotalTokens = finalResponseMeta.Usage.PromptTokens + finalResponseMeta.Usage.CompletionTokens
}
// Preserve other metadata fields from the latest chunk
if msg.ResponseMeta.FinishReason != "" {
finalResponseMeta.FinishReason = msg.ResponseMeta.FinishReason
}
}
// Accumulate tool calls incrementally - Anthropic streams them piece by piece
// NOTE: We don't process these tool calls until EOF is reached
if len(msg.ToolCalls) > 0 {
for _, toolCall := range msg.ToolCalls {
// Use tool call ID as key, but handle cases where ID might be empty in partial chunks
key := toolCall.ID
if key == "" {
// For chunks without ID, try to find existing tool call or create a temporary key
if len(accumulatedToolCalls) == 1 {
// If we have exactly one tool call being built, assume this chunk belongs to it
for existingKey := range accumulatedToolCalls {
key = existingKey
break
}
} else {
// Create a temporary key for this tool call
key = "temp_" + toolCall.Function.Name
}
}
existing := accumulatedToolCalls[key]
if existing == nil {
// First time seeing this tool call
accumulatedToolCalls[key] = &schema.ToolCall{
ID: toolCall.ID,
Function: toolCall.Function,
}
} else {
// Update existing tool call with new information
// Preserve non-empty values, accumulate arguments
if toolCall.ID != "" {
existing.ID = toolCall.ID
}
if toolCall.Function.Name != "" {
existing.Function.Name = toolCall.Function.Name
}
// Accumulate arguments (they come in pieces)
existing.Function.Arguments += toolCall.Function.Arguments
}
}
}
}
// Only process tool calls after EOF - ensures we have complete information
var finalToolCalls []schema.ToolCall
if streamComplete && len(accumulatedToolCalls) > 0 {
finalToolCalls = make([]schema.ToolCall, 0, len(accumulatedToolCalls))
for _, toolCall := range accumulatedToolCalls {
finalToolCalls = append(finalToolCalls, *toolCall)
}
}
// Return complete message with all content, final tool calls, and preserved metadata
return &schema.Message{
Role: schema.Assistant,
Content: content.String(),
ToolCalls: finalToolCalls,
ResponseMeta: finalResponseMeta, // Preserve usage and other metadata from streaming
}, nil
}
+4 -4
View File
@@ -109,7 +109,7 @@ func (c *OAuthClient) ExchangeCode(code, verifier string) (*AnthropicCredentials
parsedCode, parsedState := c.parseCodeAndState(code)
// Build request body
reqBody := map[string]interface{}{
reqBody := map[string]any{
"code": parsedCode,
"grant_type": "authorization_code",
"client_id": c.ClientID,
@@ -131,7 +131,7 @@ func (c *OAuthClient) ExchangeCode(code, verifier string) (*AnthropicCredentials
// rotated), and new expiration timestamp. Returns an error if the refresh fails or
// the refresh token is invalid.
func (c *OAuthClient) RefreshToken(refreshToken string) (*AnthropicCredentials, error) {
reqBody := map[string]interface{}{
reqBody := map[string]any{
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"client_id": c.ClientID,
@@ -141,7 +141,7 @@ func (c *OAuthClient) RefreshToken(refreshToken string) (*AnthropicCredentials,
}
// makeTokenRequest makes a token request to the OAuth server
func (c *OAuthClient) makeTokenRequest(body map[string]interface{}) (*AnthropicCredentials, error) {
func (c *OAuthClient) makeTokenRequest(body map[string]any) (*AnthropicCredentials, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
@@ -162,7 +162,7 @@ func (c *OAuthClient) makeTokenRequest(body map[string]interface{}) (*AnthropicC
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errorResp map[string]interface{}
var errorResp map[string]any
if err := json.NewDecoder(resp.Body).Decode(&errorResp); err == nil {
return nil, fmt.Errorf("token request failed: %v", errorResp)
}
+1 -5
View File
@@ -84,11 +84,7 @@ func executeBash(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToo
timeout := defaultTimeout
if timeoutMs := request.GetFloat("timeout", 0); timeoutMs > 0 {
timeoutDuration := time.Duration(timeoutMs) * time.Millisecond
if timeoutDuration > maxTimeout {
timeout = maxTimeout
} else {
timeout = timeoutDuration
}
timeout = min(timeoutDuration, maxTimeout)
}
// Check for banned commands
+2 -7
View File
@@ -2,6 +2,7 @@ package builtin
import (
"context"
"slices"
"testing"
"github.com/mark3labs/mcp-go/mcp"
@@ -23,13 +24,7 @@ func TestBashServerRegistry(t *testing.T) {
// Test that bash server is registered
servers := registry.ListServers()
found := false
for _, name := range servers {
if name == "bash" {
found = true
break
}
}
found := slices.Contains(servers, "bash")
if !found {
t.Error("bash server not found in registry")
+1 -5
View File
@@ -73,11 +73,7 @@ func executeFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallTo
timeout := defaultFetchTimeout
if timeoutSec := request.GetFloat("timeout", 0); timeoutSec > 0 {
timeoutDuration := time.Duration(timeoutSec) * time.Second
if timeoutDuration > maxFetchTimeout {
timeout = maxFetchTimeout
} else {
timeout = timeoutDuration
}
timeout = min(timeoutDuration, maxFetchTimeout)
}
// Validate URL
+2 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
@@ -26,13 +27,7 @@ func TestFetchServerRegistry(t *testing.T) {
// Test that fetch server is registered
servers := registry.ListServers()
found := false
for _, name := range servers {
if name == "fetch" {
found = true
break
}
}
found := slices.Contains(servers, "fetch")
if !found {
t.Error("fetch server not found in registry")
+22 -99
View File
@@ -10,13 +10,13 @@ import (
"strings"
"time"
"github.com/JohannesKaufmann/html-to-markdown"
md "github.com/JohannesKaufmann/html-to-markdown"
"github.com/PuerkitoBio/goquery"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/tidwall/gjson"
"charm.land/fantasy"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/tidwall/gjson"
)
const (
@@ -26,15 +26,14 @@ const (
)
// httpServerModel holds the model for the HTTP server
var httpServerModel model.ToolCallingChatModel
var httpServerModel fantasy.LanguageModel
// NewHTTPServer creates a new MCP server providing advanced HTTP fetching capabilities.
// The server includes tools for fetching web content, summarizing pages, extracting
// specific information, and filtering JSON responses. If an LLM model is provided,
// AI-powered summarization and extraction tools are enabled. Returns an error if
// server initialization fails.
func NewHTTPServer(llmModel model.ToolCallingChatModel) (*server.MCPServer, error) {
// Store the model globally for use in tool handlers
func NewHTTPServer(llmModel fantasy.LanguageModel) (*server.MCPServer, error) {
httpServerModel = llmModel
s := server.NewMCPServer("http-server", "1.0.0", server.WithToolCapabilities(true))
@@ -63,7 +62,7 @@ func NewHTTPServer(llmModel model.ToolCallingChatModel) (*server.MCPServer, erro
s.AddTool(fetchTool, executeHTTPFetch)
// Only add the summarize tool if we have a model
// Only add AI-powered tools if we have a model
if llmModel != nil {
summarizeTool := mcp.NewTool("fetch_summarize",
mcp.WithDescription(httpSummarizeDescription),
@@ -114,7 +113,6 @@ func NewHTTPServer(llmModel model.ToolCallingChatModel) (*server.MCPServer, erro
// executeHTTPFetch handles the fetch tool execution
func executeHTTPFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Extract parameters
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
@@ -125,32 +123,23 @@ func executeHTTPFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.Ca
return mcp.NewToolResultError("format parameter is required and must be a string"), nil
}
// Validate format
if format != "html" && format != "markdown" {
return mcp.NewToolResultError("format must be 'html' or 'markdown'"), nil
}
// Get bodyOnly parameter (optional, defaults to false)
bodyOnly := request.GetBool("bodyOnly", false)
// Parse timeout (optional)
timeout := httpDefaultFetchTimeout
if timeoutSec := request.GetFloat("timeout", 0); timeoutSec > 0 {
timeoutDuration := time.Duration(timeoutSec) * time.Second
if timeoutDuration > httpMaxFetchTimeout {
timeout = httpMaxFetchTimeout
} else {
timeout = timeoutDuration
}
timeout = min(timeoutDuration, httpMaxFetchTimeout)
}
// Validate URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL: %v", err)), nil
}
// Ensure URL has a scheme
if parsedURL.Scheme == "" {
urlStr = "https://" + urlStr
parsedURL, err = url.Parse(urlStr)
@@ -159,52 +148,43 @@ func executeHTTPFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.Ca
}
}
// Only allow HTTP and HTTPS
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return mcp.NewToolResultError("URL must use http:// or https://"), nil
}
// Create HTTP client with timeout
client := &http.Client{
Timeout: timeout,
}
// Create request with context
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to create request: %v", err)), nil
}
// Set headers to mimic a real browser
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
// Make the request
resp, err := client.Do(req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("request failed: %v", err)), nil
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return mcp.NewToolResultError(fmt.Sprintf("request failed with status code: %d", resp.StatusCode)), nil
}
// Check content length
if resp.ContentLength > httpMaxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
// Read response body with size limit
limitedReader := io.LimitReader(resp.Body, httpMaxResponseSize+1)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read response: %v", err)), nil
}
// Check if we exceeded the size limit
if len(bodyBytes) > httpMaxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
@@ -215,7 +195,6 @@ func executeHTTPFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.Ca
contentType = "unknown"
}
// Extract body content if requested
if bodyOnly && strings.Contains(contentType, "text/html") {
content, err = extractBodyContent(content)
if err != nil {
@@ -223,12 +202,10 @@ func executeHTTPFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.Ca
}
}
// Process content based on format
var output string
switch format {
case "html":
output = content
case "markdown":
if strings.Contains(contentType, "text/html") {
output, err = httpConvertHTMLToMarkdown(content)
@@ -236,12 +213,10 @@ func executeHTTPFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.Ca
return mcp.NewToolResultError(fmt.Sprintf("failed to convert HTML to markdown: %v", err)), nil
}
} else {
// Non-HTML content, wrap in code block
output = "```\n" + content + "\n```"
}
}
// Create result with metadata
title := fmt.Sprintf("%s (%s)", urlStr, contentType)
result := mcp.NewToolResultText(output)
result.Meta = &mcp.Meta{
@@ -263,14 +238,11 @@ func extractBodyContent(htmlContent string) (string, error) {
return "", err
}
// Find the body tag
bodySelection := doc.Find("body")
if bodySelection.Length() == 0 {
// No body tag found, return the original content
return htmlContent, nil
}
// Get the inner HTML of the body tag
bodyHTML, err := bodySelection.Html()
if err != nil {
return "", err
@@ -283,7 +255,6 @@ func extractBodyContent(htmlContent string) (string, error) {
func httpConvertHTMLToMarkdown(htmlContent string) (string, error) {
converter := md.NewConverter("", true, nil)
// Remove unwanted elements
converter.Remove("script")
converter.Remove("style")
converter.Remove("meta")
@@ -300,43 +271,39 @@ func httpConvertHTMLToMarkdown(htmlContent string) (string, error) {
// executeHTTPFetchSummarize handles the fetch_summarize tool execution
func executeHTTPFetchSummarize(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Get URL
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
}
// Get optional instructions
instructions := request.GetString("instructions", "Provide a concise summary of this content.")
// Fetch content as text (reuse existing logic)
content, err := httpFetchAndExtractText(ctx, urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch content: %v", err)), nil
}
// Check if we have a model available
if httpServerModel == nil {
return mcp.NewToolResultError("LLM model not available for summarization"), nil
}
// Create messages for the LLM
messages := []*schema.Message{
schema.UserMessage(fmt.Sprintf("%s\n\nContent to summarize:\n%s", instructions, content)),
// Use fantasy model for summarization
call := fantasy.Call{
Prompt: fantasy.Prompt{
fantasy.NewUserMessage(fmt.Sprintf("%s\n\nContent to summarize:\n%s", instructions, content)),
},
}
// Generate summary using the model directly
response, err := httpServerModel.Generate(ctx, messages)
response, err := httpServerModel.Generate(ctx, call)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Summarization failed: %v", err)), nil
}
// Return summary
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: response.Content,
Text: response.Content.Text(),
},
},
}, nil
@@ -344,30 +311,25 @@ func executeHTTPFetchSummarize(ctx context.Context, request mcp.CallToolRequest)
// executeHTTPFetchExtract handles the fetch_extract tool execution
func executeHTTPFetchExtract(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Get URL
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
}
// Get extraction instructions
instructions, err := request.RequireString("instructions")
if err != nil {
return mcp.NewToolResultError("instructions parameter is required and must be a string"), nil
}
// Fetch content as text (reuse existing logic)
content, err := httpFetchAndExtractText(ctx, urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch content: %v", err)), nil
}
// Check if we have a model available
if httpServerModel == nil {
return mcp.NewToolResultError("LLM model not available for extraction"), nil
}
// Create extraction prompt
extractionPrompt := fmt.Sprintf(`Extract the requested information from the following web content.
Extraction Instructions: %s
@@ -377,23 +339,22 @@ Web Content:
Please extract only the requested information. If the requested information is not found, respond with "Information not found" and explain what was searched for.`, instructions, content)
// Create messages for the LLM
messages := []*schema.Message{
schema.UserMessage(extractionPrompt),
call := fantasy.Call{
Prompt: fantasy.Prompt{
fantasy.NewUserMessage(extractionPrompt),
},
}
// Generate extraction using the model directly
response, err := httpServerModel.Generate(ctx, messages)
response, err := httpServerModel.Generate(ctx, call)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Extraction failed: %v", err)), nil
}
// Return extracted data
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: response.Content,
Text: response.Content.Text(),
},
},
}, nil
@@ -401,16 +362,13 @@ Please extract only the requested information. If the requested information is n
// httpFetchAndExtractText fetches content from URL and extracts as text
func httpFetchAndExtractText(ctx context.Context, urlStr string) (string, error) {
// Parse timeout (use default)
timeout := httpDefaultFetchTimeout
// Validate URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %v", err)
}
// Ensure URL has a scheme
if parsedURL.Scheme == "" {
urlStr = "https://" + urlStr
parsedURL, err = url.Parse(urlStr)
@@ -419,52 +377,43 @@ func httpFetchAndExtractText(ctx context.Context, urlStr string) (string, error)
}
}
// Only allow HTTP and HTTPS
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return "", fmt.Errorf("URL must use http:// or https://")
}
// Create HTTP client with timeout
client := &http.Client{
Timeout: timeout,
}
// Create request with context
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %v", err)
}
// Set headers to mimic a real browser
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
// Make the request
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("request failed with status code: %d", resp.StatusCode)
}
// Check content length
if resp.ContentLength > httpMaxResponseSize {
return "", fmt.Errorf("response too large (exceeds 5MB limit)")
}
// Read response body with size limit
limitedReader := io.LimitReader(resp.Body, httpMaxResponseSize+1)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
return "", fmt.Errorf("failed to read response: %v", err)
}
// Check if we exceeded the size limit
if len(bodyBytes) > httpMaxResponseSize {
return "", fmt.Errorf("response too large (exceeds 5MB limit)")
}
@@ -472,7 +421,6 @@ func httpFetchAndExtractText(ctx context.Context, urlStr string) (string, error)
content := string(bodyBytes)
contentType := resp.Header.Get("Content-Type")
// Extract text content
if strings.Contains(contentType, "text/html") {
return httpExtractTextFromHTML(content)
}
@@ -486,13 +434,10 @@ func httpExtractTextFromHTML(htmlContent string) (string, error) {
return "", err
}
// Remove script, style, and other non-content elements
doc.Find("script, style, noscript, iframe, object, embed").Remove()
// Extract text content
text := doc.Text()
// Clean up whitespace
lines := strings.Split(text, "\n")
var cleanLines []string
for _, line := range lines {
@@ -507,7 +452,6 @@ func httpExtractTextFromHTML(htmlContent string) (string, error) {
// executeHTTPFetchFilteredJSON handles the fetch_filtered_json tool execution
func executeHTTPFetchFilteredJSON(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Extract parameters
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
@@ -518,24 +462,17 @@ func executeHTTPFetchFilteredJSON(ctx context.Context, request mcp.CallToolReque
return mcp.NewToolResultError("path parameter is required and must be a string"), nil
}
// Parse timeout (optional)
timeout := httpDefaultFetchTimeout
if timeoutSec := request.GetFloat("timeout", 0); timeoutSec > 0 {
timeoutDuration := time.Duration(timeoutSec) * time.Second
if timeoutDuration > httpMaxFetchTimeout {
timeout = httpMaxFetchTimeout
} else {
timeout = timeoutDuration
}
timeout = min(timeoutDuration, httpMaxFetchTimeout)
}
// Validate URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL: %v", err)), nil
}
// Ensure URL has a scheme
if parsedURL.Scheme == "" {
urlStr = "https://" + urlStr
parsedURL, err = url.Parse(urlStr)
@@ -544,75 +481,62 @@ func executeHTTPFetchFilteredJSON(ctx context.Context, request mcp.CallToolReque
}
}
// Only allow HTTP and HTTPS
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return mcp.NewToolResultError("URL must use http:// or https://"), nil
}
// Create HTTP client with timeout
client := &http.Client{
Timeout: timeout,
}
// Create request with context
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to create request: %v", err)), nil
}
// Set headers to mimic a real browser and accept JSON
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
// Make the request
resp, err := client.Do(req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("request failed: %v", err)), nil
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return mcp.NewToolResultError(fmt.Sprintf("request failed with status code: %d", resp.StatusCode)), nil
}
// Check content length
if resp.ContentLength > httpMaxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
// Read response body with size limit
limitedReader := io.LimitReader(resp.Body, httpMaxResponseSize+1)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read response: %v", err)), nil
}
// Check if we exceeded the size limit
if len(bodyBytes) > httpMaxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
content := string(bodyBytes)
// Validate that the content is valid JSON
if !json.Valid(bodyBytes) {
return mcp.NewToolResultError("response is not valid JSON"), nil
}
// Apply gjson path to filter the JSON
result := gjson.Get(content, path)
if !result.Exists() {
return mcp.NewToolResultError(fmt.Sprintf("gjson path '%s' did not match any data", path)), nil
}
// Get the filtered JSON as a string
var filteredJSON string
if result.IsArray() || result.IsObject() {
filteredJSON = result.Raw
} else {
// For primitive values, wrap in quotes if it's a string
if result.Type == gjson.String {
filteredJSON = fmt.Sprintf(`"%s"`, result.Str)
} else {
@@ -620,7 +544,6 @@ func executeHTTPFetchFilteredJSON(ctx context.Context, request mcp.CallToolReque
}
}
// Create result with metadata
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/json"
+2 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
@@ -26,13 +27,7 @@ func TestHTTPServerRegistry(t *testing.T) {
// Test that HTTP server is registered
servers := registry.ListServers()
found := false
for _, name := range servers {
if name == "http" {
found = true
break
}
}
found := slices.Contains(servers, "http")
if !found {
t.Error("http server not found in registry")
+10 -20
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"os"
"github.com/cloudwego/eino/components/model"
"charm.land/fantasy"
"github.com/mark3labs/mcp-filesystem-server/filesystemserver"
"github.com/mark3labs/mcp-go/server"
)
@@ -19,7 +19,6 @@ type BuiltinServerWrapper struct {
// a no-op as the server is initialized during creation. Returns an error if
// initialization fails.
func (w *BuiltinServerWrapper) Initialize() error {
// The server is already initialized when created
return nil
}
@@ -33,7 +32,7 @@ func (w *BuiltinServerWrapper) GetServer() *server.MCPServer {
// It provides a centralized registry for creating instances of builtin MCP servers
// with their respective configurations.
type Registry struct {
servers map[string]func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error)
servers map[string]func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error)
}
// NewRegistry creates a new builtin server registry with all available builtin
@@ -41,10 +40,9 @@ type Registry struct {
// and HTTP servers.
func NewRegistry() *Registry {
r := &Registry{
servers: make(map[string]func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error)),
servers: make(map[string]func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error)),
}
// Register builtin servers
r.registerFilesystemServer()
r.registerBashServer()
r.registerTodoServer()
@@ -58,7 +56,7 @@ func NewRegistry() *Registry {
// parameter provides server-specific configuration, and the model parameter provides
// an optional LLM for AI-powered features. Returns an error if the server name
// is unknown or if creation fails.
func (r *Registry) CreateServer(name string, options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) {
func (r *Registry) CreateServer(name string, options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
factory, exists := r.servers[name]
if !exists {
return nil, fmt.Errorf("unknown builtin server: %s", name)
@@ -67,8 +65,7 @@ func (r *Registry) CreateServer(name string, options map[string]any, model model
return factory(options, model)
}
// ListServers returns a list of all available builtin server names that can be
// created using CreateServer. The order of names is not guaranteed.
// ListServers returns a list of all available builtin server names.
func (r *Registry) ListServers() []string {
names := make([]string, 0, len(r.servers))
for name := range r.servers {
@@ -79,8 +76,7 @@ func (r *Registry) ListServers() []string {
// registerFilesystemServer registers the filesystem server
func (r *Registry) registerFilesystemServer() {
r.servers["fs"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) {
// Extract allowed directories from options
r.servers["fs"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
var allowedDirs []string
if dirs, ok := options["allowed_directories"]; ok {
switch v := dirs.(type) {
@@ -101,7 +97,6 @@ func (r *Registry) registerFilesystemServer() {
return nil, fmt.Errorf("allowed_directories must be a string or array of strings")
}
} else {
// Default to current working directory if no directories specified
cwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get current working directory: %v", err)
@@ -109,7 +104,6 @@ func (r *Registry) registerFilesystemServer() {
allowedDirs = []string{cwd}
}
// Create the filesystem server
server, err := filesystemserver.NewFilesystemServer(allowedDirs)
if err != nil {
return nil, fmt.Errorf("failed to create filesystem server: %v", err)
@@ -121,8 +115,7 @@ func (r *Registry) registerFilesystemServer() {
// registerBashServer registers the bash server
func (r *Registry) registerBashServer() {
r.servers["bash"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) {
// Create the bash server
r.servers["bash"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
server, err := NewBashServer()
if err != nil {
return nil, fmt.Errorf("failed to create bash server: %v", err)
@@ -134,8 +127,7 @@ func (r *Registry) registerBashServer() {
// registerTodoServer registers the todo server
func (r *Registry) registerTodoServer() {
r.servers["todo"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) {
// Create the todo server
r.servers["todo"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
server, err := NewTodoServer()
if err != nil {
return nil, fmt.Errorf("failed to create todo server: %v", err)
@@ -147,8 +139,7 @@ func (r *Registry) registerTodoServer() {
// registerFetchServer registers the fetch server
func (r *Registry) registerFetchServer() {
r.servers["fetch"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) {
// Create the fetch server
r.servers["fetch"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
server, err := NewFetchServer()
if err != nil {
return nil, fmt.Errorf("failed to create fetch server: %v", err)
@@ -160,8 +151,7 @@ func (r *Registry) registerFetchServer() {
// registerHTTPServer registers the HTTP server
func (r *Registry) registerHTTPServer() {
r.servers["http"] = func(options map[string]any, model model.ToolCallingChatModel) (*BuiltinServerWrapper, error) {
// Create the HTTP server
r.servers["http"] = func(options map[string]any, model fantasy.LanguageModel) (*BuiltinServerWrapper, error) {
server, err := NewHTTPServer(model)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP server: %v", err)
+2 -7
View File
@@ -2,6 +2,7 @@ package builtin
import (
"context"
"slices"
"testing"
"github.com/mark3labs/mcp-go/mcp"
@@ -23,13 +24,7 @@ func TestTodoServerRegistry(t *testing.T) {
// Test that todo server is registered
servers := registry.ListServers()
found := false
for _, name := range servers {
if name == "todo" {
found = true
break
}
}
found := slices.Contains(servers, "todo")
if !found {
t.Error("todo server not found in registry")
+2 -7
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
@@ -77,13 +78,7 @@ mcpServers:
// Check command has substituted value
expectedInCommand := "GITHUB_PERSONAL_ACCESS_TOKEN=ghp_test_token_123"
found := false
for _, arg := range githubServer.Command {
if arg == expectedInCommand {
found = true
break
}
}
found := slices.Contains(githubServer.Command, expectedInCommand)
if !found {
t.Errorf("Expected '%s' in command, got: %v", expectedInCommand, githubServer.Command)
}
+2 -2
View File
@@ -51,10 +51,10 @@ func fixEnvironmentCase(config *Config) {
// Check if we have mcpServers in the raw config
if mcpServersRaw, ok := rawConfig["mcpservers"]; ok {
if mcpServersMap, ok := mcpServersRaw.(map[string]interface{}); ok {
if mcpServersMap, ok := mcpServersRaw.(map[string]any); ok {
// Iterate through each server
for serverName, serverDataRaw := range mcpServersMap {
if serverData, ok := serverDataRaw.(map[string]interface{}); ok {
if serverData, ok := serverDataRaw.(map[string]any); ok {
// Check if this server has an environment field
if _, hasEnv := serverData["environment"]; hasEnv {
// Get the server config from our parsed config
+4 -4
View File
@@ -24,15 +24,15 @@ mcpServers:
// Test 1: Direct YAML parsing
t.Run("DirectYAMLParsing", func(t *testing.T) {
var yamlData map[string]interface{}
var yamlData map[string]any
err := yaml.Unmarshal([]byte(yamlContent), &yamlData)
if err != nil {
t.Fatalf("YAML unmarshal error: %v", err)
}
servers := yamlData["mcpServers"].(map[string]interface{})
testServer := servers["test"].(map[string]interface{})
env := testServer["environment"].(map[string]interface{})
servers := yamlData["mcpServers"].(map[string]any)
testServer := servers["test"].(map[string]any)
env := testServer["environment"].(map[string]any)
if env["KEY1"] != "value1" {
t.Errorf("Expected KEY1=value1, got %v", env["KEY1"])
+3 -3
View File
@@ -74,7 +74,7 @@ func (e *Executor) PopulateCommonFields(event HookEvent) CommonInput {
// it matches hooks based on tool name patterns. Hooks are executed in parallel
// with configurable timeouts. Returns a combined HookOutput from all executed
// hooks, with blocking decisions taking precedence.
func (e *Executor) ExecuteHooks(ctx context.Context, event HookEvent, input interface{}) (*HookOutput, error) {
func (e *Executor) ExecuteHooks(ctx context.Context, event HookEvent, input any) (*HookOutput, error) {
matchers, ok := e.config.Hooks[event]
if !ok || len(matchers) == 0 {
return nil, nil
@@ -119,7 +119,7 @@ func (e *Executor) ExecuteHooks(ctx context.Context, event HookEvent, input inte
}
// executeHook runs a single hook command
func (e *Executor) executeHook(ctx context.Context, hook HookEntry, input interface{}) *hookResult {
func (e *Executor) executeHook(ctx context.Context, hook HookEntry, input any) *hookResult {
// Prepare input JSON
inputJSON, err := json.Marshal(input)
if err != nil {
@@ -187,7 +187,7 @@ func matchesPattern(pattern, toolName string) bool {
}
// extractToolName gets the tool name from various input types
func extractToolName(input interface{}) string {
func extractToolName(input any) string {
switch v := input.(type) {
case *PreToolUseInput:
return v.ToolName
+4 -3
View File
@@ -42,7 +42,7 @@ echo '{"decision": "approve", "reason": "Approved by test"}'
name string
config *HookConfig
event HookEvent
input interface{}
input any
expected *HookOutput
wantErr bool
}{
@@ -87,7 +87,7 @@ echo '{"decision": "approve", "reason": "Approved by test"}'
expected: &HookOutput{
Decision: "block",
Reason: "Blocked by policy\n",
Continue: boolPtr(false),
Continue: new(false),
},
},
{
@@ -166,8 +166,9 @@ echo '{"decision": "approve", "reason": "Approved by test"}'
}
}
//go:fix inline
func boolPtr(b bool) *bool {
return &b
return new(b)
}
func compareHookOutputs(a, b *HookOutput) bool {
-283
View File
@@ -1,283 +0,0 @@
package anthropic
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
einoclaude "github.com/cloudwego/eino-ext/components/model/claude"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// CustomChatModel wraps the eino-ext Claude model with custom tool schema handling.
// It provides a compatibility layer that fixes malformed JSON in tool calls and
// ensures proper schema validation for Anthropic's API requirements.
// This wrapper is necessary to handle edge cases where the underlying library
// may generate invalid JSON for empty tool inputs or missing properties.
type CustomChatModel struct {
// wrapped is the underlying eino-ext Claude model instance
wrapped *einoclaude.ChatModel
}
// CustomRoundTripper intercepts HTTP requests to fix Anthropic function schemas.
// It acts as a middleware that modifies requests before they reach the Anthropic API,
// ensuring that tool schemas and function calls are properly formatted.
// This is particularly important for handling edge cases like empty tool inputs
// or missing schema properties that would otherwise cause API errors.
type CustomRoundTripper struct {
// wrapped is the underlying HTTP transport to use for actual requests
wrapped http.RoundTripper
}
// NewCustomChatModel creates a new custom Anthropic chat model.
// It wraps the standard eino-ext Claude model with additional request
// preprocessing to ensure compatibility with Anthropic's API requirements.
//
// Parameters:
// - ctx: Context for the operation
// - config: Configuration for the Claude model including API key, model name, and parameters
//
// Returns:
// - *CustomChatModel: A wrapped Claude model with enhanced compatibility
// - error: Returns an error if model creation fails
//
// The custom model automatically:
// - Fixes malformed JSON in tool calls
// - Ensures tool schemas have required properties
// - Handles empty or missing input fields in function calls
func NewCustomChatModel(ctx context.Context, config *einoclaude.Config) (*CustomChatModel, error) {
// Create a custom HTTP client that intercepts requests
if config.HTTPClient == nil {
config.HTTPClient = &http.Client{}
}
// Wrap the transport with our custom round tripper
if config.HTTPClient.Transport == nil {
config.HTTPClient.Transport = http.DefaultTransport
}
config.HTTPClient.Transport = &CustomRoundTripper{
wrapped: config.HTTPClient.Transport,
}
// Create the wrapped model
wrapped, err := einoclaude.NewChatModel(ctx, config)
if err != nil {
return nil, err
}
return &CustomChatModel{
wrapped: wrapped,
}, nil
}
// RoundTrip implements http.RoundTripper to intercept and fix requests.
// It preprocesses outgoing requests to the Anthropic API to ensure
// they meet the API's requirements for tool schemas and function calls.
//
// Parameters:
// - req: The HTTP request to be sent to the Anthropic API
//
// Returns:
// - *http.Response: The response from the Anthropic API
// - error: Any error that occurred during the request
//
// The method performs the following fixes:
// - Ensures tool input_schema properties are not null
// - Fixes malformed JSON patterns in tool_use content
// - Validates and corrects empty or invalid function call inputs
func (rt *CustomRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Only process Anthropic API requests
if !strings.Contains(req.URL.Host, "anthropic.com") {
return rt.wrapped.RoundTrip(req)
}
// Read the request body
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body = io.NopCloser(bytes.NewReader(body))
// Apply string-based fixes BEFORE JSON parsing for malformed patterns
bodyStr := string(body)
// Replace common malformed patterns - be more specific about context
replacements := []struct {
old string
new string
}{
// Handle input field in tool_use objects
{`"input":,"name"`, `"input":{},"name"`},
{`"input":,"type"`, `"input":{},"type"`},
{`"input":}`, `"input":{}}`},
// Handle arguments field in function calls
{`"arguments":,"name"`, `"arguments":"{}","name"`},
{`"arguments":,"type"`, `"arguments":"{}","type"`},
{`"arguments":}`, `"arguments":"{}"`},
// Fallback patterns (less specific)
{`"input":,`, `"input":{}`},
{`"arguments":,`, `"arguments":"{}"`},
}
for _, r := range replacements {
if strings.Contains(bodyStr, r.old) {
bodyStr = strings.ReplaceAll(bodyStr, r.old, r.new)
}
}
// Parse the JSON request (after string fixes)
var requestData map[string]interface{}
if err := json.Unmarshal([]byte(bodyStr), &requestData); err != nil {
// Return the original request to avoid panic
req.Body = io.NopCloser(bytes.NewReader(body))
req.ContentLength = int64(len(body))
return rt.wrapped.RoundTrip(req)
}
// Fix tool schemas if present
if tools, ok := requestData["tools"].([]interface{}); ok {
for _, tool := range tools {
if toolMap, ok := tool.(map[string]interface{}); ok {
if inputSchema, ok := toolMap["input_schema"].(map[string]interface{}); ok {
// Ensure properties exists and is not null
if properties, exists := inputSchema["properties"]; !exists || properties == nil {
inputSchema["properties"] = map[string]interface{}{}
} else if propertiesMap, ok := properties.(map[string]interface{}); ok {
// Ensure each property has a type
for _, propValue := range propertiesMap {
if propMap, ok := propValue.(map[string]interface{}); ok {
if _, hasType := propMap["type"]; !hasType {
propMap["type"] = "string"
}
}
}
}
}
}
}
}
// Fix tool_use content in messages if present
if messages, ok := requestData["messages"].([]interface{}); ok {
for _, message := range messages {
if msgMap, ok := message.(map[string]interface{}); ok {
if content, ok := msgMap["content"].([]interface{}); ok {
for _, contentItem := range content {
if contentMap, ok := contentItem.(map[string]interface{}); ok {
if contentType, ok := contentMap["type"].(string); ok && contentType == "tool_use" {
// Ensure tool_use input is valid JSON
if input, exists := contentMap["input"]; exists {
// If input is nil or empty, set it to an empty object
if input == nil {
contentMap["input"] = map[string]interface{}{}
} else if inputBytes, ok := input.(json.RawMessage); ok {
if len(inputBytes) == 0 {
contentMap["input"] = map[string]interface{}{}
} else {
// Validate that it's valid JSON
var temp interface{}
if err := json.Unmarshal(inputBytes, &temp); err != nil {
contentMap["input"] = map[string]interface{}{}
}
}
} else if inputStr, ok := input.(string); ok {
// Handle string inputs that might be empty or invalid JSON
if inputStr == "" || inputStr == "{}" {
contentMap["input"] = map[string]interface{}{}
} else {
// Try to parse as JSON
var temp interface{}
if err := json.Unmarshal([]byte(inputStr), &temp); err != nil {
contentMap["input"] = map[string]interface{}{}
}
}
}
} else {
// If input field doesn't exist, add it as empty object
contentMap["input"] = map[string]interface{}{}
}
}
}
}
}
}
}
}
// Marshal the fixed request back to JSON
fixedBody, err := json.Marshal(requestData)
if err != nil {
return nil, err
}
// Use the fixed body from JSON marshaling
finalBodyStr := string(fixedBody)
// Validate the final JSON
var finalCheck interface{}
if err := json.Unmarshal([]byte(finalBodyStr), &finalCheck); err != nil {
return nil, err
}
// Create new request with fixed body
req.Body = io.NopCloser(strings.NewReader(finalBodyStr))
req.ContentLength = int64(len(finalBodyStr))
// Make the actual request
return rt.wrapped.RoundTrip(req)
}
// Generate implements the model.BaseChatModel interface.
// It generates a single response from the model based on the input messages.
//
// Parameters:
// - ctx: Context for the operation, supporting cancellation and deadlines
// - input: The conversation history as a slice of messages
// - opts: Optional configuration options for the generation
//
// Returns:
// - *schema.Message: The generated response message
// - error: Any error that occurred during generation
func (m *CustomChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
return m.wrapped.Generate(ctx, input, opts...)
}
// Stream implements the model.BaseChatModel interface.
// It generates a streaming response from the model, allowing incremental
// processing of the model's output as it's generated.
//
// Parameters:
// - ctx: Context for the operation, supporting cancellation and deadlines
// - input: The conversation history as a slice of messages
// - opts: Optional configuration options for the generation
//
// Returns:
// - *schema.StreamReader[*schema.Message]: A reader for the streaming response
// - error: Any error that occurred during stream setup
func (m *CustomChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return m.wrapped.Stream(ctx, input, opts...)
}
// WithTools implements the model.ToolCallingChatModel interface.
// It creates a new model instance with the specified tools available for function calling.
// The original model instance remains unchanged.
//
// Parameters:
// - tools: A slice of tool definitions that the model can use
//
// Returns:
// - model.ToolCallingChatModel: A new model instance with tools enabled
// - error: Returns an error if tool binding fails
func (m *CustomChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
wrappedWithTools, err := m.wrapped.WithTools(tools)
if err != nil {
return nil, err
}
return &CustomChatModel{
wrapped: wrappedWithTools.(*einoclaude.ChatModel),
}, nil
}
-791
View File
@@ -1,791 +0,0 @@
package gemini
import (
"context"
"encoding/json"
"errors"
"fmt"
"runtime/debug"
"github.com/cloudwego/eino/callbacks"
"github.com/cloudwego/eino/components"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/eino-contrib/jsonschema"
"github.com/getkin/kin-openapi/openapi3"
"google.golang.org/genai"
)
var _ model.ToolCallingChatModel = (*ChatModel)(nil)
// NewChatModel creates a new Gemini chat model instance.
// It initializes a Google Gemini model with the specified configuration,
// supporting both text generation and tool calling capabilities.
//
// Parameters:
// - ctx: The context for the operation (currently unused but kept for interface consistency)
// - cfg: Configuration for the Gemini model including client, model name, and parameters
//
// Returns:
// - *ChatModel: A Gemini chat model instance implementing ToolCallingChatModel
// - error: Any error that occurred during creation
//
// Example:
//
// client, _ := genai.NewClient(ctx, &genai.ClientConfig{
// APIKey: "your-api-key",
// })
// model, err := gemini.NewChatModel(ctx, &gemini.Config{
// Client: client,
// Model: "gemini-pro",
// MaxTokens: &maxTokens,
// })
func NewChatModel(_ context.Context, cfg *Config) (*ChatModel, error) {
return &ChatModel{
cli: cfg.Client,
model: cfg.Model,
maxTokens: cfg.MaxTokens,
temperature: cfg.Temperature,
topP: cfg.TopP,
topK: cfg.TopK,
responseSchema: cfg.ResponseSchema,
enableCodeExecution: cfg.EnableCodeExecution,
safetySettings: cfg.SafetySettings,
}, nil
}
// Config contains the configuration options for the Gemini model
type Config struct {
// Client is the Gemini API client instance
// Required for making API calls to Gemini
Client *genai.Client
// Model specifies which Gemini model to use
// Examples: "gemini-pro", "gemini-pro-vision", "gemini-1.5-flash"
Model string
// MaxTokens limits the maximum number of tokens in the response
// Optional. Example: maxTokens := 100
MaxTokens *int
// Temperature controls randomness in responses
// Range: [0.0, 1.0], where 0.0 is more focused and 1.0 is more creative
// Optional. Example: temperature := float32(0.7)
Temperature *float32
// TopP controls diversity via nucleus sampling
// Range: [0.0, 1.0], where 1.0 disables nucleus sampling
// Optional. Example: topP := float32(0.95)
TopP *float32
// TopK controls diversity by limiting the top K tokens to sample from
// Optional. Example: topK := int32(40)
TopK *int32
// ResponseSchema defines the structure for JSON responses
// Optional. Used when you want structured output in JSON format
ResponseSchema *jsonschema.Schema
// EnableCodeExecution allows the model to execute code
// Warning: Be cautious with code execution in production
// Optional. Default: false
EnableCodeExecution bool
// SafetySettings configures content filtering for different harm categories
// Controls the model's filtering behavior for potentially harmful content
// Optional.
SafetySettings []*genai.SafetySetting
}
// options contains Gemini-specific options for model configuration.
// These are options that are specific to the Gemini API and not part
// of the common model options interface.
type options struct {
// TopK limits the number of tokens to sample from
TopK *int32
// ResponseSchema defines the expected JSON structure for responses
ResponseSchema *jsonschema.Schema
}
// ChatModel implements the Gemini chat model for the eino framework.
// It provides integration with Google's Gemini API, supporting both
// text generation and tool calling capabilities.
type ChatModel struct {
// cli is the Gemini API client instance
cli *genai.Client
// model specifies which Gemini model to use
model string
// maxTokens limits the response length
maxTokens *int
// topP controls nucleus sampling
topP *float32
// temperature controls randomness
temperature *float32
// topK limits token sampling
topK *int32
// responseSchema for structured JSON output
responseSchema *jsonschema.Schema
// tools converted to Gemini format
tools []*genai.Tool
// origTools stores the original tool definitions
origTools []*schema.ToolInfo
// toolChoice controls how tools are used
toolChoice *schema.ToolChoice
// enableCodeExecution allows code execution (use with caution)
enableCodeExecution bool
// safetySettings for content filtering
safetySettings []*genai.SafetySetting
}
// Generate generates a single response from the Gemini model.
// It processes the input messages and returns a complete response.
//
// Parameters:
// - ctx: Context for the operation, supporting cancellation and callbacks
// - input: The conversation history as a slice of messages
// - opts: Optional configuration options for the generation
//
// Returns:
// - *schema.Message: The generated response message with content and metadata
// - error: Any error that occurred during generation
func (cm *ChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (message *schema.Message, err error) {
ctx = callbacks.EnsureRunInfo(ctx, cm.GetType(), components.ComponentOfChatModel)
config, conf, err := cm.buildGenerateConfig(opts...)
if err != nil {
return nil, err
}
ctx = callbacks.OnStart(ctx, &model.CallbackInput{
Messages: input,
Tools: model.GetCommonOptions(&model.Options{Tools: cm.origTools}, opts...).Tools,
Config: conf,
})
defer func() {
if err != nil {
callbacks.OnError(ctx, err)
}
}()
if len(input) == 0 {
return nil, fmt.Errorf("gemini input is empty")
}
contents, err := cm.convertSchemaMessages(input)
if err != nil {
return nil, err
}
result, err := cm.cli.Models.GenerateContent(ctx, cm.model, contents, config)
if err != nil {
return nil, fmt.Errorf("generate content failed: %w", err)
}
message, err = cm.convertResponse(result)
if err != nil {
return nil, fmt.Errorf("convert response failed: %w", err)
}
callbacks.OnEnd(ctx, cm.convertCallbackOutput(message, conf))
return message, nil
}
// Stream generates a streaming response from the Gemini model.
// It allows incremental processing of the model's output as it's generated.
//
// Parameters:
// - ctx: Context for the operation, supporting cancellation and callbacks
// - input: The conversation history as a slice of messages
// - opts: Optional configuration options for the generation
//
// Returns:
// - *schema.StreamReader[*schema.Message]: A reader for the streaming response
// - error: Any error that occurred during stream setup
func (cm *ChatModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (result *schema.StreamReader[*schema.Message], err error) {
ctx = callbacks.EnsureRunInfo(ctx, cm.GetType(), components.ComponentOfChatModel)
config, conf, err := cm.buildGenerateConfig(opts...)
if err != nil {
return nil, err
}
ctx = callbacks.OnStart(ctx, &model.CallbackInput{
Messages: input,
Tools: model.GetCommonOptions(&model.Options{Tools: cm.origTools}, opts...).Tools,
Config: conf,
})
defer func() {
if err != nil {
callbacks.OnError(ctx, err)
}
}()
if len(input) == 0 {
return nil, fmt.Errorf("gemini input is empty")
}
contents, err := cm.convertSchemaMessages(input)
if err != nil {
return nil, err
}
sr, sw := schema.Pipe[*model.CallbackOutput](1)
go func() {
defer func() {
panicErr := recover()
if panicErr != nil {
_ = sw.Send(nil, newPanicErr(panicErr, debug.Stack()))
}
sw.Close()
}()
for resp, err := range cm.cli.Models.GenerateContentStream(ctx, cm.model, contents, config) {
if err != nil {
sw.Send(nil, err)
return
}
message, err := cm.convertResponse(resp)
if err != nil {
sw.Send(nil, err)
return
}
closed := sw.Send(cm.convertCallbackOutput(message, conf), nil)
if closed {
return
}
}
}()
srList := sr.Copy(2)
callbacks.OnEndWithStreamOutput(ctx, srList[0])
return schema.StreamReaderWithConvert(srList[1], func(t *model.CallbackOutput) (*schema.Message, error) {
return t.Message, nil
}), nil
}
// WithTools creates a new model instance with the specified tools available.
// It returns a new ChatModel with tools configured for function calling.
// The original model instance remains unchanged.
//
// Parameters:
// - tools: A slice of tool definitions that the model can use
//
// Returns:
// - model.ToolCallingChatModel: A new model instance with tools enabled
// - error: Returns an error if no tools provided or conversion fails
func (cm *ChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
if len(tools) == 0 {
return nil, errors.New("no tools to bind")
}
gTools, err := cm.convertToGeminiTools(tools)
if err != nil {
return nil, fmt.Errorf("convert to gemini tools failed: %w", err)
}
tc := schema.ToolChoiceAllowed
ncm := *cm
ncm.toolChoice = &tc
ncm.tools = gTools
ncm.origTools = tools
return &ncm, nil
}
// BindTools binds tools to the current model instance.
// Unlike WithTools, this modifies the current instance rather than
// creating a new one. Tools are set to "allowed" mode by default.
//
// Parameters:
// - tools: A slice of tool definitions to bind to the model
//
// Returns:
// - error: Returns an error if no tools provided or conversion fails
func (cm *ChatModel) BindTools(tools []*schema.ToolInfo) error {
if len(tools) == 0 {
return errors.New("no tools to bind")
}
gTools, err := cm.convertToGeminiTools(tools)
if err != nil {
return err
}
cm.tools = gTools
cm.origTools = tools
tc := schema.ToolChoiceAllowed
cm.toolChoice = &tc
return nil
}
// BindForcedTools binds tools to the current model instance in forced mode.
// This ensures the model will always use one of the provided tools
// rather than generating a text response.
//
// Parameters:
// - tools: A slice of tool definitions to bind to the model
//
// Returns:
// - error: Returns an error if no tools provided or conversion fails
func (cm *ChatModel) BindForcedTools(tools []*schema.ToolInfo) error {
if len(tools) == 0 {
return errors.New("no tools to bind")
}
gTools, err := cm.convertToGeminiTools(tools)
if err != nil {
return err
}
cm.tools = gTools
cm.origTools = tools
tc := schema.ToolChoiceForced
cm.toolChoice = &tc
return nil
}
func (cm *ChatModel) buildGenerateConfig(opts ...model.Option) (*genai.GenerateContentConfig, *model.Config, error) {
commonOptions := model.GetCommonOptions(&model.Options{
Temperature: cm.temperature,
MaxTokens: cm.maxTokens,
TopP: cm.topP,
Tools: nil,
ToolChoice: cm.toolChoice,
}, opts...)
geminiOptions := model.GetImplSpecificOptions(&options{
TopK: cm.topK,
ResponseSchema: cm.responseSchema,
}, opts...)
conf := &model.Config{}
config := &genai.GenerateContentConfig{}
// Set model
if commonOptions.Model != nil {
conf.Model = *commonOptions.Model
} else {
conf.Model = cm.model
}
// Set temperature
if commonOptions.Temperature != nil {
conf.Temperature = *commonOptions.Temperature
config.Temperature = commonOptions.Temperature
} else if cm.temperature != nil {
conf.Temperature = *cm.temperature
config.Temperature = cm.temperature
}
// Set max tokens
if commonOptions.MaxTokens != nil {
conf.MaxTokens = *commonOptions.MaxTokens
config.MaxOutputTokens = int32(*commonOptions.MaxTokens)
} else if cm.maxTokens != nil {
conf.MaxTokens = *cm.maxTokens
config.MaxOutputTokens = int32(*cm.maxTokens)
}
// Set top P
if commonOptions.TopP != nil {
conf.TopP = *commonOptions.TopP
config.TopP = commonOptions.TopP
} else if cm.topP != nil {
conf.TopP = *cm.topP
config.TopP = cm.topP
}
// Set top K
if geminiOptions.TopK != nil {
config.TopK = genai.Ptr(float32(*geminiOptions.TopK))
} else if cm.topK != nil {
config.TopK = genai.Ptr(float32(*cm.topK))
}
// Set tools
tools := cm.tools
if commonOptions.Tools != nil {
var err error
tools, err = cm.convertToGeminiTools(commonOptions.Tools)
if err != nil {
return nil, nil, err
}
}
if len(tools) > 0 {
config.Tools = tools
}
// Set tool choice
if commonOptions.ToolChoice != nil {
switch *commonOptions.ToolChoice {
case schema.ToolChoiceForbidden:
config.ToolConfig = &genai.ToolConfig{
FunctionCallingConfig: &genai.FunctionCallingConfig{
Mode: genai.FunctionCallingConfigModeNone,
},
}
case schema.ToolChoiceAllowed:
config.ToolConfig = &genai.ToolConfig{
FunctionCallingConfig: &genai.FunctionCallingConfig{
Mode: genai.FunctionCallingConfigModeAuto,
},
}
case schema.ToolChoiceForced:
if len(tools) == 0 {
return nil, nil, fmt.Errorf("tool choice is forced but no tools provided")
}
config.ToolConfig = &genai.ToolConfig{
FunctionCallingConfig: &genai.FunctionCallingConfig{
Mode: genai.FunctionCallingConfigModeAny,
},
}
default:
return nil, nil, fmt.Errorf("tool choice=%s not supported", *commonOptions.ToolChoice)
}
}
// Set safety settings
if len(cm.safetySettings) > 0 {
config.SafetySettings = cm.safetySettings
}
// Set response schema for JSON mode
if geminiOptions.ResponseSchema != nil {
gSchema, err := cm.convertJSONSchema(geminiOptions.ResponseSchema)
if err != nil {
return nil, nil, fmt.Errorf("convert response schema failed: %w", err)
}
config.ResponseMIMEType = "application/json"
config.ResponseSchema = gSchema
}
return config, conf, nil
}
func (cm *ChatModel) convertToGeminiTools(tools []*schema.ToolInfo) ([]*genai.Tool, error) {
if len(tools) == 0 {
return nil, nil
}
var functionDeclarations []*genai.FunctionDeclaration
for _, tool := range tools {
openSchema, err := tool.ToJSONSchema()
if err != nil {
return nil, fmt.Errorf("get open schema failed: %w", err)
}
gSchema, err := cm.convertJSONSchema(openSchema)
if err != nil {
return nil, fmt.Errorf("convert open schema failed: %w", err)
}
funcDecl := &genai.FunctionDeclaration{
Name: tool.Name,
Description: tool.Desc,
Parameters: gSchema,
}
functionDeclarations = append(functionDeclarations, funcDecl)
}
return []*genai.Tool{{FunctionDeclarations: functionDeclarations}}, nil
}
func (cm *ChatModel) convertJSONSchema(schema *jsonschema.Schema) (*genai.Schema, error) {
if schema == nil {
return nil, nil
}
result := &genai.Schema{
Description: schema.Description,
}
switch schema.Type {
case openapi3.TypeObject:
result.Type = genai.TypeObject
if schema.Properties != nil {
properties := make(map[string]*genai.Schema)
for pair := schema.Properties.Oldest(); pair != nil; pair = pair.Next() {
propSchema, err := cm.convertJSONSchema(pair.Value)
if err != nil {
return nil, err
}
properties[pair.Key] = propSchema
}
result.Properties = properties
}
if schema.Required != nil {
result.Required = schema.Required
}
case openapi3.TypeArray:
result.Type = genai.TypeArray
if schema.Items != nil {
itemSchema, err := cm.convertJSONSchema(schema.Items)
if err != nil {
return nil, err
}
result.Items = itemSchema
}
case openapi3.TypeString:
result.Type = genai.TypeString
if schema.Enum != nil {
enums := make([]string, 0, len(schema.Enum))
for _, e := range schema.Enum {
if str, ok := e.(string); ok {
enums = append(enums, str)
} else {
return nil, fmt.Errorf("enum value must be a string, schema: %+v", schema)
}
}
result.Enum = enums
}
case openapi3.TypeNumber:
result.Type = genai.TypeNumber
case openapi3.TypeInteger:
result.Type = genai.TypeInteger
case openapi3.TypeBoolean:
result.Type = genai.TypeBoolean
default:
result.Type = genai.TypeUnspecified
}
return result, nil
}
func (cm *ChatModel) convertSchemaMessages(messages []*schema.Message) ([]*genai.Content, error) {
var contents []*genai.Content
for _, message := range messages {
content, err := cm.convertSchemaMessage(message)
if err != nil {
return nil, fmt.Errorf("convert schema message failed: %w", err)
}
if content != nil {
contents = append(contents, content)
}
}
return contents, nil
}
func (cm *ChatModel) convertSchemaMessage(message *schema.Message) (*genai.Content, error) {
if message == nil {
return nil, nil
}
var parts []*genai.Part
// Handle tool calls
if message.ToolCalls != nil {
for _, call := range message.ToolCalls {
var args map[string]any
if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {
return nil, fmt.Errorf("unmarshal tool call arguments failed: %w", err)
}
parts = append(parts, &genai.Part{
FunctionCall: &genai.FunctionCall{
Name: call.Function.Name,
Args: args,
},
})
}
}
// Handle tool responses
if message.Role == schema.Tool {
var response map[string]any
if err := json.Unmarshal([]byte(message.Content), &response); err != nil {
// If the content is not valid JSON, treat it as a plain text error response
response = map[string]any{
"error": message.Content,
}
}
parts = append(parts, &genai.Part{
FunctionResponse: &genai.FunctionResponse{
Name: message.ToolCallID,
Response: response,
},
})
} else {
// Handle text content
if message.Content != "" {
parts = append(parts, &genai.Part{Text: message.Content})
}
// Handle multi-content (images, audio, etc.)
for _, content := range message.MultiContent {
switch content.Type {
case schema.ChatMessagePartTypeText:
parts = append(parts, &genai.Part{Text: content.Text})
case schema.ChatMessagePartTypeImageURL:
if content.ImageURL != nil {
parts = append(parts, &genai.Part{
FileData: &genai.FileData{
MIMEType: content.ImageURL.MIMEType,
FileURI: content.ImageURL.URI,
},
})
}
case schema.ChatMessagePartTypeAudioURL:
if content.AudioURL != nil {
parts = append(parts, &genai.Part{
FileData: &genai.FileData{
MIMEType: content.AudioURL.MIMEType,
FileURI: content.AudioURL.URI,
},
})
}
case schema.ChatMessagePartTypeVideoURL:
if content.VideoURL != nil {
parts = append(parts, &genai.Part{
FileData: &genai.FileData{
MIMEType: content.VideoURL.MIMEType,
FileURI: content.VideoURL.URI,
},
})
}
case schema.ChatMessagePartTypeFileURL:
if content.FileURL != nil {
parts = append(parts, &genai.Part{
FileData: &genai.FileData{
MIMEType: content.FileURL.MIMEType,
FileURI: content.FileURL.URI,
},
})
}
}
}
}
if len(parts) == 0 {
return nil, nil
}
return &genai.Content{
Role: string(cm.convertRole(message.Role)),
Parts: parts,
}, nil
}
func (cm *ChatModel) convertRole(role schema.RoleType) genai.Role {
switch role {
case schema.Assistant:
return genai.RoleModel
case schema.User:
return genai.RoleUser
case schema.Tool:
return genai.RoleUser // Tool responses are treated as user messages in the new API
default:
return genai.RoleUser
}
}
func (cm *ChatModel) convertResponse(resp *genai.GenerateContentResponse) (*schema.Message, error) {
if len(resp.Candidates) == 0 {
return nil, fmt.Errorf("gemini result is empty")
}
candidate := resp.Candidates[0]
message := &schema.Message{
Role: schema.Assistant,
ResponseMeta: &schema.ResponseMeta{
FinishReason: string(candidate.FinishReason),
},
}
// Handle usage metadata
if resp.UsageMetadata != nil {
message.ResponseMeta.Usage = &schema.TokenUsage{
PromptTokens: int(resp.UsageMetadata.PromptTokenCount),
CompletionTokens: int(resp.UsageMetadata.CandidatesTokenCount),
TotalTokens: int(resp.UsageMetadata.TotalTokenCount),
}
}
// Process content parts
var textParts []string
for _, part := range candidate.Content.Parts {
switch {
case part.Text != "":
textParts = append(textParts, part.Text)
case part.FunctionCall != nil:
args, err := json.Marshal(part.FunctionCall.Args)
if err != nil {
return nil, fmt.Errorf("marshal function call arguments failed: %w", err)
}
message.ToolCalls = append(message.ToolCalls, schema.ToolCall{
ID: part.FunctionCall.Name,
Function: schema.FunctionCall{
Name: part.FunctionCall.Name,
Arguments: string(args),
},
})
case part.ExecutableCode != nil:
textParts = append(textParts, part.ExecutableCode.Code)
case part.CodeExecutionResult != nil:
textParts = append(textParts, part.CodeExecutionResult.Output)
}
}
// Set content
if len(textParts) == 1 {
message.Content = textParts[0]
} else if len(textParts) > 1 {
for _, text := range textParts {
message.MultiContent = append(message.MultiContent, schema.ChatMessagePart{
Type: schema.ChatMessagePartTypeText,
Text: text,
})
}
}
return message, nil
}
func (cm *ChatModel) convertCallbackOutput(message *schema.Message, conf *model.Config) *model.CallbackOutput {
callbackOutput := &model.CallbackOutput{
Message: message,
Config: conf,
}
if message.ResponseMeta != nil && message.ResponseMeta.Usage != nil {
callbackOutput.TokenUsage = &model.TokenUsage{
PromptTokens: message.ResponseMeta.Usage.PromptTokens,
CompletionTokens: message.ResponseMeta.Usage.CompletionTokens,
TotalTokens: message.ResponseMeta.Usage.TotalTokens,
}
}
return callbackOutput
}
// IsCallbacksEnabled indicates whether this model supports callbacks.
// For the Gemini model, callbacks are always enabled to support
// token usage tracking and other monitoring features.
//
// Returns:
// - bool: Always returns true for Gemini models
func (cm *ChatModel) IsCallbacksEnabled() bool {
return true
}
const typ = "Gemini"
// GetType returns the type identifier for this model.
// This is used for logging and debugging purposes to identify
// which model implementation is being used.
//
// Returns:
// - string: Returns "Gemini" as the model type
func (cm *ChatModel) GetType() string {
return typ
}
type panicErr struct {
info any
stack []byte
}
func (p *panicErr) Error() string {
return fmt.Sprintf("panic error: %v, \nstack: %s", p.info, string(p.stack))
}
func newPanicErr(info any, stack []byte) error {
return &panicErr{
info: info,
stack: stack,
}
}
-226
View File
@@ -1,226 +0,0 @@
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"text/template"
"time"
)
// ModelInfo represents information about a specific model.
// This struct is used during code generation to parse model data
// from the models.dev API and generate the static Go code.
type ModelInfo struct {
// ID is the unique identifier for the model
ID string `json:"id"`
// Name is the human-readable name of the model
Name string `json:"name"`
// Attachment indicates whether the model supports file attachments
Attachment bool `json:"attachment"`
// Reasoning indicates whether this is a reasoning/chain-of-thought model
Reasoning bool `json:"reasoning"`
// Temperature indicates whether the model supports temperature parameter
Temperature bool `json:"temperature"`
// Cost contains the pricing information for the model
Cost Cost `json:"cost"`
// Limit contains the context and output token limits
Limit Limit `json:"limit"`
}
// Cost represents the pricing information for a model.
// Used during code generation to parse pricing data from models.dev.
type Cost struct {
// Input is the cost per million input tokens
Input float64 `json:"input"`
// Output is the cost per million output tokens
Output float64 `json:"output"`
// CacheRead is the cost per million cached read tokens (optional)
CacheRead *float64 `json:"cache_read,omitempty"`
// CacheWrite is the cost per million cached write tokens (optional)
CacheWrite *float64 `json:"cache_write,omitempty"`
}
// Limit represents the context and output limits for a model.
// Used during code generation to parse token limit data from models.dev.
type Limit struct {
// Context is the maximum number of input tokens
Context int `json:"context"`
// Output is the maximum number of output tokens
Output int `json:"output"`
}
// ProviderInfo represents information about a model provider.
// Used during code generation to parse provider data from models.dev
// and generate the static provider registry.
type ProviderInfo struct {
// ID is the unique identifier for the provider
ID string `json:"id"`
// Env lists the environment variables for API credentials
Env []string `json:"env"`
// NPM is the NPM package name (for reference)
NPM string `json:"npm"`
// Name is the human-readable provider name
Name string `json:"name"`
// Models maps model IDs to their information
Models map[string]ModelInfo `json:"models"`
}
const codeTemplate = `// Code generated by go generate; DO NOT EDIT.
// Generated at: {{.Timestamp}}
package models
// ModelInfo represents information about a specific model
type ModelInfo struct {
ID string
Name string
Attachment bool
Reasoning bool
Temperature bool
Cost Cost
Limit Limit
}
// Cost represents the pricing information for a model
type Cost struct {
Input float64
Output float64
CacheRead *float64
CacheWrite *float64
}
// Limit represents the context and output limits for a model
type Limit struct {
Context int
Output int
}
// ProviderInfo represents information about a model provider
type ProviderInfo struct {
ID string
Env []string
NPM string
Name string
Models map[string]ModelInfo
}
// GetModelsData returns the static models data from models.dev
func GetModelsData() map[string]ProviderInfo {
return map[string]ProviderInfo{
{{- range $providerID, $provider := .Providers}}
"{{$providerID}}": {
ID: "{{$provider.ID}}",
Env: []string{ {{- range $i, $env := $provider.Env}}{{if $i}}, {{end}}"{{$env}}"{{end}} },
NPM: "{{$provider.NPM}}",
Name: "{{$provider.Name}}",
Models: map[string]ModelInfo{
{{- range $modelID, $model := $provider.Models}}
"{{$modelID}}": {
ID: "{{$model.ID}}",
Name: "{{$model.Name}}",
Attachment: {{$model.Attachment}},
Reasoning: {{$model.Reasoning}},
Temperature: {{$model.Temperature}},
Cost: Cost{
Input: {{$model.Cost.Input}},
Output: {{$model.Cost.Output}},
{{- if $model.Cost.CacheRead}}
CacheRead: &[]float64{{"{"}}{{$model.Cost.CacheRead}}{{"}"}}[0],
{{- else}}
CacheRead: nil,
{{- end}}
{{- if $model.Cost.CacheWrite}}
CacheWrite: &[]float64{{"{"}}{{$model.Cost.CacheWrite}}{{"}"}}[0],
{{- else}}
CacheWrite: nil,
{{- end}}
},
Limit: Limit{
Context: {{$model.Limit.Context}},
Output: {{$model.Limit.Output}},
},
},
{{- end}}
},
},
{{- end}}
}
}
`
func main() {
fmt.Println("Fetching models data from models.dev...")
// Fetch data from API
resp, err := http.Get("https://models.dev/api.json")
if err != nil {
fmt.Fprintf(os.Stderr, "Error fetching data: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "API returned status %d\n", resp.StatusCode)
os.Exit(1)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading response: %v\n", err)
os.Exit(1)
}
// Parse JSON
var providers map[string]ProviderInfo
if err := json.Unmarshal(body, &providers); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err)
os.Exit(1)
}
// Fix Google provider environment variables to match our implementation
if googleProvider, exists := providers["google"]; exists {
googleProvider.Env = []string{"GOOGLE_API_KEY", "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"}
providers["google"] = googleProvider
}
// Generate Go code
tmpl, err := template.New("models").Parse(codeTemplate)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err)
os.Exit(1)
}
// Create output file
file, err := os.Create("models_data.go")
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err)
os.Exit(1)
}
defer file.Close()
// Execute template
data := struct {
Providers map[string]ProviderInfo
Timestamp string
}{
Providers: providers,
Timestamp: time.Now().Format(time.RFC3339),
}
if err := tmpl.Execute(file, data); err != nil {
fmt.Fprintf(os.Stderr, "Error executing template: %v\n", err)
os.Exit(1)
}
fmt.Printf("Generated models_data.go with %d providers\n", len(providers))
// Print summary
for providerID, provider := range providers {
fmt.Printf(" %s: %d models\n", providerID, len(provider.Models))
}
}
File diff suppressed because it is too large Load Diff
-249
View File
@@ -1,249 +0,0 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// CustomChatModel wraps the eino-ext OpenAI model with custom tool schema handling.
// It provides a compatibility layer that ensures proper JSON schema formatting
// for OpenAI's function calling feature. This wrapper addresses cases where
// tool schemas might have missing or empty properties that would cause API errors.
type CustomChatModel struct {
// wrapped is the underlying eino-ext OpenAI model instance
wrapped *einoopenai.ChatModel
}
// CustomRoundTripper intercepts HTTP requests to fix OpenAI function schemas.
// It acts as middleware that modifies outgoing requests to ensure that
// function/tool schemas are properly formatted according to OpenAI's requirements.
// This is particularly important for handling edge cases where tool schemas
// might have missing or empty properties fields.
type CustomRoundTripper struct {
// wrapped is the underlying HTTP transport to use for actual requests
wrapped http.RoundTripper
}
// NewCustomChatModel creates a new custom OpenAI chat model.
// It wraps the standard eino-ext OpenAI model with additional request
// preprocessing to ensure compatibility with OpenAI's API requirements,
// particularly for function calling and tool schemas.
//
// Parameters:
// - ctx: Context for the operation
// - config: Configuration for the OpenAI model including API key, model name, and parameters
//
// Returns:
// - *CustomChatModel: A wrapped OpenAI model with enhanced compatibility
// - error: Returns an error if model creation fails
//
// The custom model automatically:
// - Ensures function parameter schemas have properties fields
// - Fixes missing or empty properties in tool schemas
// - Maintains compatibility with OpenAI's function calling requirements
func NewCustomChatModel(ctx context.Context, config *einoopenai.ChatModelConfig) (*CustomChatModel, error) {
// Create a custom HTTP client that intercepts requests
if config.HTTPClient == nil {
config.HTTPClient = &http.Client{}
}
// Wrap the transport to intercept requests
if config.HTTPClient.Transport == nil {
config.HTTPClient.Transport = http.DefaultTransport
}
config.HTTPClient.Transport = &CustomRoundTripper{
wrapped: config.HTTPClient.Transport,
}
wrapped, err := einoopenai.NewChatModel(ctx, config)
if err != nil {
return nil, err
}
return &CustomChatModel{
wrapped: wrapped,
}, nil
}
// RoundTrip implements http.RoundTripper to intercept and fix OpenAI requests.
// It preprocesses outgoing requests to the OpenAI API to ensure tool/function
// schemas meet the API's requirements.
//
// Parameters:
// - req: The HTTP request to be sent to the OpenAI API
//
// Returns:
// - *http.Response: The response from the OpenAI API
// - error: Any error that occurred during the request
//
// The method performs the following fixes:
// - Ensures function parameter schemas of type "object" have a properties field
// - Adds empty properties object if missing to prevent API validation errors
func (c *CustomRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Only intercept OpenAI chat completions requests
if !strings.Contains(req.URL.Path, "/chat/completions") {
return c.wrapped.RoundTrip(req)
}
// Read the request body
if req.Body == nil {
return c.wrapped.RoundTrip(req)
}
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return c.wrapped.RoundTrip(req)
}
req.Body.Close()
// Parse the JSON request
var requestData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &requestData); err != nil {
// If we can't parse it, just pass it through
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return c.wrapped.RoundTrip(req)
}
// Fix function schemas if present
if tools, ok := requestData["tools"].([]interface{}); ok {
for _, tool := range tools {
if toolMap, ok := tool.(map[string]interface{}); ok {
if function, ok := toolMap["function"].(map[string]interface{}); ok {
if parameters, ok := function["parameters"].(map[string]interface{}); ok {
if typeVal, ok := parameters["type"].(string); ok && typeVal == "object" {
// Check if properties is missing or empty
if properties, exists := parameters["properties"]; !exists || properties == nil {
parameters["properties"] = map[string]interface{}{}
} else if propMap, ok := properties.(map[string]interface{}); ok && len(propMap) == 0 {
parameters["properties"] = map[string]interface{}{}
}
}
}
}
}
}
}
// Marshal the fixed request back to JSON
fixedBodyBytes, err := json.Marshal(requestData)
if err != nil {
// If we can't marshal it, use the original
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
return c.wrapped.RoundTrip(req)
}
// Create new request body with fixed data
req.Body = io.NopCloser(bytes.NewReader(fixedBodyBytes))
req.ContentLength = int64(len(fixedBodyBytes))
return c.wrapped.RoundTrip(req)
}
// Generate implements model.ChatModel interface.
// It generates a single response from the OpenAI model based on the input messages.
//
// Parameters:
// - ctx: Context for the operation, supporting cancellation and deadlines
// - in: The conversation history as a slice of messages
// - opts: Optional configuration options for the generation
//
// Returns:
// - *schema.Message: The generated response message
// - error: Any error that occurred during generation
func (c *CustomChatModel) Generate(ctx context.Context, in []*schema.Message, opts ...model.Option) (*schema.Message, error) {
return c.wrapped.Generate(ctx, in, opts...)
}
// Stream implements model.ChatModel interface.
// It generates a streaming response from the OpenAI model, allowing
// incremental processing of the model's output as it's generated.
//
// Parameters:
// - ctx: Context for the operation, supporting cancellation and deadlines
// - in: The conversation history as a slice of messages
// - opts: Optional configuration options for the generation
//
// Returns:
// - *schema.StreamReader[*schema.Message]: A reader for the streaming response
// - error: Any error that occurred during stream setup
func (c *CustomChatModel) Stream(ctx context.Context, in []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return c.wrapped.Stream(ctx, in, opts...)
}
// WithTools implements model.ToolCallingChatModel interface.
// It creates a new model instance with the specified tools available for function calling.
// The original model instance remains unchanged.
//
// Parameters:
// - tools: A slice of tool definitions that the model can use
//
// Returns:
// - model.ToolCallingChatModel: A new model instance with tools enabled
// - error: Returns an error if tool binding fails
func (c *CustomChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
wrappedWithTools, err := c.wrapped.WithTools(tools)
if err != nil {
return nil, err
}
// Type assert back to *einoopenai.ChatModel
wrappedChatModel, ok := wrappedWithTools.(*einoopenai.ChatModel)
if !ok {
return nil, fmt.Errorf("unexpected type returned from WithTools")
}
return &CustomChatModel{wrapped: wrappedChatModel}, nil
}
// BindTools implements model.ToolCallingChatModel interface.
// It binds tools to the current model instance, modifying it in place
// rather than creating a new instance.
//
// Parameters:
// - tools: A slice of tool definitions to bind to the model
//
// Returns:
// - error: Returns an error if tool binding fails
func (c *CustomChatModel) BindTools(tools []*schema.ToolInfo) error {
return c.wrapped.BindTools(tools)
}
// BindForcedTools implements model.ToolCallingChatModel interface.
// It binds tools to the current model instance in forced mode,
// ensuring the model will always use one of the provided tools.
//
// Parameters:
// - tools: A slice of tool definitions to bind to the model
//
// Returns:
// - error: Returns an error if tool binding fails
func (c *CustomChatModel) BindForcedTools(tools []*schema.ToolInfo) error {
return c.wrapped.BindForcedTools(tools)
}
// GetType implements model.ChatModel interface.
// It returns the type identifier for this model implementation.
//
// Returns:
// - string: Returns "CustomOpenAI" as the model type identifier
func (c *CustomChatModel) GetType() string {
return "CustomOpenAI"
}
// IsCallbacksEnabled implements model.ChatModel interface.
// It indicates whether this model supports callbacks for monitoring
// and tracking purposes.
//
// Returns:
// - bool: Returns the callback enabled status from the wrapped model
func (c *CustomChatModel) IsCallbacksEnabled() bool {
return c.wrapped.IsCallbacksEnabled()
}
File diff suppressed because it is too large Load Diff
+140 -80
View File
@@ -1,46 +1,164 @@
//go:generate go run generate_models.go
package models
import (
"fmt"
"os"
"strings"
"charm.land/catwalk/pkg/embedded"
)
// ModelInfo represents information about a specific model.
type ModelInfo struct {
ID string
Name string
Attachment bool
Reasoning bool
Temperature bool
Cost Cost
Limit Limit
}
// Cost represents the pricing information for a model.
type Cost struct {
Input float64
Output float64
CacheRead *float64
CacheWrite *float64
}
// Limit represents the context and output limits for a model.
type Limit struct {
Context int
Output int
}
// ProviderInfo represents information about a model provider.
type ProviderInfo struct {
ID string
Env []string
Name string
Models map[string]ModelInfo
}
// providerEnvVars maps provider IDs to their required environment variables.
// Catwalk provides APIKey field names but we need the actual env var names.
var providerEnvVars = map[string][]string{
"anthropic": {"ANTHROPIC_API_KEY"},
"openai": {"OPENAI_API_KEY"},
"google": {"GOOGLE_API_KEY", "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"},
"azure": {"AZURE_OPENAI_API_KEY"},
"openrouter": {"OPENROUTER_API_KEY"},
"bedrock": {"AWS_ACCESS_KEY_ID"},
"google-vertex-anthropic": {"GOOGLE_APPLICATION_CREDENTIALS"},
"ollama": {},
"mistral": {"MISTRAL_API_KEY"},
"groq": {"GROQ_API_KEY"},
"deepseek": {"DEEPSEEK_API_KEY"},
"xai": {"XAI_API_KEY"},
"fireworks": {"FIREWORKS_API_KEY"},
"together": {"TOGETHER_API_KEY"},
"perplexity": {"PERPLEXITY_API_KEY"},
"alibaba": {"DASHSCOPE_API_KEY"},
"cohere": {"COHERE_API_KEY"},
}
// ModelsRegistry provides validation and information about models.
// It maintains a registry of all supported LLM providers and their models,
// including capabilities, pricing, and configuration requirements.
// The registry data is generated from models.dev and provides a single
// source of truth for model validation and discovery.
// The registry data comes from the catwalk embedded database.
type ModelsRegistry struct {
// providers maps provider IDs to their information and available models
providers map[string]ProviderInfo
}
// NewModelsRegistry creates a new models registry with static data.
// The registry is populated with model information generated from models.dev,
// providing comprehensive metadata about available models across all supported providers.
//
// Returns:
// - *ModelsRegistry: A new registry instance populated with current model data
// NewModelsRegistry creates a new models registry populated from the catwalk embedded database.
func NewModelsRegistry() *ModelsRegistry {
return &ModelsRegistry{
providers: GetModelsData(),
providers: buildFromCatwalk(),
}
}
// buildFromCatwalk converts catwalk embedded data into our internal format.
func buildFromCatwalk() map[string]ProviderInfo {
providers := make(map[string]ProviderInfo)
for _, cp := range embedded.GetAll() {
providerID := string(cp.ID)
modelsMap := make(map[string]ModelInfo, len(cp.Models))
for _, cm := range cp.Models {
var cacheRead, cacheWrite *float64
if cm.CostPer1MInCached > 0 {
v := cm.CostPer1MInCached
cacheRead = &v
}
if cm.CostPer1MOutCached > 0 {
v := cm.CostPer1MOutCached
cacheWrite = &v
}
hasTemperature := true // most models support temperature
if cm.Options.Temperature != nil && *cm.Options.Temperature == 0 {
hasTemperature = false
}
modelsMap[cm.ID] = ModelInfo{
ID: cm.ID,
Name: cm.Name,
Attachment: cm.SupportsImages,
Reasoning: cm.CanReason,
Temperature: hasTemperature,
Cost: Cost{
Input: cm.CostPer1MIn,
Output: cm.CostPer1MOut,
CacheRead: cacheRead,
CacheWrite: cacheWrite,
},
Limit: Limit{
Context: int(cm.ContextWindow),
Output: int(cm.DefaultMaxTokens),
},
}
}
envVars := providerEnvVars[providerID]
if envVars == nil {
// Derive from the catwalk APIKey field if available
if cp.APIKey != "" {
envVars = []string{cp.APIKey}
}
}
providers[providerID] = ProviderInfo{
ID: providerID,
Env: envVars,
Name: cp.Name,
Models: modelsMap,
}
}
// Ensure providers that mcphost explicitly supports are always present
// even if catwalk doesn't list them (e.g. ollama, google-vertex-anthropic)
ensureProvider(providers, "ollama", "Ollama", nil)
ensureProvider(providers, "google-vertex-anthropic", "Google Vertex (Anthropic)",
providerEnvVars["google-vertex-anthropic"])
return providers
}
// ensureProvider ensures a provider entry exists in the map.
func ensureProvider(providers map[string]ProviderInfo, id, name string, env []string) {
if _, exists := providers[id]; !exists {
providers[id] = ProviderInfo{
ID: id,
Env: env,
Name: name,
Models: make(map[string]ModelInfo),
}
}
}
// ValidateModel validates if a model exists and returns detailed information.
// It checks whether a specific model is available for a given provider and
// returns comprehensive information about the model's capabilities and limits.
//
// Parameters:
// - provider: The provider ID (e.g., "anthropic", "openai", "google")
// - modelID: The specific model ID (e.g., "claude-3-sonnet-20240620", "gpt-4")
//
// Returns:
// - *ModelInfo: Detailed information about the model including pricing, limits, and capabilities
// - error: Returns an error if the provider is unsupported or model is not found
func (r *ModelsRegistry) ValidateModel(provider, modelID string) (*ModelInfo, error) {
providerInfo, exists := r.providers[provider]
if !exists {
@@ -56,20 +174,6 @@ func (r *ModelsRegistry) ValidateModel(provider, modelID string) (*ModelInfo, er
}
// GetRequiredEnvVars returns the required environment variables for a provider.
// These are the environment variable names that should contain API keys or
// other authentication credentials for the specified provider.
//
// Parameters:
// - provider: The provider ID (e.g., "anthropic", "openai", "google")
//
// Returns:
// - []string: List of environment variable names the provider checks for credentials
// - error: Returns an error if the provider is unsupported
//
// Example:
//
// For "anthropic", returns ["ANTHROPIC_API_KEY"]
// For "google", returns ["GOOGLE_API_KEY", "GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"]
func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
providerInfo, exists := r.providers[provider]
if !exists {
@@ -80,28 +184,17 @@ func (r *ModelsRegistry) GetRequiredEnvVars(provider string) ([]string, error) {
}
// ValidateEnvironment checks if required environment variables are set.
// It verifies that at least one of the provider's required environment variables
// contains an API key, unless an API key is explicitly provided via configuration.
//
// Parameters:
// - provider: The provider ID to validate environment for
// - apiKey: An API key provided via configuration (if empty, checks environment variables)
//
// Returns:
// - error: Returns nil if validation passes, or an error describing missing credentials
func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) error {
envVars, err := r.GetRequiredEnvVars(provider)
if err != nil {
return err
}
// If API key is provided via config, we don't need to check env vars
if apiKey != "" {
return nil
}
// Add alternative environment variable names for google-vertex-anthropic
// These match the env vars checked by eino-claude and other tools
if provider == "google-vertex-anthropic" {
envVars = append(envVars,
"ANTHROPIC_VERTEX_PROJECT_ID",
@@ -113,7 +206,6 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
)
}
// Check if at least one environment variable is set
var foundVar bool
for _, envVar := range envVars {
if os.Getenv(envVar) != "" {
@@ -131,15 +223,6 @@ func (r *ModelsRegistry) ValidateEnvironment(provider string, apiKey string) err
}
// SuggestModels returns similar model names when an invalid model is provided.
// It helps users discover the correct model ID by finding models that partially
// match the provided input, useful for correcting typos or finding alternatives.
//
// Parameters:
// - provider: The provider ID to search within
// - invalidModel: The invalid or misspelled model name to find suggestions for
//
// Returns:
// - []string: A list of up to 5 suggested model IDs that partially match the input
func (r *ModelsRegistry) SuggestModels(provider, invalidModel string) []string {
providerInfo, exists := r.providers[provider]
if !exists {
@@ -149,12 +232,10 @@ func (r *ModelsRegistry) SuggestModels(provider, invalidModel string) []string {
var suggestions []string
invalidLower := strings.ToLower(invalidModel)
// Look for models that contain parts of the invalid model name
for modelID, modelInfo := range providerInfo.Models {
modelIDLower := strings.ToLower(modelID)
modelNameLower := strings.ToLower(modelInfo.Name)
// Check if the invalid model is a substring of existing models
if strings.Contains(modelIDLower, invalidLower) ||
strings.Contains(modelNameLower, invalidLower) ||
strings.Contains(invalidLower, strings.ToLower(strings.Split(modelID, "-")[0])) {
@@ -162,7 +243,6 @@ func (r *ModelsRegistry) SuggestModels(provider, invalidModel string) []string {
}
}
// Limit suggestions to avoid overwhelming output
if len(suggestions) > 5 {
suggestions = suggestions[:5]
}
@@ -171,11 +251,6 @@ func (r *ModelsRegistry) SuggestModels(provider, invalidModel string) []string {
}
// GetSupportedProviders returns a list of all supported providers.
// This includes all providers that have models registered in the system,
// such as "anthropic", "openai", "google", "alibaba", etc.
//
// Returns:
// - []string: A list of all provider IDs available in the registry
func (r *ModelsRegistry) GetSupportedProviders() []string {
var providers []string
for providerID := range r.providers {
@@ -185,15 +260,6 @@ func (r *ModelsRegistry) GetSupportedProviders() []string {
}
// GetModelsForProvider returns all models for a specific provider.
// This is useful for listing available models when a user wants to see
// all options for a particular provider.
//
// Parameters:
// - provider: The provider ID to get models for
//
// Returns:
// - map[string]ModelInfo: A map of model IDs to their detailed information
// - error: Returns an error if the provider is unsupported
func (r *ModelsRegistry) GetModelsForProvider(provider string) (map[string]ModelInfo, error) {
providerInfo, exists := r.providers[provider]
if !exists {
@@ -207,12 +273,6 @@ func (r *ModelsRegistry) GetModelsForProvider(provider string) (map[string]Model
var globalRegistry = NewModelsRegistry()
// GetGlobalRegistry returns the global models registry instance.
// This provides a singleton registry that can be accessed throughout
// the application for model validation and information retrieval.
// The registry is initialized once with data from models.dev.
//
// Returns:
// - *ModelsRegistry: The global registry instance
func GetGlobalRegistry() *ModelsRegistry {
return globalRegistry
}
+13 -57
View File
@@ -4,14 +4,12 @@ import (
"fmt"
"sync"
"github.com/cloudwego/eino/schema"
"charm.land/fantasy"
)
// Manager manages session state and auto-saving functionality.
// It provides thread-safe operations for managing a conversation session,
// including automatic persistence to disk after each modification.
// The Manager ensures that all session operations are synchronized and
// that the session file is kept up-to-date with any changes.
type Manager struct {
session *Session
filePath string
@@ -19,9 +17,6 @@ type Manager struct {
}
// NewManager creates a new session manager with a fresh session.
// The filePath parameter specifies where the session will be auto-saved.
// If filePath is empty, the session will not be automatically saved to disk.
// Returns a Manager instance ready to track conversation messages.
func NewManager(filePath string) *Manager {
return &Manager{
session: NewSession(),
@@ -30,10 +25,6 @@ func NewManager(filePath string) *Manager {
}
// NewManagerWithSession creates a new session manager with an existing session.
// This is useful when loading a session from a file and wanting to continue
// managing it with auto-save functionality.
// The session parameter is the existing session to manage.
// The filePath parameter specifies where the session will be auto-saved.
func NewManagerWithSession(session *Session, filePath string) *Manager {
return &Manager{
session: session,
@@ -41,17 +32,12 @@ func NewManagerWithSession(session *Session, filePath string) *Manager {
}
}
// AddMessage adds a message to the session and auto-saves.
// The message is converted from schema.Message format to the internal
// session Message format before being added. If a filePath was specified
// when creating the Manager, the session is automatically saved to disk.
// This operation is thread-safe.
// Returns an error if auto-saving fails, nil otherwise.
func (m *Manager) AddMessage(msg *schema.Message) error {
// AddMessage adds a fantasy message to the session and auto-saves.
func (m *Manager) AddMessage(msg fantasy.Message) error {
m.mutex.Lock()
defer m.mutex.Unlock()
sessionMsg := ConvertFromSchemaMessage(msg)
sessionMsg := ConvertFromFantasyMessage(msg)
m.session.AddMessage(sessionMsg)
if m.filePath != "" {
@@ -61,17 +47,13 @@ func (m *Manager) AddMessage(msg *schema.Message) error {
return nil
}
// AddMessages adds multiple messages to the session and auto-saves.
// All messages are added in order and then the session is saved once.
// This is more efficient than calling AddMessage multiple times when
// adding several messages at once. The operation is thread-safe.
// Returns an error if auto-saving fails, nil otherwise.
func (m *Manager) AddMessages(msgs []*schema.Message) error {
// AddMessages adds multiple fantasy messages to the session and auto-saves.
func (m *Manager) AddMessages(msgs []fantasy.Message) error {
m.mutex.Lock()
defer m.mutex.Unlock()
for _, msg := range msgs {
sessionMsg := ConvertFromSchemaMessage(msg)
sessionMsg := ConvertFromFantasyMessage(msg)
m.session.AddMessage(sessionMsg)
}
@@ -83,12 +65,7 @@ func (m *Manager) AddMessages(msgs []*schema.Message) error {
}
// ReplaceAllMessages replaces all messages in the session with the provided messages.
// This method completely clears the existing message history and replaces it with
// the new set of messages. Useful for resetting a conversation or loading a
// different conversation context. The operation is thread-safe and triggers
// an auto-save if a filePath is configured.
// Returns an error if auto-saving fails, nil otherwise.
func (m *Manager) ReplaceAllMessages(msgs []*schema.Message) error {
func (m *Manager) ReplaceAllMessages(msgs []fantasy.Message) error {
m.mutex.Lock()
defer m.mutex.Unlock()
@@ -97,7 +74,7 @@ func (m *Manager) ReplaceAllMessages(msgs []*schema.Message) error {
// Add all new messages
for _, msg := range msgs {
sessionMsg := ConvertFromSchemaMessage(msg)
sessionMsg := ConvertFromFantasyMessage(msg)
m.session.AddMessage(sessionMsg)
}
@@ -109,10 +86,6 @@ func (m *Manager) ReplaceAllMessages(msgs []*schema.Message) error {
}
// SetMetadata sets the session metadata.
// This updates the session's metadata with information about the provider,
// model, and MCPHost version. The operation is thread-safe and triggers
// an auto-save if a filePath is configured.
// Returns an error if auto-saving fails, nil otherwise.
func (m *Manager) SetMetadata(metadata Metadata) error {
m.mutex.Lock()
defer m.mutex.Unlock()
@@ -126,33 +99,24 @@ func (m *Manager) SetMetadata(metadata Metadata) error {
return nil
}
// GetMessages returns all messages as a schema.Message slice.
// This method converts all stored session messages to the schema format
// used by LLM providers. The returned slice is a new allocation, so
// modifications to it won't affect the stored session. This operation
// is thread-safe for concurrent reads.
func (m *Manager) GetMessages() []*schema.Message {
// GetMessages returns all messages as fantasy.Message slice.
func (m *Manager) GetMessages() []fantasy.Message {
m.mutex.RLock()
defer m.mutex.RUnlock()
messages := make([]*schema.Message, len(m.session.Messages))
messages := make([]fantasy.Message, len(m.session.Messages))
for i, msg := range m.session.Messages {
messages[i] = msg.ConvertToSchemaMessage()
messages[i] = msg.ConvertToFantasyMessage()
}
return messages
}
// GetSession returns a copy of the current session.
// The returned session is a deep copy, including all messages, so
// modifications to it won't affect the managed session. This is useful
// for safely inspecting the session state without risk of concurrent
// modification. This operation is thread-safe for concurrent reads.
func (m *Manager) GetSession() *Session {
m.mutex.RLock()
defer m.mutex.RUnlock()
// Return a copy to prevent external modification
sessionCopy := *m.session
sessionCopy.Messages = make([]Message, len(m.session.Messages))
copy(sessionCopy.Messages, m.session.Messages)
@@ -161,10 +125,6 @@ func (m *Manager) GetSession() *Session {
}
// Save manually saves the session to file.
// This forces a save operation even if no changes have been made.
// Useful for ensuring the session is persisted at specific points.
// Returns an error if no filePath was specified when creating the
// Manager, or if the save operation fails.
func (m *Manager) Save() error {
m.mutex.RLock()
defer m.mutex.RUnlock()
@@ -177,15 +137,11 @@ func (m *Manager) Save() error {
}
// GetFilePath returns the file path for this session.
// Returns the path where the session is being auto-saved, or an
// empty string if no auto-save path was configured.
func (m *Manager) GetFilePath() string {
return m.filePath
}
// MessageCount returns the number of messages in the session.
// This provides a quick way to check the conversation length without
// retrieving all messages. This operation is thread-safe for concurrent reads.
func (m *Manager) MessageCount() int {
m.mutex.RLock()
defer m.mutex.RUnlock()
+71 -77
View File
@@ -8,7 +8,7 @@ import (
"os"
"time"
"github.com/cloudwego/eino/schema"
"charm.land/fantasy"
)
// Session represents a complete conversation session with metadata.
@@ -30,9 +30,7 @@ type Session struct {
}
// Metadata contains session metadata that provides context about the
// environment and configuration used during the conversation. This helps
// with debugging and understanding the session's context when reviewing
// conversation history.
// environment and configuration used during the conversation.
type Metadata struct {
// MCPHostVersion is the version of MCPHost used for this session
MCPHostVersion string `json:"mcphost_version"`
@@ -61,8 +59,6 @@ type Message struct {
}
// ToolCall represents a tool invocation within an assistant message.
// When the assistant decides to use a tool, it creates a ToolCall with
// the necessary information to execute that tool.
type ToolCall struct {
// ID is a unique identifier for this tool call, used to link results
ID string `json:"id"`
@@ -73,9 +69,6 @@ type ToolCall struct {
}
// NewSession creates a new session with default values.
// It initializes a session with version 1.0, current timestamps,
// empty message list, and empty metadata. The returned session
// is ready to receive messages and can be saved to a file.
func NewSession() *Session {
return &Session{
Version: "1.0",
@@ -87,9 +80,6 @@ func NewSession() *Session {
}
// AddMessage adds a message to the session.
// If the message doesn't have an ID, one will be auto-generated.
// If the message doesn't have a timestamp, the current time will be used.
// The session's UpdatedAt timestamp is automatically updated.
func (s *Session) AddMessage(msg Message) {
if msg.ID == "" {
msg.ID = generateMessageID()
@@ -103,20 +93,12 @@ func (s *Session) AddMessage(msg Message) {
}
// SetMetadata sets the session metadata.
// This replaces the existing metadata with the provided metadata
// and updates the session's UpdatedAt timestamp. Use this to record
// information about the provider, model, and MCPHost version.
func (s *Session) SetMetadata(metadata Metadata) {
s.Metadata = metadata
s.UpdatedAt = time.Now()
}
// SaveToFile saves the session to a JSON file.
// The session is serialized as indented JSON for readability.
// The UpdatedAt timestamp is automatically updated before saving.
// The file is created with 0644 permissions if it doesn't exist,
// or overwritten if it does exist.
// Returns an error if marshaling fails or file writing fails.
func (s *Session) SaveToFile(filePath string) error {
s.UpdatedAt = time.Now()
@@ -129,11 +111,6 @@ func (s *Session) SaveToFile(filePath string) error {
}
// LoadFromFile loads a session from a JSON file.
// It reads the file at the specified path and deserializes it into
// a Session struct. This is useful for resuming previous conversations
// or reviewing session history.
// Returns the loaded session on success, or an error if the file
// cannot be read or the JSON is invalid.
func LoadFromFile(filePath string) (*Session, error) {
data, err := os.ReadFile(filePath)
if err != nil {
@@ -148,85 +125,102 @@ func LoadFromFile(filePath string) (*Session, error) {
return &session, nil
}
// ConvertFromSchemaMessage converts a schema.Message to a session Message.
// This function bridges between the eino schema message format and the
// session's internal message format. It preserves role, content, and
// tool-related information while adding a timestamp.
// Tool calls from assistant messages and tool call IDs from tool messages
// are properly converted and preserved.
func ConvertFromSchemaMessage(msg *schema.Message) Message {
// ConvertFromFantasyMessage converts a fantasy.Message to a session Message.
// This function bridges between the fantasy message format and the
// session's internal message format for JSON persistence.
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
sessionMsg := Message{
Role: string(msg.Role),
Content: msg.Content,
Timestamp: time.Now(),
}
// Convert tool calls if present (for assistant messages)
if len(msg.ToolCalls) > 0 {
sessionMsg.ToolCalls = make([]ToolCall, len(msg.ToolCalls))
for i, tc := range msg.ToolCalls {
sessionMsg.ToolCalls[i] = ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
// Extract text content and tool calls from message parts
var textParts []string
for _, part := range msg.Content {
switch p := part.(type) {
case fantasy.TextPart:
textParts = append(textParts, p.Text)
case fantasy.ToolCallPart:
sessionMsg.ToolCalls = append(sessionMsg.ToolCalls, ToolCall{
ID: p.ToolCallID,
Name: p.ToolName,
Arguments: p.Input,
})
case fantasy.ToolResultPart:
// Tool result messages — store the tool call ID
sessionMsg.ToolCallID = p.ToolCallID
// Marshal result for storage
if p.Output != nil {
if resultBytes, err := json.Marshal(p.Output); err == nil {
textParts = append(textParts, string(resultBytes))
}
}
}
}
// Handle tool result messages - extract tool call ID from ToolCallID field
if msg.Role == schema.Tool && msg.ToolCallID != "" {
sessionMsg.ToolCallID = msg.ToolCallID
// Join all text parts
for i, t := range textParts {
if i > 0 {
sessionMsg.Content += "\n"
}
sessionMsg.Content += t
}
return sessionMsg
}
// ConvertToSchemaMessage converts a session Message to a schema.Message.
// ConvertToFantasyMessage converts a session Message to a fantasy.Message.
// This method bridges between the session's internal message format and
// the eino schema message format used by the LLM providers.
// It properly handles tool calls for assistant messages and tool call IDs
// for tool result messages. Arguments are converted to string format as
// required by the schema.
func (m *Message) ConvertToSchemaMessage() *schema.Message {
msg := &schema.Message{
Role: schema.RoleType(m.Role),
Content: m.Content,
// the fantasy message format used by the LLM providers.
func (m *Message) ConvertToFantasyMessage() fantasy.Message {
msg := fantasy.Message{
Role: fantasy.MessageRole(m.Role),
}
// Convert tool calls if present (for assistant messages)
if len(m.ToolCalls) > 0 {
msg.ToolCalls = make([]schema.ToolCall, len(m.ToolCalls))
for i, tc := range m.ToolCalls {
// Arguments are already stored as a string, use them directly
var argsStr string
// Build content parts based on role
switch m.Role {
case "assistant":
// Add text content if present
if m.Content != "" {
msg.Content = append(msg.Content, fantasy.TextPart{Text: m.Content})
}
// Add tool calls if present
for _, tc := range m.ToolCalls {
var inputStr string
if str, ok := tc.Arguments.(string); ok {
argsStr = str
} else {
// Fallback: marshal to JSON if not a string
if argBytes, err := json.Marshal(tc.Arguments); err == nil {
argsStr = string(argBytes)
}
inputStr = str
} else if argBytes, err := json.Marshal(tc.Arguments); err == nil {
inputStr = string(argBytes)
}
msg.ToolCalls[i] = schema.ToolCall{
ID: tc.ID,
Function: schema.FunctionCall{
Name: tc.Name,
Arguments: argsStr,
},
}
}
msg.Content = append(msg.Content, fantasy.ToolCallPart{
ToolCallID: tc.ID,
ToolName: tc.Name,
Input: inputStr,
})
}
case "tool":
// Tool result message
msg.Role = fantasy.MessageRoleTool
var resultContent fantasy.ToolResultOutputContent
resultContent = fantasy.ToolResultOutputContentText{Text: m.Content}
// Handle tool result messages - set the tool call ID
if m.Role == "tool" && m.ToolCallID != "" {
msg.ToolCallID = m.ToolCallID
msg.Content = append(msg.Content, fantasy.ToolResultPart{
ToolCallID: m.ToolCallID,
Output: resultContent,
})
case "user":
msg.Content = append(msg.Content, fantasy.TextPart{Text: m.Content})
case "system":
msg.Content = append(msg.Content, fantasy.TextPart{Text: m.Content})
default:
msg.Content = append(msg.Content, fantasy.TextPart{Text: m.Content})
}
return msg
}
// generateMessageID generates a unique message ID
// generateMessageID generates a unique message ID.
func generateMessageID() string {
bytes := make([]byte, 8)
rand.Read(bytes)
+9 -10
View File
@@ -3,11 +3,12 @@ package tools
import (
"context"
"fmt"
"maps"
"strings"
"sync"
"time"
"github.com/cloudwego/eino/components/model"
"charm.land/fantasy"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
@@ -63,7 +64,7 @@ type MCPConnectionPool struct {
connections map[string]*MCPConnection
config *ConnectionPoolConfig
mu sync.RWMutex
model model.ToolCallingChatModel
model fantasy.LanguageModel
ctx context.Context
cancel context.CancelFunc
debug bool
@@ -75,7 +76,7 @@ type MCPConnectionPool struct {
// goroutine for periodic health checks that runs until Close is called.
// The model parameter is used for MCP servers that require sampling support.
// Thread-safe for concurrent use immediately after creation.
func NewMCPConnectionPool(config *ConnectionPoolConfig, model model.ToolCallingChatModel, debug bool) *MCPConnectionPool {
func NewMCPConnectionPool(config *ConnectionPoolConfig, model fantasy.LanguageModel, debug bool) *MCPConnectionPool {
if config == nil {
config = DefaultConnectionPoolConfig()
}
@@ -406,7 +407,7 @@ func (p *MCPConnectionPool) initializeClient(ctx context.Context, client client.
}
if p.debugLogger != nil && p.debugLogger.IsDebugEnabled() {
p.debugLogger.LogDebug(fmt.Sprintf("[POOL] Initialized MCP client"))
p.debugLogger.LogDebug("[POOL] Initialized MCP client")
}
return nil
}
@@ -430,9 +431,7 @@ func (p *MCPConnectionPool) startHealthCheck() {
func (p *MCPConnectionPool) checkConnectionsHealth() {
p.mu.RLock()
connections := make(map[string]*MCPConnection)
for k, v := range p.connections {
connections[k] = v
}
maps.Copy(connections, p.connections)
p.mu.RUnlock()
for serverName, conn := range connections {
@@ -494,14 +493,14 @@ func (p *MCPConnectionPool) HandleConnectionError(serverName string, err error)
// The returned map includes health status, last usage time, error counts, and
// last error for each connection. Useful for monitoring and debugging connection
// pool behavior. The returned data is a snapshot and safe for concurrent access.
func (p *MCPConnectionPool) GetConnectionStats() map[string]interface{} {
func (p *MCPConnectionPool) GetConnectionStats() map[string]any {
p.mu.RLock()
defer p.mu.RUnlock()
stats := make(map[string]interface{})
stats := make(map[string]any)
for serverName, conn := range p.connections {
conn.mu.RLock()
stats[serverName] = map[string]interface{}{
stats[serverName] = map[string]any{
"is_healthy": conn.isHealthy,
"last_used": conn.lastUsed,
"last_error": conn.lastError,
+92
View File
@@ -0,0 +1,92 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"charm.land/fantasy"
"github.com/mark3labs/mcp-go/mcp"
)
// mcpFantasyTool adapts an MCP tool to the fantasy.AgentTool interface.
// It bridges the MCP tool protocol with fantasy's agent tool system, handling
// name prefixing, schema conversion, connection pooling, and result marshaling.
type mcpFantasyTool struct {
toolInfo fantasy.ToolInfo
mapping *toolMapping
providerOptions fantasy.ProviderOptions
}
// Info returns the fantasy tool info including name, description, and parameter schema.
func (t *mcpFantasyTool) Info() fantasy.ToolInfo {
return t.toolInfo
}
// Run executes the MCP tool by routing through the connection pool.
// It maps the prefixed tool name back to the original name, retrieves a healthy
// connection, invokes the tool, and converts the MCP result to a fantasy ToolResponse.
func (t *mcpFantasyTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
// Parse and validate JSON arguments
var arguments any
input := call.Input
if input == "" || input == "{}" {
arguments = nil
} else {
var temp any
if err := json.Unmarshal([]byte(input), &temp); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid JSON arguments: %v", err)), nil
}
arguments = json.RawMessage(input)
}
// Get connection from pool with health check
conn, err := t.mapping.manager.connectionPool.GetConnectionWithHealthCheck(
ctx, t.mapping.serverName, t.mapping.serverConfig,
)
if err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("failed to get healthy connection from pool: %w", err)
}
// Call the MCP tool using the original (unprefixed) name
result, err := conn.client.CallTool(ctx, mcp.CallToolRequest{
Request: mcp.Request{
Method: "tools/call",
},
Params: struct {
Name string `json:"name"`
Arguments any `json:"arguments,omitempty"`
Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Name: t.mapping.originalName,
Arguments: arguments,
},
})
if err != nil {
// Mark connection as unhealthy for automatic recovery
t.mapping.manager.connectionPool.HandleConnectionError(t.mapping.serverName, err)
return fantasy.ToolResponse{}, fmt.Errorf("failed to call mcp tool: %w", err)
}
// Marshal the MCP result to JSON string
marshaledResult, err := json.Marshal(result)
if err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("failed to marshal mcp tool result: %w", err)
}
// Return as text response, preserving error status from MCP
if result.IsError {
return fantasy.NewTextErrorResponse(string(marshaledResult)), nil
}
return fantasy.NewTextResponse(string(marshaledResult)), nil
}
// ProviderOptions returns provider-specific options for this tool.
func (t *mcpFantasyTool) ProviderOptions() fantasy.ProviderOptions {
return t.providerOptions
}
// SetProviderOptions sets provider-specific options for this tool.
func (t *mcpFantasyTool) SetProviderOptions(opts fantasy.ProviderOptions) {
t.providerOptions = opts
}
+72 -168
View File
@@ -4,14 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"github.com/eino-contrib/jsonschema"
"charm.land/fantasy"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
@@ -26,9 +23,9 @@ import (
// Thread-safe for concurrent tool invocations.
type MCPToolManager struct {
connectionPool *MCPConnectionPool
tools []tool.BaseTool
tools []fantasy.AgentTool
toolMap map[string]*toolMapping // maps prefixed tool names to their server and original name
model model.ToolCallingChatModel // LLM model for sampling
model fantasy.LanguageModel // LLM model for sampling
config *config.Config
debug bool
debugLogger DebugLogger
@@ -42,18 +39,12 @@ type toolMapping struct {
manager *MCPToolManager
}
// mcpToolImpl implements the eino tool interface with server prefixing
type mcpToolImpl struct {
info *schema.ToolInfo
mapping *toolMapping
}
// NewMCPToolManager creates a new MCP tool manager instance.
// Returns an initialized manager with empty tool collections ready to load tools from MCP servers.
// The manager must be configured with SetModel and LoadTools before use.
func NewMCPToolManager() *MCPToolManager {
return &MCPToolManager{
tools: make([]tool.BaseTool, 0),
tools: make([]fantasy.AgentTool, 0),
toolMap: make(map[string]*toolMapping),
}
}
@@ -62,7 +53,7 @@ func NewMCPToolManager() *MCPToolManager {
// The model is used when MCP servers request sampling operations, allowing them to
// leverage the host's LLM capabilities for text generation tasks.
// This method should be called before LoadTools if any MCP servers require sampling support.
func (m *MCPToolManager) SetModel(model model.ToolCallingChatModel) {
func (m *MCPToolManager) SetModel(model fantasy.LanguageModel) {
m.model = model
}
@@ -77,13 +68,13 @@ func (m *MCPToolManager) SetDebugLogger(logger DebugLogger) {
}
}
// samplingHandler implements the MCP sampling handler interface
// samplingHandler implements the MCP sampling handler interface using a fantasy LanguageModel
type samplingHandler struct {
model model.ToolCallingChatModel
model fantasy.LanguageModel
}
// CreateMessage handles sampling requests from MCP servers by forwarding them to the configured LLM model.
// It converts MCP message formats to eino message formats, invokes the model for generation,
// It converts MCP message formats to fantasy message formats, invokes the model for generation,
// and converts the response back to MCP format. Returns an error if no model is available
// or if generation fails.
func (h *samplingHandler) CreateMessage(ctx context.Context, request mcp.CreateMessageRequest) (*mcp.CreateMessageResult, error) {
@@ -91,17 +82,16 @@ func (h *samplingHandler) CreateMessage(ctx context.Context, request mcp.CreateM
return nil, fmt.Errorf("no model available for sampling")
}
// Convert MCP messages to eino messages
var messages []*schema.Message
// Build fantasy messages from MCP sampling request
var messages []fantasy.Message
// Add system message if provided
if request.SystemPrompt != "" {
messages = append(messages, schema.SystemMessage(request.SystemPrompt))
messages = append(messages, fantasy.NewSystemMessage(request.SystemPrompt))
}
// Convert sampling messages
for _, msg := range request.Messages {
// Extract text content
var content string
if textContent, ok := msg.Content.(mcp.TextContent); ok {
content = textContent.Text
@@ -111,30 +101,36 @@ func (h *samplingHandler) CreateMessage(ctx context.Context, request mcp.CreateM
switch msg.Role {
case mcp.RoleUser:
messages = append(messages, schema.UserMessage(content))
messages = append(messages, fantasy.NewUserMessage(content))
case mcp.RoleAssistant:
messages = append(messages, schema.AssistantMessage(content, nil))
messages = append(messages, fantasy.Message{
Role: fantasy.MessageRoleAssistant,
Content: []fantasy.MessagePart{fantasy.TextPart{Text: content}},
})
default:
messages = append(messages, schema.UserMessage(content)) // Default to user
messages = append(messages, fantasy.NewUserMessage(content))
}
}
// Generate response using the model (no config options for now)
response, err := h.model.Generate(ctx, messages)
// Generate response using the fantasy model
call := fantasy.Call{
Prompt: fantasy.Prompt(messages),
}
response, err := h.model.Generate(ctx, call)
if err != nil {
return nil, fmt.Errorf("model generation failed: %w", err)
}
// Convert response back to MCP format
result := &mcp.CreateMessageResult{
Model: "mcphost-model", // Generic model name
Model: h.model.Model(),
StopReason: "endTurn",
}
result.SamplingMessage = mcp.SamplingMessage{
Role: mcp.RoleAssistant,
Content: mcp.TextContent{
Type: "text",
Text: response.Content,
Text: response.Content.Text(),
},
}
@@ -202,7 +198,7 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
}
}
// Convert MCP tools to eino tools with prefixed names
// Convert MCP tools to fantasy AgentTools with prefixed names
for _, mcpTool := range listResults.Tools {
// Filter tools based on allowedTools/excludedTools
if len(serverConfig.AllowedTools) > 0 {
@@ -216,29 +212,40 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
continue
}
// Convert schema
marshaledInputSchema, err := sonic.Marshal(mcpTool.InputSchema)
// Convert MCP InputSchema to map[string]any for fantasy ToolInfo
marshaledSchema, err := json.Marshal(mcpTool.InputSchema)
if err != nil {
return fmt.Errorf("conv mcp tool input schema fail(marshal): %w, tool name: %s", err, mcpTool.Name)
}
// Fix for JSON Schema draft-07 vs draft-04 compatibility:
// Chrome DevTools MCP uses draft-07 where exclusiveMinimum/exclusiveMaximum are numbers,
// but kin-openapi (OpenAPI 3.0) expects them as booleans (draft-04 format).
// Pre-process the schema to convert numeric exclusive bounds to boolean format.
marshaledInputSchema = convertExclusiveBoundsToBoolean(marshaledInputSchema)
// Fix for JSON Schema draft-07 vs draft-04 compatibility
marshaledSchema = convertExclusiveBoundsToBoolean(marshaledSchema)
inputSchema := &jsonschema.Schema{}
err = sonic.Unmarshal(marshaledInputSchema, inputSchema)
if err != nil {
// Parse into map[string]any for fantasy's parameters format
var schemaMap map[string]any
if err := json.Unmarshal(marshaledSchema, &schemaMap); err != nil {
return fmt.Errorf("conv mcp tool input schema fail(unmarshal): %w, tool name: %s", err, mcpTool.Name)
}
// Extract properties and required from the schema
parameters := make(map[string]any)
var required []string
if props, ok := schemaMap["properties"].(map[string]any); ok {
parameters = props
}
// Fix for issue #89: Ensure object schemas have a properties field
// OpenAI function calling requires object schemas to have a "properties" field
// even if it's empty, otherwise it throws "object schema missing properties" error
if inputSchema.Type == "object" && inputSchema.Properties == nil {
inputSchema.Properties = jsonschema.NewProperties()
if schemaType, ok := schemaMap["type"].(string); ok && schemaType == "object" && len(parameters) == 0 {
// Keep empty parameters map - fantasy handles this fine
}
if req, ok := schemaMap["required"].([]any); ok {
for _, r := range req {
if s, ok := r.(string); ok {
required = append(required, s)
}
}
}
// Create prefixed tool name
@@ -253,89 +260,26 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
}
m.toolMap[prefixedName] = mapping
// Create eino tool
einoTool := &mcpToolImpl{
info: &schema.ToolInfo{
// Create fantasy AgentTool
fantasyTool := &mcpFantasyTool{
toolInfo: fantasy.ToolInfo{
Name: prefixedName,
Desc: mcpTool.Description,
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(inputSchema),
Description: mcpTool.Description,
Parameters: parameters,
Required: required,
},
mapping: mapping,
}
m.tools = append(m.tools, einoTool)
m.tools = append(m.tools, fantasyTool)
}
return nil
}
// Info returns the tool information including name, description, and parameter schema.
// This method implements the eino tool.BaseTool interface.
// The returned ToolInfo contains the prefixed tool name to ensure uniqueness across servers.
func (t *mcpToolImpl) Info(ctx context.Context) (*schema.ToolInfo, error) {
return t.info, nil
}
// InvokableRun executes the tool by mapping the prefixed name back to the original tool name and server.
// It retrieves a healthy connection from the pool, invokes the tool on the appropriate MCP server,
// and returns the result as a JSON string. The method handles connection errors by marking
// connections as unhealthy in the pool for automatic recovery on subsequent requests.
// Thread-safe for concurrent invocations.
func (t *mcpToolImpl) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
// Handle empty or invalid JSON arguments
var arguments any
if argumentsInJSON == "" || argumentsInJSON == "{}" {
arguments = nil
} else {
// Validate that argumentsInJSON is valid JSON before using it
var temp any
if err := json.Unmarshal([]byte(argumentsInJSON), &temp); err != nil {
return "", fmt.Errorf("invalid JSON arguments: %w", err)
}
arguments = json.RawMessage(argumentsInJSON)
}
// Get connection from pool for this server with health check
conn, err := t.mapping.manager.connectionPool.GetConnectionWithHealthCheck(ctx, t.mapping.serverName, t.mapping.serverConfig)
if err != nil {
return "", fmt.Errorf("failed to get healthy connection from pool: %w", err)
}
result, err := conn.client.CallTool(ctx, mcp.CallToolRequest{
Request: mcp.Request{
Method: "tools/call",
},
Params: struct {
Name string `json:"name"`
Arguments any `json:"arguments,omitempty"`
Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Name: t.mapping.originalName, // Use original name, not prefixed
Arguments: arguments,
},
})
if err != nil {
// Handle connection error in pool
t.mapping.manager.connectionPool.HandleConnectionError(t.mapping.serverName, err)
return "", fmt.Errorf("failed to call mcp tool: %w", err)
}
marshaledResult, err := sonic.MarshalString(result)
if err != nil {
return "", fmt.Errorf("failed to marshal mcp tool result: %w", err)
}
// If the MCP server returned an error, we still return the error content as the response
// to the LLM so it can see what went wrong. The error will be shown to the user via
// the UI callbacks, but the LLM needs to see the actual error details to continue
// the conversation appropriately.
return marshaledResult, nil
}
// GetTools returns all loaded tools from all configured MCP servers.
// GetTools returns all loaded tools as fantasy AgentTools from all configured MCP servers.
// Tools are returned with their prefixed names (serverName__toolName) to ensure uniqueness.
// The returned slice is a copy and can be safely modified by the caller.
func (m *MCPToolManager) GetTools() []tool.BaseTool {
func (m *MCPToolManager) GetTools() []fantasy.AgentTool {
return m.tools
}
@@ -360,15 +304,11 @@ func (m *MCPToolManager) Close() error {
// shouldExcludeTool determines if a tool should be excluded based on excludedTools
func (m *MCPToolManager) shouldExcludeTool(toolName string, serverConfig config.MCPServerConfig) bool {
// If excludedTools is specified, exclude tools in the list
if len(serverConfig.ExcludedTools) > 0 {
for _, excludedTool := range serverConfig.ExcludedTools {
if excludedTool == toolName {
if slices.Contains(serverConfig.ExcludedTools, toolName) {
return true
}
}
}
return false
}
@@ -377,60 +317,44 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string,
switch transportType {
case "stdio":
// STDIO client
var env []string
var command string
var args []string
// Handle command and environment
if len(serverConfig.Command) > 0 {
command = serverConfig.Command[0]
if len(serverConfig.Command) > 1 {
args = serverConfig.Command[1:]
} else if len(serverConfig.Args) > 0 {
// Legacy fallback: Command only has the command, Args has the arguments
// This handles cases where legacy config conversion didn't work properly
args = serverConfig.Args
}
}
// Convert environment variables
if serverConfig.Environment != nil {
for k, v := range serverConfig.Environment {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
}
// Legacy environment support
if serverConfig.Env != nil {
for k, v := range serverConfig.Env {
env = append(env, fmt.Sprintf("%s=%v", k, v))
}
}
// Create stdio transport
stdioTransport := transport.NewStdio(command, env, args...)
stdioClient := client.NewClient(stdioTransport)
// Start the transport
if err := stdioTransport.Start(ctx); err != nil {
return nil, fmt.Errorf("failed to start stdio transport: %v", err)
}
// Add a brief delay to allow the process to start and potentially fail
time.Sleep(100 * time.Millisecond)
// TODO: Add process health check here if the mcp-go library exposes process info
// For now, we rely on the timeout in initializeClient to catch dead processes
return stdioClient, nil
case "sse":
// SSE client
var options []transport.ClientOption
// Add headers if specified
if len(serverConfig.Headers) > 0 {
headers := make(map[string]string)
for _, header := range serverConfig.Headers {
@@ -451,7 +375,6 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string,
return nil, err
}
// Start the SSE client
if err := sseClient.Start(ctx); err != nil {
return nil, fmt.Errorf("failed to start SSE client: %v", err)
}
@@ -459,10 +382,8 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string,
return sseClient, nil
case "streamable":
// Streamable HTTP client
var options []transport.StreamableHTTPCOption
// Add headers if specified
if len(serverConfig.Headers) > 0 {
headers := make(map[string]string)
for _, header := range serverConfig.Headers {
@@ -483,7 +404,6 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string,
return nil, err
}
// Start the streamable HTTP client
if err := streamableClient.Start(ctx); err != nil {
return nil, fmt.Errorf("failed to start streamable HTTP client: %v", err)
}
@@ -491,7 +411,6 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string,
return streamableClient, nil
case "inprocess":
// Builtin server
return m.createBuiltinClient(ctx, serverName, serverConfig)
default:
@@ -500,7 +419,6 @@ func (m *MCPToolManager) createMCPClient(ctx context.Context, serverName string,
}
func (m *MCPToolManager) initializeClient(ctx context.Context, client client.MCPClient) error {
// Create a timeout context for initialization to prevent deadlocks
initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
@@ -523,13 +441,11 @@ func (m *MCPToolManager) initializeClient(ctx context.Context, client client.MCP
func (m *MCPToolManager) createBuiltinClient(ctx context.Context, serverName string, serverConfig config.MCPServerConfig) (client.MCPClient, error) {
registry := builtin.NewRegistry()
// Create the builtin server, passing the model for servers that need it
builtinServer, err := registry.CreateServer(serverConfig.Name, serverConfig.Options, m.model)
if err != nil {
return nil, fmt.Errorf("failed to create builtin server: %v", err)
}
// Create an in-process client that wraps the builtin server
inProcessClient, err := client.NewInProcessClient(builtinServer.GetServer())
if err != nil {
return nil, fmt.Errorf("failed to create in-process client: %v", err)
@@ -566,77 +482,65 @@ func (m *MCPToolManager) debugLogConnectionInfo(serverName string, serverConfig
// convertExclusiveBoundsToBoolean converts JSON Schema draft-07 style exclusive bounds
// (where exclusiveMinimum/exclusiveMaximum are numbers) to draft-04 style
// (where they are booleans that modify minimum/maximum).
// This enables compatibility with kin-openapi which uses OpenAPI 3.0 (draft-04 based) schemas.
func convertExclusiveBoundsToBoolean(schemaJSON []byte) []byte {
var data map[string]interface{}
var data map[string]any
if err := json.Unmarshal(schemaJSON, &data); err != nil {
return schemaJSON // Return unchanged on error
return schemaJSON
}
convertSchemaRecursive(data)
result, err := json.Marshal(data)
if err != nil {
return schemaJSON // Return unchanged on error
return schemaJSON
}
return result
}
// convertSchemaRecursive recursively processes a schema map and converts
// numeric exclusiveMinimum/exclusiveMaximum to boolean format.
func convertSchemaRecursive(schema map[string]interface{}) {
// Convert exclusiveMinimum if it's a number
func convertSchemaRecursive(schema map[string]any) {
if exMin, ok := schema["exclusiveMinimum"]; ok {
if num, isNum := exMin.(float64); isNum {
// JSON Schema draft-07: exclusiveMinimum is the limit value
// Convert to draft-04: set minimum = value, exclusiveMinimum = true
schema["minimum"] = num
schema["exclusiveMinimum"] = true
}
}
// Convert exclusiveMaximum if it's a number
if exMax, ok := schema["exclusiveMaximum"]; ok {
if num, isNum := exMax.(float64); isNum {
// JSON Schema draft-07: exclusiveMaximum is the limit value
// Convert to draft-04: set maximum = value, exclusiveMaximum = true
schema["maximum"] = num
schema["exclusiveMaximum"] = true
}
}
// Recursively process properties
if props, ok := schema["properties"].(map[string]interface{}); ok {
if props, ok := schema["properties"].(map[string]any); ok {
for _, prop := range props {
if propSchema, ok := prop.(map[string]interface{}); ok {
if propSchema, ok := prop.(map[string]any); ok {
convertSchemaRecursive(propSchema)
}
}
}
// Recursively process items (for arrays)
if items, ok := schema["items"].(map[string]interface{}); ok {
if items, ok := schema["items"].(map[string]any); ok {
convertSchemaRecursive(items)
}
// Recursively process additionalProperties
if addProps, ok := schema["additionalProperties"].(map[string]interface{}); ok {
if addProps, ok := schema["additionalProperties"].(map[string]any); ok {
convertSchemaRecursive(addProps)
}
// Recursively process allOf, anyOf, oneOf
for _, key := range []string{"allOf", "anyOf", "oneOf"} {
if arr, ok := schema[key].([]interface{}); ok {
if arr, ok := schema[key].([]any); ok {
for _, item := range arr {
if itemSchema, ok := item.(map[string]interface{}); ok {
if itemSchema, ok := item.(map[string]any); ok {
convertSchemaRecursive(itemSchema)
}
}
}
}
// Recursively process not
if not, ok := schema["not"].(map[string]interface{}); ok {
if not, ok := schema["not"].(map[string]any); ok {
convertSchemaRecursive(not)
}
}
+61 -65
View File
@@ -6,8 +6,6 @@ import (
"testing"
"time"
"github.com/cloudwego/eino/schema"
"github.com/eino-contrib/jsonschema"
"github.com/mark3labs/mcphost/internal/config"
)
@@ -84,7 +82,6 @@ func TestMCPToolManager_ToolWithoutProperties(t *testing.T) {
manager := NewMCPToolManager()
// Create a config with a builtin todo server (which has tools with properties)
// and test the schema conversion logic
cfg := &config.Config{
MCPServers: map[string]config.MCPServerConfig{
"todo-server": {
@@ -111,69 +108,68 @@ func TestMCPToolManager_ToolWithoutProperties(t *testing.T) {
// Test that we can get tool info for each tool
for _, tool := range tools {
info, err := tool.Info(ctx)
if err != nil {
t.Errorf("Failed to get tool info: %v", err)
continue
info := tool.Info()
// Check that the tool has a valid name
if info.Name == "" {
t.Error("Tool has empty name")
}
// Check that the tool has a valid schema
if info.ParamsOneOf == nil {
t.Errorf("Tool %s has nil ParamsOneOf", info.Name)
}
t.Logf("Tool: %s, Description: %s", info.Name, info.Desc)
t.Logf("Tool: %s, Description: %s", info.Name, info.Description)
}
}
// TestIssue89_ObjectSchemaMissingProperties tests the fix for issue #89
// This is a regression test for the "object schema missing properties" error
// that occurs when tools have no input parameters and use OpenAI function calling
// This verifies that object schemas with nil properties get an empty properties map
func TestIssue89_ObjectSchemaMissingProperties(t *testing.T) {
// Create a schema that would cause the OpenAI validation error
// This simulates what might happen with tools that have no input properties
brokenSchema := &jsonschema.Schema{
Type: "object",
// Properties is nil - this causes "object schema missing properties" error in OpenAI
// Create a schema that would cause issues with tools that have no input properties
brokenSchema := map[string]any{
"type": "object",
// Properties is nil - this used to cause "object schema missing properties" error
}
// Verify the problematic state
if brokenSchema.Type == "object" && brokenSchema.Properties == nil {
t.Log("Found object schema with nil properties - this causes OpenAI validation error")
if brokenSchema["type"] == "object" && brokenSchema["properties"] == nil {
t.Log("Found object schema with nil properties - this previously caused validation errors")
}
// Apply the fix from issue #89
if brokenSchema.Type == "object" && brokenSchema.Properties == nil {
brokenSchema.Properties = jsonschema.NewProperties()
// Apply the fix - add empty properties
if brokenSchema["type"] == "object" && brokenSchema["properties"] == nil {
brokenSchema["properties"] = map[string]any{}
}
// Verify the fix worked
if brokenSchema.Type == "object" && brokenSchema.Properties == nil {
if brokenSchema["properties"] == nil {
t.Error("Fix failed: object schema still has nil properties")
}
// Test that we can create a ParamsOneOf from the fixed schema
// This is what would fail before the fix
paramsOneOf := schema.NewParamsOneOfByJSONSchema(brokenSchema)
if paramsOneOf == nil {
t.Error("Failed to create ParamsOneOf from fixed schema - OpenAI function calling would fail")
// Verify it marshals cleanly
data, err := json.Marshal(brokenSchema)
if err != nil {
t.Errorf("Failed to marshal fixed schema: %v", err)
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
t.Errorf("Failed to unmarshal fixed schema: %v", err)
}
if result["type"] != "object" {
t.Error("Schema type should be 'object'")
}
}
// TestConvertExclusiveBoundsToBoolean tests the JSON Schema draft-07 to draft-04 conversion
// for exclusiveMinimum and exclusiveMaximum fields.
// Draft-07: exclusiveMinimum/exclusiveMaximum are numeric values (the actual bounds)
// Draft-04: exclusiveMinimum/exclusiveMaximum are booleans that modify minimum/maximum
func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
tests := []struct {
name string
input string
expected map[string]interface{}
expected map[string]any
}{
{
name: "exclusiveMinimum as number",
input: `{"type": "number", "exclusiveMinimum": 0}`,
expected: map[string]interface{}{
expected: map[string]any{
"type": "number",
"minimum": float64(0),
"exclusiveMinimum": true,
@@ -182,7 +178,7 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
{
name: "exclusiveMaximum as number",
input: `{"type": "number", "exclusiveMaximum": 100}`,
expected: map[string]interface{}{
expected: map[string]any{
"type": "number",
"maximum": float64(100),
"exclusiveMaximum": true,
@@ -191,7 +187,7 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
{
name: "both exclusive bounds as numbers",
input: `{"type": "integer", "exclusiveMinimum": 1, "exclusiveMaximum": 10}`,
expected: map[string]interface{}{
expected: map[string]any{
"type": "integer",
"minimum": float64(1),
"exclusiveMinimum": true,
@@ -202,7 +198,7 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
{
name: "already boolean exclusiveMinimum (draft-04 style)",
input: `{"type": "number", "minimum": 0, "exclusiveMinimum": true}`,
expected: map[string]interface{}{
expected: map[string]any{
"type": "number",
"minimum": float64(0),
"exclusiveMinimum": true,
@@ -211,7 +207,7 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
{
name: "no exclusive bounds",
input: `{"type": "string", "minLength": 1}`,
expected: map[string]interface{}{
expected: map[string]any{
"type": "string",
"minLength": float64(1),
},
@@ -219,10 +215,10 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
{
name: "nested properties with exclusive bounds",
input: `{"type": "object", "properties": {"age": {"type": "integer", "exclusiveMinimum": 0}}}`,
expected: map[string]interface{}{
expected: map[string]any{
"type": "object",
"properties": map[string]interface{}{
"age": map[string]interface{}{
"properties": map[string]any{
"age": map[string]any{
"type": "integer",
"minimum": float64(0),
"exclusiveMinimum": true,
@@ -233,9 +229,9 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
{
name: "array items with exclusive bounds",
input: `{"type": "array", "items": {"type": "number", "exclusiveMaximum": 100}}`,
expected: map[string]interface{}{
expected: map[string]any{
"type": "array",
"items": map[string]interface{}{
"items": map[string]any{
"type": "number",
"maximum": float64(100),
"exclusiveMaximum": true,
@@ -245,9 +241,9 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
{
name: "allOf with exclusive bounds",
input: `{"allOf": [{"type": "number", "exclusiveMinimum": 0}]}`,
expected: map[string]interface{}{
"allOf": []interface{}{
map[string]interface{}{
expected: map[string]any{
"allOf": []any{
map[string]any{
"type": "number",
"minimum": float64(0),
"exclusiveMinimum": true,
@@ -258,9 +254,9 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
{
name: "additionalProperties with exclusive bounds",
input: `{"type": "object", "additionalProperties": {"type": "integer", "exclusiveMinimum": 0, "exclusiveMaximum": 255}}`,
expected: map[string]interface{}{
expected: map[string]any{
"type": "object",
"additionalProperties": map[string]interface{}{
"additionalProperties": map[string]any{
"type": "integer",
"minimum": float64(0),
"exclusiveMinimum": true,
@@ -272,15 +268,15 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
{
name: "Chrome DevTools MCP style schema (real-world example)",
input: `{"type": "object", "properties": {"timeout": {"type": "integer", "exclusiveMinimum": 0}, "quality": {"type": "number", "minimum": 0, "maximum": 100}}}`,
expected: map[string]interface{}{
expected: map[string]any{
"type": "object",
"properties": map[string]interface{}{
"timeout": map[string]interface{}{
"properties": map[string]any{
"timeout": map[string]any{
"type": "integer",
"minimum": float64(0),
"exclusiveMinimum": true,
},
"quality": map[string]interface{}{
"quality": map[string]any{
"type": "number",
"minimum": float64(0),
"maximum": float64(100),
@@ -294,7 +290,7 @@ func TestConvertExclusiveBoundsToBoolean(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
result := convertExclusiveBoundsToBoolean([]byte(tt.input))
var got map[string]interface{}
var got map[string]any
if err := json.Unmarshal(result, &got); err != nil {
t.Fatalf("Failed to unmarshal result: %v", err)
}
@@ -317,7 +313,7 @@ func TestConvertExclusiveBoundsToBoolean_InvalidJSON(t *testing.T) {
}
// deepEqual compares two maps recursively
func deepEqual(a, b map[string]interface{}) bool {
func deepEqual(a, b map[string]any) bool {
if len(a) != len(b) {
return false
}
@@ -327,13 +323,13 @@ func deepEqual(a, b map[string]interface{}) bool {
return false
}
switch av := v.(type) {
case map[string]interface{}:
bvm, ok := bv.(map[string]interface{})
case map[string]any:
bvm, ok := bv.(map[string]any)
if !ok || !deepEqual(av, bvm) {
return false
}
case []interface{}:
bva, ok := bv.([]interface{})
case []any:
bva, ok := bv.([]any)
if !ok || !sliceEqual(av, bva) {
return false
}
@@ -347,19 +343,19 @@ func deepEqual(a, b map[string]interface{}) bool {
}
// sliceEqual compares two slices recursively
func sliceEqual(a, b []interface{}) bool {
func sliceEqual(a, b []any) bool {
if len(a) != len(b) {
return false
}
for i := range a {
switch av := a[i].(type) {
case map[string]interface{}:
bvm, ok := b[i].(map[string]interface{})
case map[string]any:
bvm, ok := b[i].(map[string]any)
if !ok || !deepEqual(av, bvm) {
return false
}
case []interface{}:
bva, ok := b[i].([]interface{})
case []any:
bva, ok := b[i].([]any)
if !ok || !sliceEqual(av, bva) {
return false
}
-41
View File
@@ -1,41 +0,0 @@
package ui
import (
"context"
"github.com/cloudwego/eino/callbacks"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
utilCallbacks "github.com/cloudwego/eino/utils/callbacks"
)
// CreateCallbackHandler creates and returns a callbacks.Handler that manages
// tool execution callbacks for the CLI. The handler displays tool calls,
// handles errors, and manages streaming output for interactive tool operations.
// It integrates with the eino callback system to provide real-time UI feedback
// during tool execution.
func (c *CLI) CreateCallbackHandler() callbacks.Handler {
toolHandler := &utilCallbacks.ToolCallbackHandler{
OnStart: func(ctx context.Context, runInfo *callbacks.RunInfo, input *tool.CallbackInput) context.Context {
// Display the tool call message with the tool name and arguments
c.DisplayToolCallMessage(runInfo.Name, input.ArgumentsInJSON)
return ctx
},
OnEnd: func(ctx context.Context, runInfo *callbacks.RunInfo, output *tool.CallbackOutput) context.Context {
// Tool execution completed - we could show results here if needed
return ctx
},
OnEndWithStreamOutput: func(ctx context.Context, runInfo *callbacks.RunInfo, output *schema.StreamReader[*tool.CallbackOutput]) context.Context {
return ctx
},
OnError: func(ctx context.Context, runInfo *callbacks.RunInfo, err error) context.Context {
// Display error message
c.DisplayError(err)
return ctx
},
}
return utilCallbacks.NewHandlerHelper().
Tool(toolHandler).
Handler()
}
+9 -20
View File
@@ -8,8 +8,8 @@ import (
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"charm.land/lipgloss/v2"
"github.com/cloudwego/eino/schema"
"golang.org/x/term"
)
@@ -498,38 +498,27 @@ func (c *CLI) UpdateUsage(inputText, outputText string) {
}
}
// UpdateUsageFromResponse records token usage using metadata from the AI provider's
// UpdateUsageFromResponse records token usage using metadata from the fantasy
// response when available. Falls back to text-based estimation if the metadata is
// missing or appears unreliable. This provides more accurate usage tracking when
// providers supply token count information.
func (c *CLI) UpdateUsageFromResponse(response *schema.Message, inputText string) {
func (c *CLI) UpdateUsageFromResponse(response *fantasy.Response, inputText string) {
if c.usageTracker == nil {
return
}
// Try to extract token usage from response metadata
if response.ResponseMeta != nil && response.ResponseMeta.Usage != nil {
usage := response.ResponseMeta.Usage
// Use actual token counts from the response
inputTokens := int(usage.PromptTokens)
outputTokens := int(usage.CompletionTokens)
usage := response.Usage
inputTokens := int(usage.InputTokens)
outputTokens := int(usage.OutputTokens)
// Validate that the metadata seems reasonable
// If token counts are 0 or seem unrealistic, fall back to estimation
if inputTokens > 0 && outputTokens > 0 {
// Handle cache tokens if available (some providers support this)
cacheReadTokens := 0
cacheWriteTokens := 0
cacheReadTokens := int(usage.CacheReadTokens)
cacheWriteTokens := int(usage.CacheCreationTokens)
c.usageTracker.UpdateUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
} else {
// Metadata exists but seems incomplete/unreliable, use estimation
c.usageTracker.EstimateAndUpdateUsage(inputText, response.Content)
}
} else {
// Fallback to estimation if no metadata is available
c.usageTracker.EstimateAndUpdateUsage(inputText, response.Content)
c.usageTracker.EstimateAndUpdateUsage(inputText, response.Content.Text())
}
}
+3 -3
View File
@@ -1,5 +1,7 @@
package ui
import "slices"
// SlashCommand represents a user-invokable slash command with its metadata.
// Commands can have multiple aliases and are organized by category for better
// discoverability and help display.
@@ -68,12 +70,10 @@ func GetCommandByName(name string) *SlashCommand {
if cmd.Name == name {
return cmd
}
for _, alias := range cmd.Aliases {
if alias == name {
if slices.Contains(cmd.Aliases, name) {
return cmd
}
}
}
return nil
}
+11 -12
View File
@@ -296,10 +296,11 @@ func (r *CompactRenderer) formatCompactContent(content string) string {
content = strings.TrimSpace(content)
// Truncate if too long (unless in debug mode)
maxLen := r.width - 28 // Reserve space for symbol and label more conservatively
if maxLen < 40 {
maxLen = 40 // Minimum width for readability
}
maxLen := max(
// Reserve space for symbol and label more conservatively
r.width-28,
// Minimum width for readability
40)
if !r.debug && len(content) > maxLen {
content = content[:maxLen-3] + "..."
}
@@ -315,10 +316,9 @@ func (r *CompactRenderer) formatUserAssistantContent(content string) string {
// Calculate available width more conservatively
// Account for: symbol (1) + spaces (2) + label (up to 20 chars) + space (1) + margin (4)
availableWidth := r.width - 28
if availableWidth < 40 {
availableWidth = 40 // Minimum width for readability
}
availableWidth := max(r.width-28,
// Minimum width for readability
40)
// Use glamour to render markdown content with proper width
rendered := toMarkdown(content, availableWidth)
@@ -407,10 +407,9 @@ func (r *CompactRenderer) formatToolResult(result string) string {
}
// Calculate available width more conservatively
availableWidth := r.width - 28
if availableWidth < 40 {
availableWidth = 40 // Minimum width for readability
}
availableWidth := max(r.width-28,
// Minimum width for readability
40)
// First wrap the text to prevent long lines (tool results are usually plain text, not markdown)
wrappedResult := r.wrapText(result, availableWidth)
+4 -4
View File
@@ -36,12 +36,12 @@ func (l *CLIDebugLogger) LogDebug(message string) {
// Check if this is a multi-line debug output (like connection info)
if strings.Contains(message, "[DEBUG]") || strings.Contains(message, "[POOL]") {
// Extract the tag and content
if strings.HasPrefix(message, "[DEBUG]") {
content := strings.TrimPrefix(message, "[DEBUG]")
if after, ok := strings.CutPrefix(message, "[DEBUG]"); ok {
content := after
content = strings.TrimSpace(content)
formattedMessage = fmt.Sprintf("🔍 DEBUG: %s", content)
} else if strings.HasPrefix(message, "[POOL]") {
content := strings.TrimPrefix(message, "[POOL]")
} else if after, ok := strings.CutPrefix(message, "[POOL]"); ok {
content := after
content = strings.TrimSpace(content)
// Add appropriate emoji based on the message content
+3 -8
View File
@@ -80,10 +80,7 @@ func (m ProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.WindowSizeMsg:
newWidth := msg.Width - padding*2 - 4
if newWidth > maxWidth {
newWidth = maxWidth
}
newWidth := min(msg.Width-padding*2-4, maxWidth)
m.progress.SetWidth(newWidth)
return m, nil
@@ -167,14 +164,12 @@ func NewProgressReader(reader io.Reader) *ProgressReader {
}
// Start the TUI in a goroutine
pr.wg.Add(1)
go func() {
defer pr.wg.Done()
pr.wg.Go(func() {
if _, err := program.Run(); err != nil {
// Handle error silently for now
}
close(pr.done)
}()
})
return pr
}
+69 -121
View File
@@ -1,153 +1,101 @@
package ui
import (
"context"
"fmt"
"image/color"
"os"
"sync"
"time"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// Spinner provides an animated loading indicator that displays while long-running
// operations are in progress. It wraps the bubbles spinner component and manages
// its lifecycle through a tea.Program for proper terminal handling.
// spinnerFrames defines available spinner animation styles.
var (
pointsFrames = []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"}
pointsFPS = time.Second / 7
dotFrames = []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "}
dotFPS = time.Second / 10
)
// Spinner provides an animated loading indicator that displays while
// long-running operations are in progress. It writes directly to stderr
// using a goroutine-based animation loop, avoiding Bubble Tea's terminal
// capability queries that can leak escape sequences (mode 2026 DECRPM).
type Spinner struct {
model spinner.Model
done chan struct{}
prog *tea.Program
ctx context.Context
cancel context.CancelFunc
}
// spinnerModel is the tea.Model for the spinner
type spinnerModel struct {
spinner spinner.Model
message string
quitting bool
frames []string
fps time.Duration
color color.Color
done chan struct{}
once sync.Once
}
func (m spinnerModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
m.quitting = true
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case quitMsg:
m.quitting = true
return m, tea.Quit
default:
return m, nil
}
}
func (m spinnerModel) View() tea.View {
if m.quitting {
return tea.NewView("")
}
// Enhanced spinner display with better styling
baseStyle := lipgloss.NewStyle()
theme := GetTheme()
spinnerStyle := baseStyle.
Foreground(theme.Primary).
Bold(true)
messageStyle := baseStyle.
Foreground(theme.Text).
Italic(true)
return tea.NewView(fmt.Sprintf(" %s %s",
spinnerStyle.Render(m.spinner.View()),
messageStyle.Render(m.message)))
}
// quitMsg is sent when we want to quit the spinner
type quitMsg struct{}
// NewSpinner creates a new animated spinner with the specified message. The spinner
// uses the theme's primary color and a modern animation style. It runs in a separate
// tea.Program to avoid interfering with other terminal operations.
// NewSpinner creates a new animated spinner with the specified message.
// The spinner uses the theme's primary color and a points animation style.
func NewSpinner(message string) *Spinner {
s := spinner.New()
s.Spinner = spinner.Points // More modern spinner style
theme := GetTheme()
s.Style = s.Style.Foreground(theme.Primary)
ctx, cancel := context.WithCancel(context.Background())
model := spinnerModel{
spinner: s,
message: message,
}
prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
return &Spinner{
model: s,
message: message,
frames: pointsFrames,
fps: pointsFPS,
color: GetTheme().Primary,
done: make(chan struct{}),
prog: prog,
ctx: ctx,
cancel: cancel,
}
}
// NewThemedSpinner creates a new animated spinner with custom color styling.
// This allows for different spinner colors based on the operation type or status.
// The spinner runs independently in its own tea.Program.
func NewThemedSpinner(message string, color color.Color) *Spinner {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = s.Style.Foreground(color)
ctx, cancel := context.WithCancel(context.Background())
model := spinnerModel{
spinner: s,
message: message,
}
prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
func NewThemedSpinner(message string, c color.Color) *Spinner {
return &Spinner{
model: s,
message: message,
frames: dotFrames,
fps: dotFPS,
color: c,
done: make(chan struct{}),
prog: prog,
ctx: ctx,
cancel: cancel,
}
}
// Start begins the spinner animation in a separate goroutine. The spinner will
// continue animating until Stop is called. The animation runs in a separate
// tea.Program to maintain smooth animation independent of other operations.
// Start begins the spinner animation in a separate goroutine. The spinner
// will continue animating until Stop is called.
func (s *Spinner) Start() {
go func() {
defer close(s.done)
go func() {
<-s.ctx.Done()
s.prog.Send(quitMsg{})
}()
_, err := s.prog.Run()
if err != nil {
fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
}
}()
go s.run()
}
// Stop halts the spinner animation and cleans up resources. This method blocks
// until the spinner has fully stopped and the terminal state is restored.
// Stop halts the spinner animation and cleans up. This method blocks until
// the animation goroutine has exited and the line is cleared.
func (s *Spinner) Stop() {
s.cancel()
<-s.done
s.once.Do(func() { close(s.done) })
}
// run is the animation loop that renders spinner frames to stderr.
func (s *Spinner) run() {
theme := GetTheme()
spinnerStyle := lipgloss.NewStyle().
Foreground(s.color).
Bold(true)
messageStyle := lipgloss.NewStyle().
Foreground(theme.Text).
Italic(true)
ticker := time.NewTicker(s.fps)
defer ticker.Stop()
var frame int
for {
select {
case <-s.done:
// Clear the spinner line and return.
fmt.Fprint(os.Stderr, "\r\033[K")
return
case <-ticker.C:
f := s.frames[frame%len(s.frames)]
fmt.Fprintf(os.Stderr, "\r %s %s",
spinnerStyle.Render(f),
messageStyle.Render(s.message))
frame++
}
}
}
+81 -75
View File
@@ -11,9 +11,15 @@ import (
const defaultMargin = 1
// Helper functions for style pointers
func boolPtr(b bool) *bool { return &b }
func stringPtr(s string) *string { return &s }
func uintPtr(u uint) *uint { return &u }
//
//go:fix inline
func boolPtr(b bool) *bool { return new(b) }
//go:fix inline
func stringPtr(s string) *string { return new(s) }
//go:fix inline
func uintPtr(u uint) *uint { return new(u) }
// BaseStyle returns a new, empty lipgloss style that can be customized with
// additional styling methods. This serves as the foundation for building more
@@ -106,101 +112,101 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "",
BlockSuffix: "",
Color: stringPtr(textColor),
Color: new(textColor),
},
Margin: uintPtr(0), // Remove margin to prevent spacing
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(mutedColor),
Italic: boolPtr(true),
Color: new(mutedColor),
Italic: new(true),
Prefix: "┃ ",
},
Indent: uintPtr(1),
IndentToken: stringPtr(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
},
List: ansi.StyleList{
LevelIndent: 0, // Remove list indentation
StyleBlock: ansi.StyleBlock{
IndentToken: stringPtr(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
IndentToken: new(lipgloss.NewStyle().Background(lipgloss.Color(bgColor)).Render(" ")),
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
},
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: stringPtr(headingColor),
Bold: boolPtr(true),
Color: new(headingColor),
Bold: new(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "# ",
Color: stringPtr(headingColor),
Bold: boolPtr(true),
Color: new(headingColor),
Bold: new(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: stringPtr(headingColor),
Bold: boolPtr(true),
Color: new(headingColor),
Bold: new(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: stringPtr(headingColor),
Bold: boolPtr(true),
Color: new(headingColor),
Bold: new(true),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: stringPtr(headingColor),
Bold: boolPtr(true),
Color: new(headingColor),
Bold: new(true),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: stringPtr(headingColor),
Bold: boolPtr(true),
Color: new(headingColor),
Bold: new(true),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: stringPtr(headingColor),
Bold: boolPtr(true),
Color: new(headingColor),
Bold: new(true),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: boolPtr(true),
Color: stringPtr(mutedColor),
CrossedOut: new(true),
Color: new(mutedColor),
},
Emph: ansi.StylePrimitive{
Color: stringPtr(emphColor),
Color: new(emphColor),
Italic: boolPtr(true),
Italic: new(true),
},
Strong: ansi.StylePrimitive{
Bold: boolPtr(true),
Color: stringPtr(strongColor),
Bold: new(true),
Color: new(strongColor),
},
HorizontalRule: ansi.StylePrimitive{
Color: stringPtr(mutedColor),
Color: new(mutedColor),
Format: "\n─────────────────────────────────────────\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
Color: stringPtr(textColor),
Color: new(textColor),
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
Color: stringPtr(textColor),
Color: new(textColor),
},
Task: ansi.StyleTask{
StylePrimitive: ansi.StylePrimitive{},
@@ -208,29 +214,29 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: stringPtr(linkColor),
Color: new(linkColor),
Underline: boolPtr(true),
Underline: new(true),
},
LinkText: ansi.StylePrimitive{
Color: stringPtr(linkColor),
Color: new(linkColor),
Bold: boolPtr(true),
Bold: new(true),
},
Image: ansi.StylePrimitive{
Color: stringPtr(linkColor),
Color: new(linkColor),
Underline: boolPtr(true),
Underline: new(true),
Format: "🖼 {{.text}}",
},
ImageText: ansi.StylePrimitive{
Color: stringPtr(linkColor),
Color: new(linkColor),
Format: "{{.text}}",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(codeColor),
Color: new(codeColor),
Prefix: "",
Suffix: "",
@@ -240,92 +246,92 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "",
Color: stringPtr(codeColor),
Color: new(codeColor),
},
Margin: uintPtr(0), // Remove margin
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
Error: ansi.StylePrimitive{
Color: stringPtr(errorColor),
Color: new(errorColor),
},
Comment: ansi.StylePrimitive{
Color: stringPtr(commentColor),
Color: new(commentColor),
},
CommentPreproc: ansi.StylePrimitive{
Color: stringPtr(keywordColor),
Color: new(keywordColor),
},
Keyword: ansi.StylePrimitive{
Color: stringPtr(keywordColor),
Color: new(keywordColor),
},
KeywordReserved: ansi.StylePrimitive{
Color: stringPtr(keywordColor),
Color: new(keywordColor),
},
KeywordNamespace: ansi.StylePrimitive{
Color: stringPtr(keywordColor),
Color: new(keywordColor),
},
KeywordType: ansi.StylePrimitive{
Color: stringPtr(keywordColor),
Color: new(keywordColor),
},
Operator: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
Punctuation: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
Name: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
NameBuiltin: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
NameTag: ansi.StylePrimitive{
Color: stringPtr(keywordColor),
Color: new(keywordColor),
},
NameAttribute: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
NameClass: ansi.StylePrimitive{
Color: stringPtr(keywordColor),
Color: new(keywordColor),
},
NameConstant: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
NameDecorator: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
NameFunction: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
LiteralNumber: ansi.StylePrimitive{
Color: stringPtr(numberColor),
Color: new(numberColor),
},
LiteralString: ansi.StylePrimitive{
Color: stringPtr(stringColor),
Color: new(stringColor),
},
LiteralStringEscape: ansi.StylePrimitive{
Color: stringPtr(keywordColor),
Color: new(keywordColor),
},
GenericDeleted: ansi.StylePrimitive{
Color: stringPtr(errorColor),
Color: new(errorColor),
},
GenericEmph: ansi.StylePrimitive{
Color: stringPtr(emphColor),
Color: new(emphColor),
Italic: boolPtr(true),
Italic: new(true),
},
GenericInserted: ansi.StylePrimitive{
Color: stringPtr(stringColor),
Color: new(stringColor),
},
GenericStrong: ansi.StylePrimitive{
Color: stringPtr(strongColor),
Color: new(strongColor),
Bold: boolPtr(true),
Bold: new(true),
},
GenericSubheading: ansi.StylePrimitive{
Color: stringPtr(headingColor),
Color: new(headingColor),
},
},
},
@@ -336,20 +342,20 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
BlockSuffix: "\n",
},
},
CenterSeparator: stringPtr("┼"),
ColumnSeparator: stringPtr("│"),
RowSeparator: stringPtr("─"),
CenterSeparator: new("┼"),
ColumnSeparator: new("│"),
RowSeparator: new("─"),
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n ",
Color: stringPtr(linkColor),
Color: new(linkColor),
},
Text: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
Paragraph: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(textColor),
Color: new(textColor),
},
},
}
+8 -9
View File
@@ -4,7 +4,7 @@ import (
"context"
"fmt"
"github.com/cloudwego/eino/schema"
"charm.land/fantasy"
"github.com/mark3labs/mcphost/cmd"
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/config"
@@ -78,7 +78,7 @@ func New(ctx context.Context, opts *Options) (*MCPHost, error) {
return nil, fmt.Errorf("failed to load system prompt: %v", err)
}
// Create model configuration (same as CLI in root.go:387-406)
// Create model configuration (same as CLI in root.go)
temperature := float32(viper.GetFloat64("temperature"))
topP := float32(viper.GetFloat64("top-p"))
topK := int32(viper.GetInt("top-k"))
@@ -100,7 +100,7 @@ func New(ctx context.Context, opts *Options) (*MCPHost, error) {
TLSSkipVerify: viper.GetBool("tls-skip-verify"),
}
// Create agent using existing factory (same as CLI in root.go:431-440)
// Create agent using existing factory
a, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{
ModelConfig: modelConfig,
MCPConfig: mcpConfig,
@@ -132,10 +132,10 @@ func (m *MCPHost) Prompt(ctx context.Context, message string) (string, error) {
messages := m.sessionMgr.GetMessages()
// Add new user message
userMsg := schema.UserMessage(message)
userMsg := fantasy.NewUserMessage(message)
messages = append(messages, userMsg)
// Call agent (same as CLI does in root.go:902)
// Call agent
result, err := m.agent.GenerateWithLoop(ctx, messages,
nil, // onToolCall
nil, // onToolExecution
@@ -149,12 +149,11 @@ func (m *MCPHost) Prompt(ctx context.Context, message string) (string, error) {
}
// Update session with all messages from the conversation
// This preserves the complete history including tool calls
if err := m.sessionMgr.ReplaceAllMessages(result.ConversationMessages); err != nil {
return "", fmt.Errorf("failed to update session: %v", err)
}
return result.FinalResponse.Content, nil
return result.FinalResponse.Content.Text(), nil
}
// PromptWithCallbacks sends a message with callbacks for monitoring tool execution
@@ -171,7 +170,7 @@ func (m *MCPHost) PromptWithCallbacks(
messages := m.sessionMgr.GetMessages()
// Add new user message
userMsg := schema.UserMessage(message)
userMsg := fantasy.NewUserMessage(message)
messages = append(messages, userMsg)
// Call agent with callbacks
@@ -193,7 +192,7 @@ func (m *MCPHost) PromptWithCallbacks(
return "", fmt.Errorf("failed to update session: %v", err)
}
return result.FinalResponse.Content, nil
return result.FinalResponse.Content.Text(), nil
}
// GetSessionManager returns the current session manager for direct access
+1 -1
View File
@@ -35,7 +35,7 @@ func TestNewWithOptions(t *testing.T) {
ctx := context.Background()
opts := &sdk.Options{
Model: "anthropic:claude-3-haiku-20240307",
Model: "anthropic:claude-sonnet-4-20250514",
MaxSteps: 5,
Quiet: true,
}
+7 -7
View File
@@ -1,7 +1,7 @@
package sdk
import (
"github.com/cloudwego/eino/schema"
"charm.land/fantasy"
"github.com/mark3labs/mcphost/internal/session"
)
@@ -13,14 +13,14 @@ type Message = session.Message
// with its name, arguments, and result within a conversation.
type ToolCall = session.ToolCall
// ConvertToSchemaMessage converts an SDK message to the underlying schema message
// ConvertToFantasyMessage converts an SDK message to the underlying fantasy message
// format used by the agent for LLM interactions.
func ConvertToSchemaMessage(msg *Message) *schema.Message {
return msg.ConvertToSchemaMessage()
func ConvertToFantasyMessage(msg *Message) fantasy.Message {
return msg.ConvertToFantasyMessage()
}
// ConvertFromSchemaMessage converts a schema message from the agent to an SDK
// ConvertFromFantasyMessage converts a fantasy message from the agent to an SDK
// message format for use in the SDK API.
func ConvertFromSchemaMessage(msg *schema.Message) Message {
return session.ConvertFromSchemaMessage(msg)
func ConvertFromFantasyMessage(msg fantasy.Message) Message {
return session.ConvertFromFantasyMessage(msg)
}