Files

214 lines
6.0 KiB
Go

// Copyright 2026 eat-pray-ai & OpenWaygate
// SPDX-License-Identifier: Apache-2.0
package agent
import (
"context"
"embed"
"fmt"
"io"
"log/slog"
"os"
"strings"
"github.com/eat-pray-ai/yutu/cmd"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/cobra"
"google.golang.org/adk/v2/agent"
"google.golang.org/adk/v2/agent/llmagent"
"google.golang.org/adk/v2/cmd/launcher"
"google.golang.org/adk/v2/cmd/launcher/full"
"google.golang.org/adk/v2/memory"
"google.golang.org/adk/v2/model"
"google.golang.org/adk/v2/model/gemini"
"google.golang.org/adk/v2/model/openaimodel"
"google.golang.org/adk/v2/session"
"google.golang.org/adk/v2/tool"
"google.golang.org/adk/v2/tool/geminitool"
"google.golang.org/adk/v2/tool/mcptoolset"
"google.golang.org/adk/v2/tool/skilltoolset"
"google.golang.org/adk/v2/tool/skilltoolset/skill"
"google.golang.org/genai"
)
const (
short = "Start an agent to automate YouTube workflows"
long = "Start an agent to automate YouTube workflows."
example = `# Gemini (default)
yutu agent --provider google --model gemini-3.7-flash --api-key "YOUR_KEY"
# OpenAI
yutu agent --provider openai --model gpt-5.6-terra
# OpenAI-compatible endpoint (DeepSeek, Ollama, vLLM, etc.)
yutu agent --provider openai-compatible --model deepseek-v4-flash --base-url "https://api.deepseek.com/"
# Custom Gemini endpoint
yutu agent --provider google --model gemini-3.7-flash --base-url "https://custom-endpoint.example.com/"`
argsUsage = "Launcher arguments as a single string"
providerUsage = "LLM provider (google, openai, openai-compatible)"
modelUsage = "Model name"
apiKeyUsage = "API key for the model provider"
baseURLUsage = "Base URL for the model provider's API endpoint"
instructionUsage = "Override the built-in agent instruction"
agentDescription = "YouTube growth strategist and workflow assistant — retrieve, create, update, and delete YouTube content."
errUnsupportedProvider = "unsupported provider %q: supported providers are google, openai, openai-compatible"
errModelConfig = "model configuration error"
errMCPConnect = "failed to connect to MCP server"
errMCPToolSet = "failed to create MCP tool set"
errSkillToolset = "failed to create skill toolset"
errBuildAgent = "failed to build agent"
errLaunchAgent = "failed to launch agent"
)
var (
launcherArgs string
provider string
modelName string
apiKey string
baseURL string
inst string
//go:embed INSTRUCTION.md
instruction string
//go:embed skills
skillsFS embed.FS
)
var agentCmd = &cobra.Command{
Use: "agent",
Short: short,
Long: long,
Example: example,
Run: func(cmd *cobra.Command, _ []string) {
if launcherArgs == "" {
_, _ = fmt.Fprintln(
cmd.OutOrStdout(), full.NewLauncher().CommandLineSyntax(),
)
return
}
launch(cmd.Context(), cmd.OutOrStdout(), strings.Fields(launcherArgs))
},
}
func init() {
cmd.RootCmd.AddCommand(agentCmd)
agentCmd.Flags().StringVarP(&launcherArgs, "args", "a", "console", argsUsage)
agentCmd.Flags().StringVarP(
&provider, "provider", "p", "google", providerUsage,
)
agentCmd.Flags().StringVarP(
&modelName, "model", "m", "gemini-3.7-flash", modelUsage,
)
agentCmd.Flags().StringVar(&apiKey, "api-key", "", apiKeyUsage)
agentCmd.Flags().StringVar(&baseURL, "base-url", "", baseURLUsage)
agentCmd.Flags().StringVarP(&inst, "instruction", "i", "", instructionUsage)
}
func newModel(ctx context.Context) (model.LLM, error) {
switch provider {
case "google":
cfg := &genai.ClientConfig{
APIKey: apiKey,
Backend: genai.BackendGeminiAPI,
}
if baseURL != "" {
cfg.HTTPOptions.BaseURL = baseURL
}
return gemini.NewModel(ctx, modelName, cfg)
case "openai", "openai-compatible":
return openaimodel.NewModel(
ctx, modelName, &openaimodel.ClientConfig{
APIKey: apiKey,
BaseURL: baseURL,
},
)
default:
return nil, fmt.Errorf(errUnsupportedProvider, provider)
}
}
func newSkillToolset(ctx context.Context) (tool.Toolset, error) {
source := skill.NewFileSystemSource(skillsFS)
source, _, err := skill.WithCompletePreloadSource(ctx, source)
if err != nil {
return nil, fmt.Errorf("preload skills: %w", err)
}
return skilltoolset.New(ctx, skilltoolset.Config{Source: source})
}
func buildAgent(
m model.LLM, mcpToolSet, skillToolset tool.Toolset,
) (agent.Agent, error) {
var tools []tool.Tool
if provider == "google" {
tools = append(tools, geminitool.GoogleSearch{})
}
return llmagent.New(
llmagent.Config{
Name: "Miffy",
Model: m,
Description: agentDescription,
Instruction: instruction,
Tools: tools,
Toolsets: []tool.Toolset{mcpToolSet, skillToolset},
},
)
}
func launch(ctx context.Context, writer io.Writer, args []string) {
if inst != "" {
instruction = inst
}
m, err := newModel(ctx)
if err != nil {
slog.ErrorContext(ctx, errModelConfig, "error", err)
os.Exit(1)
}
clientTransport, serverTransport := mcp.NewInMemoryTransports()
_, err = cmd.Server.Connect(ctx, serverTransport, nil)
if err != nil {
slog.ErrorContext(ctx, errMCPConnect, "error", err)
os.Exit(1)
}
mcpToolSet, err := mcptoolset.New(
mcptoolset.Config{
Transport: clientTransport,
},
)
if err != nil {
slog.ErrorContext(ctx, errMCPToolSet, "error", err)
os.Exit(1)
}
skillToolset, err := newSkillToolset(ctx)
if err != nil {
slog.ErrorContext(ctx, errSkillToolset, "error", err)
os.Exit(1)
}
miffy, err := buildAgent(m, mcpToolSet, skillToolset)
if err != nil {
slog.ErrorContext(ctx, errBuildAgent, "error", err)
os.Exit(1)
}
sessionService := session.InMemoryService()
memoryService := memory.InMemoryService()
config := &launcher.Config{
AgentLoader: agent.NewSingleLoader(miffy),
SessionService: sessionService,
MemoryService: memoryService,
}
l := full.NewLauncher()
if err := l.Execute(ctx, config, args); err != nil {
slog.ErrorContext(ctx, errLaunchAgent, "error", err)
_, _ = fmt.Fprintln(writer, l.CommandLineSyntax())
os.Exit(1)
}
}