feat(project): 项目完结
This commit is contained in:
2
internal/api/doc.go
Normal file
2
internal/api/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package api contains request and response contracts exposed by triggers.
|
||||
package api
|
||||
27
internal/api/dto/agent.go
Normal file
27
internal/api/dto/agent.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package dto
|
||||
|
||||
type AiAgentConfigResponse struct {
|
||||
AgentID string `json:"agentId"`
|
||||
AgentName string `json:"agentName"`
|
||||
AgentDesc string `json:"agentDesc"`
|
||||
}
|
||||
|
||||
type CreateSessionRequest struct {
|
||||
AgentID string `json:"agentId"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
type CreateSessionResponse struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
AgentID string `json:"agentId"`
|
||||
UserID string `json:"userId"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ChatResponse struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
24
internal/api/response/response.go
Normal file
24
internal/api/response/response.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package response
|
||||
|
||||
import "ai-agent-scaffold-go/pkg/types"
|
||||
|
||||
type Envelope[T any] struct {
|
||||
Code string `json:"code"`
|
||||
Info string `json:"info"`
|
||||
Data T `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func Success[T any](data T) Envelope[T] {
|
||||
return Envelope[T]{
|
||||
Code: types.CodeSuccess,
|
||||
Info: types.InfoSuccess,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func Failure(code, info string) Envelope[any] {
|
||||
return Envelope[any]{
|
||||
Code: code,
|
||||
Info: info,
|
||||
}
|
||||
}
|
||||
150
internal/app/bootstrap/bootstrap.go
Normal file
150
internal/app/bootstrap/bootstrap.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Package bootstrap wires application config, armory assembly, chat service,
|
||||
// and Gin HTTP routes into a single runnable engine.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"ai-agent-scaffold-go/internal/app/config"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/service/armory"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/service/armory/factory"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/service/chat"
|
||||
"ai-agent-scaffold-go/internal/infrastructure/adk"
|
||||
"ai-agent-scaffold-go/internal/infrastructure/ai"
|
||||
httptrigger "ai-agent-scaffold-go/internal/trigger/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Engine struct {
|
||||
Application config.Application
|
||||
Router *gin.Engine
|
||||
ChatService *chat.Service
|
||||
Registry ports.AgentRegistry
|
||||
Sessions ports.SessionStore
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
func Build(ctx context.Context, appCfg config.Application, logger *zap.Logger) (*Engine, error) {
|
||||
tables, err := loadAgentTables(appCfg.Agent.ConfigPaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return nil, fmt.Errorf("no agent tables loaded; configure agent.config-paths")
|
||||
}
|
||||
|
||||
llmTimeout, err := appCfg.LLM.RequestTimeoutDuration()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registry := ports.NewInMemoryAgentRegistry()
|
||||
sessions := ports.NewInMemorySessionStore()
|
||||
|
||||
modelProvider := ai.NewEinoProvider().WithRequestTimeout(llmTimeout)
|
||||
toolRouter := ai.NewMCPToolRouter()
|
||||
mcpFactory := ai.NewToolFactory(toolRouter)
|
||||
skillFactory := ai.NewSkillFactory()
|
||||
agentFactory := adk.NewFactory()
|
||||
agentFactory.UseToolRouter(toolRouter)
|
||||
runnerFactory := agentFactory
|
||||
|
||||
armoryFactory := factory.NewDefaultFactory(modelProvider, mcpFactory, skillFactory, agentFactory, runnerFactory, registry)
|
||||
armoryService := armory.NewService(armoryFactory.ArmoryStrategyHandler())
|
||||
|
||||
if err := armoryService.AcceptArmoryAgents(ctx, tables); err != nil {
|
||||
return nil, fmt.Errorf("armory assembly: %w", err)
|
||||
}
|
||||
|
||||
chatService := chat.NewService(registry, sessions)
|
||||
|
||||
router := newRouter(appCfg.App.Env)
|
||||
httptrigger.RegisterAgentRoutes(router, chatService)
|
||||
|
||||
if logger != nil {
|
||||
logger.Info("agents registered", zap.Int("count", len(registry.List())), zap.Duration("llm_request_timeout", llmTimeout))
|
||||
}
|
||||
|
||||
return &Engine{
|
||||
Application: appCfg,
|
||||
Router: router,
|
||||
ChatService: chatService,
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
Logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *Engine) Run() error {
|
||||
addr := e.Application.Server.Addr
|
||||
if addr == "" {
|
||||
addr = ":8091"
|
||||
}
|
||||
if e.Logger != nil {
|
||||
e.Logger.Info("http server listening", zap.String("addr", addr))
|
||||
}
|
||||
return e.Router.Run(addr)
|
||||
}
|
||||
|
||||
func newRouter(env string) *gin.Engine {
|
||||
if env == "prod" || env == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
if env != "prod" && env != "production" {
|
||||
router.Use(gin.Logger())
|
||||
}
|
||||
router.Use(corsMiddleware())
|
||||
router.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func loadAgentTables(paths []string) (map[string]model.AiAgentConfigTable, error) {
|
||||
merged := make(map[string]model.AiAgentConfigTable)
|
||||
for _, raw := range paths {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
expanded := os.ExpandEnv(path)
|
||||
tables, err := config.LoadAgentTablesFile(expanded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, table := range tables {
|
||||
merged[name] = table
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
88
internal/app/config/application.go
Normal file
88
internal/app/config/application.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
App AppSection `yaml:"app"`
|
||||
Server ServerSection `yaml:"server"`
|
||||
Database DatabaseSection `yaml:"database"`
|
||||
Redis RedisSection `yaml:"redis"`
|
||||
Agent AgentSection `yaml:"agent"`
|
||||
LLM LLMSection `yaml:"llm"`
|
||||
}
|
||||
|
||||
type AppSection struct {
|
||||
Name string `yaml:"name"`
|
||||
Env string `yaml:"env"`
|
||||
}
|
||||
|
||||
type ServerSection struct {
|
||||
Addr string `yaml:"addr"`
|
||||
}
|
||||
|
||||
type DatabaseSection struct {
|
||||
Required bool `yaml:"required"`
|
||||
DSN string `yaml:"dsn"`
|
||||
}
|
||||
|
||||
type RedisSection struct {
|
||||
Required bool `yaml:"required"`
|
||||
Addr string `yaml:"addr"`
|
||||
Password string `yaml:"password"`
|
||||
DB int `yaml:"db"`
|
||||
}
|
||||
|
||||
type AgentSection struct {
|
||||
ConfigPaths []string `yaml:"config-paths"`
|
||||
}
|
||||
|
||||
type LLMSection struct {
|
||||
RequestTimeout string `yaml:"request-timeout"`
|
||||
}
|
||||
|
||||
const defaultLLMRequestTimeout = 5 * time.Minute
|
||||
|
||||
func (s LLMSection) RequestTimeoutDuration() (time.Duration, error) {
|
||||
raw := ""
|
||||
if s.RequestTimeout != "" {
|
||||
raw = s.RequestTimeout
|
||||
}
|
||||
if raw == "" {
|
||||
return defaultLLMRequestTimeout, nil
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid llm.request-timeout %q: %w", raw, err)
|
||||
}
|
||||
if d <= 0 {
|
||||
return 0, fmt.Errorf("llm.request-timeout must be positive, got %q", raw)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func LoadApplication(path string) (Application, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Application{}, fmt.Errorf("read application config %s: %w", path, err)
|
||||
}
|
||||
var app Application
|
||||
if err := yaml.Unmarshal(data, &app); err != nil {
|
||||
return Application{}, fmt.Errorf("parse application config: %w", err)
|
||||
}
|
||||
if app.Server.Addr == "" {
|
||||
app.Server.Addr = ":8091"
|
||||
}
|
||||
if app.App.Env == "" {
|
||||
app.App.Env = "local"
|
||||
}
|
||||
if _, err := app.LLM.RequestTimeoutDuration(); err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
186
internal/app/config/loader.go
Normal file
186
internal/app/config/loader.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type agentRoot struct {
|
||||
AI struct {
|
||||
Agent struct {
|
||||
Config struct {
|
||||
Tables map[string]model.AiAgentConfigTable `yaml:"tables"`
|
||||
} `yaml:"config"`
|
||||
} `yaml:"agent"`
|
||||
} `yaml:"ai"`
|
||||
}
|
||||
|
||||
func LoadAgentTables(data []byte) (map[string]model.AiAgentConfigTable, error) {
|
||||
expanded := expandEnvPlaceholders(string(data))
|
||||
var root agentRoot
|
||||
if err := yaml.Unmarshal([]byte(expanded), &root); err != nil {
|
||||
return nil, fmt.Errorf("parse agent config: %w", err)
|
||||
}
|
||||
|
||||
tables := root.AI.Agent.Config.Tables
|
||||
if len(tables) == 0 {
|
||||
return nil, fmt.Errorf("agent config tables are required")
|
||||
}
|
||||
|
||||
for name, table := range tables {
|
||||
normalizeDefaults(&table)
|
||||
if err := validateTable(name, table); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tables[name] = table
|
||||
}
|
||||
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func LoadAgentTablesFile(path string) (map[string]model.AiAgentConfigTable, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read agent config %s: %w", path, err)
|
||||
}
|
||||
return LoadAgentTables(data)
|
||||
}
|
||||
|
||||
var envPlaceholderRE = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}`)
|
||||
|
||||
// expandEnvPlaceholders replaces ${VAR} and ${VAR:-default} in YAML text using
|
||||
// the current process environment. It deliberately ignores bare $NAME forms so
|
||||
// dollar signs that happen to appear inside instructions or URLs are not
|
||||
// mangled.
|
||||
func expandEnvPlaceholders(input string) string {
|
||||
return envPlaceholderRE.ReplaceAllStringFunc(input, func(match string) string {
|
||||
groups := envPlaceholderRE.FindStringSubmatch(match)
|
||||
name := groups[1]
|
||||
if value, ok := os.LookupEnv(name); ok && value != "" {
|
||||
return value
|
||||
}
|
||||
if len(groups) > 2 {
|
||||
return groups[2]
|
||||
}
|
||||
return ""
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeDefaults(table *model.AiAgentConfigTable) {
|
||||
if table.Module.AiAPI.CompletionsPath == "" {
|
||||
table.Module.AiAPI.CompletionsPath = "v1/chat/completions"
|
||||
}
|
||||
if table.Module.AiAPI.EmbeddingsPath == "" {
|
||||
table.Module.AiAPI.EmbeddingsPath = "v1/embeddings"
|
||||
}
|
||||
for i := range table.Module.ChatModel.ToolSkillsList {
|
||||
if table.Module.ChatModel.ToolSkillsList[i].Type == "" {
|
||||
table.Module.ChatModel.ToolSkillsList[i].Type = "directory"
|
||||
}
|
||||
}
|
||||
for i := range table.Module.AgentWorkflows {
|
||||
if table.Module.AgentWorkflows[i].MaxIterations == 0 {
|
||||
table.Module.AgentWorkflows[i].MaxIterations = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateTable(name string, table model.AiAgentConfigTable) error {
|
||||
prefix := "agent table " + name
|
||||
required := map[string]string{
|
||||
"app-name": table.AppName,
|
||||
"agent.agent-id": table.Agent.AgentID,
|
||||
"module.ai-api.base-url": table.Module.AiAPI.BaseURL,
|
||||
"module.ai-api.api-key": table.Module.AiAPI.APIKey,
|
||||
"module.chat-model.model": table.Module.ChatModel.Model,
|
||||
"module.runner.agent-name": table.Module.Runner.AgentName,
|
||||
}
|
||||
for field, value := range required {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s: %s is required", prefix, field)
|
||||
}
|
||||
}
|
||||
if len(table.Module.Agents) == 0 {
|
||||
return fmt.Errorf("%s: module.agents is required", prefix)
|
||||
}
|
||||
for i, agent := range table.Module.Agents {
|
||||
if strings.TrimSpace(agent.Name) == "" {
|
||||
return fmt.Errorf("%s: module.agents[%d].name is required", prefix, i)
|
||||
}
|
||||
if strings.TrimSpace(agent.Instruction) == "" {
|
||||
return fmt.Errorf("%s: module.agents[%d].instruction is required", prefix, i)
|
||||
}
|
||||
}
|
||||
for i, workflow := range table.Module.AgentWorkflows {
|
||||
switch workflow.Type {
|
||||
case model.WorkflowTypeLoop, model.WorkflowTypeParallel, model.WorkflowTypeSequential:
|
||||
default:
|
||||
return fmt.Errorf("%s: module.agent-workflows[%d].type is invalid: %s", prefix, i, workflow.Type)
|
||||
}
|
||||
if strings.TrimSpace(workflow.Name) == "" {
|
||||
return fmt.Errorf("%s: module.agent-workflows[%d].name is required", prefix, i)
|
||||
}
|
||||
}
|
||||
for i, tool := range table.Module.ChatModel.ToolMCPList {
|
||||
if err := validateMCPEntry(prefix, i, tool); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMCPEntry(prefix string, index int, tool model.ToolMCPConfig) error {
|
||||
fieldPrefix := fmt.Sprintf("%s: module.chat-model.tool-mcp-list[%d]", prefix, index)
|
||||
count := 0
|
||||
if tool.Local != nil {
|
||||
count++
|
||||
}
|
||||
if tool.SSE != nil {
|
||||
count++
|
||||
}
|
||||
if tool.Stdio != nil {
|
||||
count++
|
||||
}
|
||||
|
||||
switch count {
|
||||
case 0:
|
||||
return fmt.Errorf("%s must define exactly one of local, sse, or stdio", fieldPrefix)
|
||||
case 1:
|
||||
default:
|
||||
return fmt.Errorf("%s cannot define multiple transport types", fieldPrefix)
|
||||
}
|
||||
|
||||
if tool.Local != nil {
|
||||
if strings.TrimSpace(tool.Local.Name) == "" {
|
||||
return fmt.Errorf("%s.local.name is required", fieldPrefix)
|
||||
}
|
||||
}
|
||||
if tool.SSE != nil {
|
||||
if strings.TrimSpace(tool.SSE.Name) == "" {
|
||||
return fmt.Errorf("%s.sse.name is required", fieldPrefix)
|
||||
}
|
||||
if strings.TrimSpace(tool.SSE.BaseURI) == "" {
|
||||
return fmt.Errorf("%s.sse.base-uri is required", fieldPrefix)
|
||||
}
|
||||
if tool.SSE.RequestTimeout < 0 {
|
||||
return fmt.Errorf("%s.sse.request-timeout must be positive", fieldPrefix)
|
||||
}
|
||||
}
|
||||
if tool.Stdio != nil {
|
||||
if strings.TrimSpace(tool.Stdio.Name) == "" {
|
||||
return fmt.Errorf("%s.stdio.name is required", fieldPrefix)
|
||||
}
|
||||
if strings.TrimSpace(tool.Stdio.ServerParameters.Command) == "" {
|
||||
return fmt.Errorf("%s.stdio.server-parameters.command is required", fieldPrefix)
|
||||
}
|
||||
if tool.Stdio.RequestTimeout < 0 {
|
||||
return fmt.Errorf("%s.stdio.request-timeout must be positive", fieldPrefix)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
2
internal/app/doc.go
Normal file
2
internal/app/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package app wires configuration, application services, and infrastructure.
|
||||
package app
|
||||
47
internal/domain/agent/model/command.go
Normal file
47
internal/domain/agent/model/command.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
type ArmoryCommand struct {
|
||||
Table AiAgentConfigTable
|
||||
}
|
||||
|
||||
type ChatCommand struct {
|
||||
AgentID string
|
||||
UserID string
|
||||
SessionID string
|
||||
Message string
|
||||
Content ChatContent
|
||||
}
|
||||
|
||||
type ChatContent struct {
|
||||
Texts []TextPart
|
||||
Files []FilePart
|
||||
InlineDatas []InlineDataPart
|
||||
}
|
||||
|
||||
type TextPart struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
type FilePart struct {
|
||||
FileURI string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type InlineDataPart struct {
|
||||
Bytes []byte
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type RegisteredAgent struct {
|
||||
AppName string
|
||||
AgentID string
|
||||
AgentName string
|
||||
AgentDesc string
|
||||
Runner Runner
|
||||
}
|
||||
|
||||
type Runner interface {
|
||||
CreateSession(userID string) (string, error)
|
||||
Run(userID, sessionID string, content ChatContent) ([]string, error)
|
||||
Stream(userID, sessionID string, content ChatContent) (<-chan string, <-chan error)
|
||||
}
|
||||
96
internal/domain/agent/model/config.go
Normal file
96
internal/domain/agent/model/config.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package model
|
||||
|
||||
type WorkflowType string
|
||||
|
||||
const (
|
||||
WorkflowTypeLoop WorkflowType = "loop"
|
||||
WorkflowTypeParallel WorkflowType = "parallel"
|
||||
WorkflowTypeSequential WorkflowType = "sequential"
|
||||
)
|
||||
|
||||
type AiAgentConfigTable struct {
|
||||
AppName string `yaml:"app-name" json:"appName"`
|
||||
Agent AgentSummary `yaml:"agent" json:"agent"`
|
||||
Module AgentModule `yaml:"module" json:"module"`
|
||||
}
|
||||
|
||||
type AgentSummary struct {
|
||||
AgentID string `yaml:"agent-id" json:"agentId"`
|
||||
AgentName string `yaml:"agent-name" json:"agentName"`
|
||||
AgentDesc string `yaml:"agent-desc" json:"agentDesc"`
|
||||
}
|
||||
|
||||
type AgentModule struct {
|
||||
AiAPI AiAPIConfig `yaml:"ai-api" json:"aiApi"`
|
||||
ChatModel ChatModelConfig `yaml:"chat-model" json:"chatModel"`
|
||||
Agents []AgentConfig `yaml:"agents" json:"agents"`
|
||||
AgentWorkflows []AgentWorkflowConfig `yaml:"agent-workflows" json:"agentWorkflows"`
|
||||
Runner RunnerConfig `yaml:"runner" json:"runner"`
|
||||
}
|
||||
|
||||
type AiAPIConfig struct {
|
||||
BaseURL string `yaml:"base-url" json:"baseUrl"`
|
||||
APIKey string `yaml:"api-key" json:"apiKey"`
|
||||
CompletionsPath string `yaml:"completions-path" json:"completionsPath"`
|
||||
EmbeddingsPath string `yaml:"embeddings-path" json:"embeddingsPath"`
|
||||
}
|
||||
|
||||
type ChatModelConfig struct {
|
||||
Model string `yaml:"model" json:"model"`
|
||||
ToolMCPList []ToolMCPConfig `yaml:"tool-mcp-list" json:"toolMcpList"`
|
||||
ToolSkillsList []ToolSkillsConfig `yaml:"tool-skills-list" json:"toolSkillsList"`
|
||||
}
|
||||
|
||||
type ToolMCPConfig struct {
|
||||
SSE *SSEServerParameters `yaml:"sse,omitempty" json:"sse,omitempty"`
|
||||
Stdio *StdioServerParameters `yaml:"stdio,omitempty" json:"stdio,omitempty"`
|
||||
Local *LocalToolParameters `yaml:"local,omitempty" json:"local,omitempty"`
|
||||
}
|
||||
|
||||
type SSEServerParameters struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
BaseURI string `yaml:"base-uri" json:"baseUri"`
|
||||
SSEEndpoint string `yaml:"sse-endpoint" json:"sseEndpoint"`
|
||||
RequestTimeout int `yaml:"request-timeout" json:"requestTimeout"`
|
||||
}
|
||||
|
||||
type StdioServerParameters struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
RequestTimeout int `yaml:"request-timeout" json:"requestTimeout"`
|
||||
ServerParameters ServerParameters `yaml:"server-parameters" json:"serverParameters"`
|
||||
}
|
||||
|
||||
type ServerParameters struct {
|
||||
Command string `yaml:"command" json:"command"`
|
||||
Args []string `yaml:"args" json:"args"`
|
||||
Env map[string]string `yaml:"env" json:"env"`
|
||||
}
|
||||
|
||||
type LocalToolParameters struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
}
|
||||
|
||||
type ToolSkillsConfig struct {
|
||||
Type string `yaml:"type" json:"type"`
|
||||
Path string `yaml:"path" json:"path"`
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Instruction string `yaml:"instruction" json:"instruction"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
OutputKey string `yaml:"output-key" json:"outputKey"`
|
||||
}
|
||||
|
||||
type AgentWorkflowConfig struct {
|
||||
Type WorkflowType `yaml:"type" json:"type"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
SubAgents []string `yaml:"sub-agents" json:"subAgents"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
MaxIterations int `yaml:"max-iterations" json:"maxIterations"`
|
||||
}
|
||||
|
||||
type RunnerConfig struct {
|
||||
AgentName string `yaml:"agent-name" json:"agentName"`
|
||||
PluginNameList []string `yaml:"plugin-name-list" json:"pluginNameList"`
|
||||
}
|
||||
167
internal/domain/agent/ports/ports.go
Normal file
167
internal/domain/agent/ports/ports.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
)
|
||||
|
||||
type ModelProvider interface {
|
||||
NewAPI(ctx context.Context, config model.AiAPIConfig) (ModelAPI, error)
|
||||
NewChatModel(ctx context.Context, api ModelAPI, config model.ChatModelConfig, tools []Tool) (ChatModel, error)
|
||||
}
|
||||
|
||||
type ModelAPI interface{}
|
||||
|
||||
type ChatRole string
|
||||
|
||||
const (
|
||||
ChatRoleSystem ChatRole = "system"
|
||||
ChatRoleUser ChatRole = "user"
|
||||
ChatRoleAssistant ChatRole = "assistant"
|
||||
ChatRoleTool ChatRole = "tool"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
Role ChatRole
|
||||
Content string
|
||||
ToolCallID string
|
||||
Name string
|
||||
ToolCalls []ChatToolCall
|
||||
}
|
||||
|
||||
type ChatToolCall struct {
|
||||
ID string
|
||||
Name string
|
||||
Arguments string
|
||||
}
|
||||
|
||||
type ChatReply struct {
|
||||
Content string
|
||||
ToolCalls []ChatToolCall
|
||||
}
|
||||
|
||||
type ChatStreamEvent struct {
|
||||
Delta string
|
||||
ToolCalls []ChatToolCall
|
||||
Done bool
|
||||
}
|
||||
|
||||
type ChatModel interface {
|
||||
Generate(ctx context.Context, messages []ChatMessage) (ChatReply, error)
|
||||
Stream(ctx context.Context, messages []ChatMessage) (<-chan ChatStreamEvent, <-chan error)
|
||||
Tools() []Tool
|
||||
}
|
||||
|
||||
type Tool interface {
|
||||
Name() string
|
||||
}
|
||||
|
||||
type ToolDescriptor interface {
|
||||
Description() string
|
||||
}
|
||||
|
||||
type MCPToolFactory interface {
|
||||
BuildTools(ctx context.Context, config model.ToolMCPConfig) ([]Tool, error)
|
||||
}
|
||||
|
||||
type SkillFactory interface {
|
||||
BuildTools(ctx context.Context, config model.ToolSkillsConfig) ([]Tool, error)
|
||||
}
|
||||
|
||||
type ToolRouter interface {
|
||||
CallTool(ctx context.Context, name, arguments string) (string, error)
|
||||
}
|
||||
|
||||
type AgentFactory interface {
|
||||
NewLLMAgent(ctx context.Context, config model.AgentConfig, chatModel ChatModel) (Agent, error)
|
||||
NewLoopAgent(ctx context.Context, config model.AgentWorkflowConfig, subAgents []Agent) (Agent, error)
|
||||
NewParallelAgent(ctx context.Context, config model.AgentWorkflowConfig, subAgents []Agent) (Agent, error)
|
||||
NewSequentialAgent(ctx context.Context, config model.AgentWorkflowConfig, subAgents []Agent) (Agent, error)
|
||||
}
|
||||
|
||||
type Agent interface {
|
||||
Name() string
|
||||
}
|
||||
|
||||
type RunnerPlugin interface {
|
||||
Name() string
|
||||
OnUserMessage(ctx context.Context, appName, userID, sessionID string, agent Agent, content model.ChatContent) error
|
||||
BeforeAgent(ctx context.Context, appName, userID, sessionID string, agent Agent) error
|
||||
}
|
||||
|
||||
type RunnerFactory interface {
|
||||
NewRunner(ctx context.Context, appName string, agent Agent, pluginNames []string) (model.Runner, error)
|
||||
}
|
||||
|
||||
type AgentRegistry interface {
|
||||
Register(agent model.RegisteredAgent) error
|
||||
Get(agentID string) (model.RegisteredAgent, bool)
|
||||
List() []model.RegisteredAgent
|
||||
}
|
||||
|
||||
type SessionStore interface {
|
||||
Get(userID, agentID string) (string, bool)
|
||||
Set(userID, agentID, sessionID string) error
|
||||
}
|
||||
|
||||
type InMemoryAgentRegistry struct {
|
||||
mu sync.RWMutex
|
||||
agents map[string]model.RegisteredAgent
|
||||
}
|
||||
|
||||
func NewInMemoryAgentRegistry() *InMemoryAgentRegistry {
|
||||
return &InMemoryAgentRegistry{agents: make(map[string]model.RegisteredAgent)}
|
||||
}
|
||||
|
||||
func (r *InMemoryAgentRegistry) Register(agent model.RegisteredAgent) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.agents[agent.AgentID] = agent
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InMemoryAgentRegistry) Get(agentID string) (model.RegisteredAgent, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
agent, ok := r.agents[agentID]
|
||||
return agent, ok
|
||||
}
|
||||
|
||||
func (r *InMemoryAgentRegistry) List() []model.RegisteredAgent {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
agents := make([]model.RegisteredAgent, 0, len(r.agents))
|
||||
for _, agent := range r.agents {
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
type InMemorySessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]string
|
||||
}
|
||||
|
||||
func NewInMemorySessionStore() *InMemorySessionStore {
|
||||
return &InMemorySessionStore{sessions: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (s *InMemorySessionStore) Get(userID, agentID string) (string, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
sessionID, ok := s.sessions[sessionKey(userID, agentID)]
|
||||
return sessionID, ok
|
||||
}
|
||||
|
||||
func (s *InMemorySessionStore) Set(userID, agentID, sessionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sessionKey(userID, agentID)] = sessionID
|
||||
return nil
|
||||
}
|
||||
|
||||
func sessionKey(userID, agentID string) string {
|
||||
return userID + ":" + agentID
|
||||
}
|
||||
29
internal/domain/agent/service/armory/agent_node.go
Normal file
29
internal/domain/agent/service/armory/agent_node.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package armory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
"ai-agent-scaffold-go/internal/domain/shared/tree"
|
||||
)
|
||||
|
||||
type AgentNode struct {
|
||||
agentFactory ports.AgentFactory
|
||||
next Handler
|
||||
}
|
||||
|
||||
func NewAgentNode(agentFactory ports.AgentFactory, next Handler) AgentNode {
|
||||
return AgentNode{agentFactory: agentFactory, next: next}
|
||||
}
|
||||
|
||||
func (n AgentNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
|
||||
for _, config := range command.Table.Module.Agents {
|
||||
agent, err := n.agentFactory.NewLLMAgent(ctx, config, dynamic.ChatModel)
|
||||
if err != nil {
|
||||
return model.RegisteredAgent{}, err
|
||||
}
|
||||
dynamic.AddAgent(agent)
|
||||
}
|
||||
return tree.Route(ctx, n.next, command, dynamic)
|
||||
}
|
||||
27
internal/domain/agent/service/armory/ai_api_node.go
Normal file
27
internal/domain/agent/service/armory/ai_api_node.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package armory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
"ai-agent-scaffold-go/internal/domain/shared/tree"
|
||||
)
|
||||
|
||||
type AiAPINode struct {
|
||||
modelProvider ports.ModelProvider
|
||||
next Handler
|
||||
}
|
||||
|
||||
func NewAiAPINode(modelProvider ports.ModelProvider, next Handler) AiAPINode {
|
||||
return AiAPINode{modelProvider: modelProvider, next: next}
|
||||
}
|
||||
|
||||
func (n AiAPINode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
|
||||
api, err := n.modelProvider.NewAPI(ctx, command.Table.Module.AiAPI)
|
||||
if err != nil {
|
||||
return model.RegisteredAgent{}, err
|
||||
}
|
||||
dynamic.ModelAPI = api
|
||||
return tree.Route(ctx, n.next, command, dynamic)
|
||||
}
|
||||
54
internal/domain/agent/service/armory/chat_model_node.go
Normal file
54
internal/domain/agent/service/armory/chat_model_node.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package armory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
"ai-agent-scaffold-go/internal/domain/shared/tree"
|
||||
)
|
||||
|
||||
type ChatModelNode struct {
|
||||
modelProvider ports.ModelProvider
|
||||
mcpFactory ports.MCPToolFactory
|
||||
skillFactory ports.SkillFactory
|
||||
next Handler
|
||||
}
|
||||
|
||||
func NewChatModelNode(
|
||||
modelProvider ports.ModelProvider,
|
||||
mcpFactory ports.MCPToolFactory,
|
||||
skillFactory ports.SkillFactory,
|
||||
next Handler,
|
||||
) ChatModelNode {
|
||||
return ChatModelNode{
|
||||
modelProvider: modelProvider,
|
||||
mcpFactory: mcpFactory,
|
||||
skillFactory: skillFactory,
|
||||
next: next,
|
||||
}
|
||||
}
|
||||
|
||||
func (n ChatModelNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
|
||||
var tools []ports.Tool
|
||||
for _, config := range command.Table.Module.ChatModel.ToolMCPList {
|
||||
built, err := n.mcpFactory.BuildTools(ctx, config)
|
||||
if err != nil {
|
||||
return model.RegisteredAgent{}, err
|
||||
}
|
||||
tools = append(tools, built...)
|
||||
}
|
||||
for _, config := range command.Table.Module.ChatModel.ToolSkillsList {
|
||||
built, err := n.skillFactory.BuildTools(ctx, config)
|
||||
if err != nil {
|
||||
return model.RegisteredAgent{}, err
|
||||
}
|
||||
tools = append(tools, built...)
|
||||
}
|
||||
chatModel, err := n.modelProvider.NewChatModel(ctx, dynamic.ModelAPI, command.Table.Module.ChatModel, tools)
|
||||
if err != nil {
|
||||
return model.RegisteredAgent{}, err
|
||||
}
|
||||
dynamic.ChatModel = chatModel
|
||||
return tree.Route(ctx, n.next, command, dynamic)
|
||||
}
|
||||
87
internal/domain/agent/service/armory/context.go
Normal file
87
internal/domain/agent/service/armory/context.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package armory
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
)
|
||||
|
||||
type DynamicContext struct {
|
||||
mu sync.RWMutex
|
||||
ModelAPI ports.ModelAPI
|
||||
ChatModel ports.ChatModel
|
||||
agentGroup map[string]ports.Agent
|
||||
currentStepIndex int
|
||||
currentWorkflow *model.AgentWorkflowConfig
|
||||
values map[string]any
|
||||
}
|
||||
|
||||
func NewDynamicContext() *DynamicContext {
|
||||
return &DynamicContext{
|
||||
agentGroup: make(map[string]ports.Agent),
|
||||
values: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DynamicContext) AddAgent(agent ports.Agent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.agentGroup[agent.Name()] = agent
|
||||
}
|
||||
|
||||
func (c *DynamicContext) Agent(name string) (ports.Agent, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
agent, ok := c.agentGroup[name]
|
||||
return agent, ok
|
||||
}
|
||||
|
||||
func (c *DynamicContext) QueryAgentList(names []string) []ports.Agent {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
agents := make([]ports.Agent, 0, len(names))
|
||||
for _, name := range names {
|
||||
if agent, ok := c.agentGroup[name]; ok {
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
func (c *DynamicContext) AddCurrentStepIndex() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.currentStepIndex++
|
||||
}
|
||||
|
||||
func (c *DynamicContext) CurrentStepIndex() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.currentStepIndex
|
||||
}
|
||||
|
||||
func (c *DynamicContext) SetCurrentWorkflow(workflow *model.AgentWorkflowConfig) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.currentWorkflow = workflow
|
||||
}
|
||||
|
||||
func (c *DynamicContext) CurrentWorkflow() *model.AgentWorkflowConfig {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.currentWorkflow
|
||||
}
|
||||
|
||||
func (c *DynamicContext) SetValue(key string, value any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.values[key] = value
|
||||
}
|
||||
|
||||
func (c *DynamicContext) Value(key string) (any, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
value, ok := c.values[key]
|
||||
return value, ok
|
||||
}
|
||||
32
internal/domain/agent/service/armory/factory/factory.go
Normal file
32
internal/domain/agent/service/armory/factory/factory.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/service/armory"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/service/armory/workflow"
|
||||
)
|
||||
|
||||
type DefaultFactory struct {
|
||||
root armory.Handler
|
||||
}
|
||||
|
||||
func NewDefaultFactory(
|
||||
modelProvider ports.ModelProvider,
|
||||
mcpFactory ports.MCPToolFactory,
|
||||
skillFactory ports.SkillFactory,
|
||||
agentFactory ports.AgentFactory,
|
||||
runnerFactory ports.RunnerFactory,
|
||||
registry ports.AgentRegistry,
|
||||
) *DefaultFactory {
|
||||
runner := armory.NewRunnerNode(runnerFactory, registry)
|
||||
workflowNode := workflow.NewAgentWorkflowNode(agentFactory, runner)
|
||||
agent := armory.NewAgentNode(agentFactory, workflowNode)
|
||||
chatModel := armory.NewChatModelNode(modelProvider, mcpFactory, skillFactory, agent)
|
||||
api := armory.NewAiAPINode(modelProvider, chatModel)
|
||||
root := armory.NewRootNode(api)
|
||||
return &DefaultFactory{root: root}
|
||||
}
|
||||
|
||||
func (f *DefaultFactory) ArmoryStrategyHandler() armory.Handler {
|
||||
return f.root
|
||||
}
|
||||
20
internal/domain/agent/service/armory/root_node.go
Normal file
20
internal/domain/agent/service/armory/root_node.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package armory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/shared/tree"
|
||||
)
|
||||
|
||||
type RootNode struct {
|
||||
next Handler
|
||||
}
|
||||
|
||||
func NewRootNode(next Handler) RootNode {
|
||||
return RootNode{next: next}
|
||||
}
|
||||
|
||||
func (n RootNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
|
||||
return tree.Route(ctx, n.next, command, dynamic)
|
||||
}
|
||||
44
internal/domain/agent/service/armory/runner_node.go
Normal file
44
internal/domain/agent/service/armory/runner_node.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package armory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
)
|
||||
|
||||
type RunnerNode struct {
|
||||
runnerFactory ports.RunnerFactory
|
||||
registry ports.AgentRegistry
|
||||
}
|
||||
|
||||
func NewRunnerNode(runnerFactory ports.RunnerFactory, registry ports.AgentRegistry) RunnerNode {
|
||||
return RunnerNode{runnerFactory: runnerFactory, registry: registry}
|
||||
}
|
||||
|
||||
func (n RunnerNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *DynamicContext) (model.RegisteredAgent, error) {
|
||||
runnerConfig := command.Table.Module.Runner
|
||||
if runnerConfig.AgentName == "" {
|
||||
return model.RegisteredAgent{}, fmt.Errorf("runner.agent-name is required")
|
||||
}
|
||||
agent, ok := dynamic.Agent(runnerConfig.AgentName)
|
||||
if !ok {
|
||||
return model.RegisteredAgent{}, fmt.Errorf("runner agent %q not found", runnerConfig.AgentName)
|
||||
}
|
||||
runner, err := n.runnerFactory.NewRunner(ctx, command.Table.AppName, agent, runnerConfig.PluginNameList)
|
||||
if err != nil {
|
||||
return model.RegisteredAgent{}, err
|
||||
}
|
||||
registered := model.RegisteredAgent{
|
||||
AppName: command.Table.AppName,
|
||||
AgentID: command.Table.Agent.AgentID,
|
||||
AgentName: command.Table.Agent.AgentName,
|
||||
AgentDesc: command.Table.Agent.AgentDesc,
|
||||
Runner: runner,
|
||||
}
|
||||
if err := n.registry.Register(registered); err != nil {
|
||||
return model.RegisteredAgent{}, err
|
||||
}
|
||||
return registered, nil
|
||||
}
|
||||
25
internal/domain/agent/service/armory/service.go
Normal file
25
internal/domain/agent/service/armory/service.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package armory
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
handler Handler
|
||||
}
|
||||
|
||||
func NewService(handler Handler) *Service {
|
||||
return &Service{handler: handler}
|
||||
}
|
||||
|
||||
func (s *Service) AcceptArmoryAgents(ctx context.Context, tables map[string]model.AiAgentConfigTable) error {
|
||||
for _, table := range tables {
|
||||
_, err := s.handler.Apply(ctx, model.ArmoryCommand{Table: table}, NewDynamicContext())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
8
internal/domain/agent/service/armory/support.go
Normal file
8
internal/domain/agent/service/armory/support.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package armory
|
||||
|
||||
import (
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/shared/tree"
|
||||
)
|
||||
|
||||
type Handler = tree.Handler[model.ArmoryCommand, *DynamicContext, model.RegisteredAgent]
|
||||
@@ -0,0 +1,67 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/service/armory"
|
||||
"ai-agent-scaffold-go/internal/domain/shared/tree"
|
||||
)
|
||||
|
||||
type workflowBuilder interface {
|
||||
Build(context.Context, model.AgentWorkflowConfig, []ports.Agent) (ports.Agent, error)
|
||||
}
|
||||
|
||||
type AgentWorkflowNode struct {
|
||||
next armory.Handler
|
||||
builders map[model.WorkflowType]workflowBuilder
|
||||
buildOrder []model.WorkflowType
|
||||
}
|
||||
|
||||
func NewAgentWorkflowNode(agentFactory ports.AgentFactory, next armory.Handler) *AgentWorkflowNode {
|
||||
buildOrder := []model.WorkflowType{
|
||||
model.WorkflowTypeLoop,
|
||||
model.WorkflowTypeParallel,
|
||||
model.WorkflowTypeSequential,
|
||||
}
|
||||
return &AgentWorkflowNode{
|
||||
next: next,
|
||||
builders: map[model.WorkflowType]workflowBuilder{
|
||||
model.WorkflowTypeLoop: NewLoopNode(agentFactory),
|
||||
model.WorkflowTypeParallel: NewParallelNode(agentFactory),
|
||||
model.WorkflowTypeSequential: NewSequentialNode(agentFactory),
|
||||
},
|
||||
buildOrder: buildOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *AgentWorkflowNode) Apply(ctx context.Context, command model.ArmoryCommand, dynamic *armory.DynamicContext) (model.RegisteredAgent, error) {
|
||||
workflows := command.Table.Module.AgentWorkflows
|
||||
if dynamic.CurrentStepIndex() >= len(workflows) {
|
||||
dynamic.SetCurrentWorkflow(nil)
|
||||
return tree.Route(ctx, n.next, command, dynamic)
|
||||
}
|
||||
|
||||
workflow := workflows[dynamic.CurrentStepIndex()]
|
||||
dynamic.SetCurrentWorkflow(&workflow)
|
||||
dynamic.AddCurrentStepIndex()
|
||||
|
||||
agent, err := n.build(ctx, workflow, dynamic.QueryAgentList(workflow.SubAgents))
|
||||
if err != nil {
|
||||
return model.RegisteredAgent{}, err
|
||||
}
|
||||
dynamic.AddAgent(agent)
|
||||
return n.Apply(ctx, command, dynamic)
|
||||
}
|
||||
|
||||
func (n *AgentWorkflowNode) build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
|
||||
for _, workflowType := range n.buildOrder {
|
||||
if workflow.Type != workflowType {
|
||||
continue
|
||||
}
|
||||
return n.builders[workflowType].Build(ctx, workflow, subAgents)
|
||||
}
|
||||
return nil, fmt.Errorf("agentWorkflow type is error: %s", workflow.Type)
|
||||
}
|
||||
20
internal/domain/agent/service/armory/workflow/loop_node.go
Normal file
20
internal/domain/agent/service/armory/workflow/loop_node.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
)
|
||||
|
||||
type LoopNode struct {
|
||||
agentFactory ports.AgentFactory
|
||||
}
|
||||
|
||||
func NewLoopNode(agentFactory ports.AgentFactory) LoopNode {
|
||||
return LoopNode{agentFactory: agentFactory}
|
||||
}
|
||||
|
||||
func (n LoopNode) Build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
|
||||
return n.agentFactory.NewLoopAgent(ctx, workflow, subAgents)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
)
|
||||
|
||||
type ParallelNode struct {
|
||||
agentFactory ports.AgentFactory
|
||||
}
|
||||
|
||||
func NewParallelNode(agentFactory ports.AgentFactory) ParallelNode {
|
||||
return ParallelNode{agentFactory: agentFactory}
|
||||
}
|
||||
|
||||
func (n ParallelNode) Build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
|
||||
return n.agentFactory.NewParallelAgent(ctx, workflow, subAgents)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
)
|
||||
|
||||
type SequentialNode struct {
|
||||
agentFactory ports.AgentFactory
|
||||
}
|
||||
|
||||
func NewSequentialNode(agentFactory ports.AgentFactory) SequentialNode {
|
||||
return SequentialNode{agentFactory: agentFactory}
|
||||
}
|
||||
|
||||
func (n SequentialNode) Build(ctx context.Context, workflow model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
|
||||
return n.agentFactory.NewSequentialAgent(ctx, workflow, subAgents)
|
||||
}
|
||||
117
internal/domain/agent/service/chat/service.go
Normal file
117
internal/domain/agent/service/chat/service.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
"ai-agent-scaffold-go/pkg/types"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
registry ports.AgentRegistry
|
||||
sessions ports.SessionStore
|
||||
}
|
||||
|
||||
func NewService(registry ports.AgentRegistry, sessions ports.SessionStore) *Service {
|
||||
return &Service{registry: registry, sessions: sessions}
|
||||
}
|
||||
|
||||
func (s *Service) QueryAgentConfigList() []model.AgentSummary {
|
||||
registered := s.registry.List()
|
||||
sort.Slice(registered, func(i, j int) bool {
|
||||
return registered[i].AgentID < registered[j].AgentID
|
||||
})
|
||||
agents := make([]model.AgentSummary, 0, len(registered))
|
||||
for _, agent := range registered {
|
||||
agents = append(agents, model.AgentSummary{
|
||||
AgentID: agent.AgentID,
|
||||
AgentName: agent.AgentName,
|
||||
AgentDesc: agent.AgentDesc,
|
||||
})
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
func (s *Service) CreateSession(agentID, userID string) (string, error) {
|
||||
if sessionID, ok := s.sessions.Get(userID, agentID); ok {
|
||||
return sessionID, nil
|
||||
}
|
||||
registered, ok := s.registry.Get(agentID)
|
||||
if !ok || registered.Runner == nil {
|
||||
return "", types.NewAppError(types.CodeAgentNotFound, types.InfoAgentNotFound)
|
||||
}
|
||||
sessionID, err := registered.Runner.CreateSession(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.sessions.Set(userID, agentID, sessionID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
func (s *Service) HandleMessage(agentID, userID, sessionID, message string) ([]string, error) {
|
||||
content := model.ChatContent{Texts: []model.TextPart{{Message: message}}}
|
||||
return s.HandleCommand(model.ChatCommand{
|
||||
AgentID: agentID,
|
||||
UserID: userID,
|
||||
SessionID: sessionID,
|
||||
Message: message,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) HandleCommand(command model.ChatCommand) ([]string, error) {
|
||||
registered, sessionID, err := s.resolveRunnerSession(command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return registered.Runner.Run(command.UserID, sessionID, command.Content)
|
||||
}
|
||||
|
||||
func (s *Service) HandleMessageStream(agentID, userID, sessionID, message string) (<-chan string, <-chan error) {
|
||||
return s.HandleCommandStream(model.ChatCommand{
|
||||
AgentID: agentID,
|
||||
UserID: userID,
|
||||
SessionID: sessionID,
|
||||
Message: message,
|
||||
Content: model.ChatContent{Texts: []model.TextPart{{Message: message}}},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) HandleCommandStream(command model.ChatCommand) (<-chan string, <-chan error) {
|
||||
registered, sessionID, err := s.resolveRunnerSession(command)
|
||||
if err != nil {
|
||||
outputs := make(chan string)
|
||||
errs := make(chan error, 1)
|
||||
errs <- err
|
||||
close(outputs)
|
||||
close(errs)
|
||||
return outputs, errs
|
||||
}
|
||||
return registered.Runner.Stream(command.UserID, sessionID, command.Content)
|
||||
}
|
||||
|
||||
func (s *Service) resolveRunnerSession(command model.ChatCommand) (model.RegisteredAgent, string, error) {
|
||||
registered, ok := s.registry.Get(command.AgentID)
|
||||
if !ok || registered.Runner == nil {
|
||||
return model.RegisteredAgent{}, "", types.NewAppError(types.CodeAgentNotFound, types.InfoAgentNotFound)
|
||||
}
|
||||
sessionID := command.SessionID
|
||||
if sessionID == "" {
|
||||
var err error
|
||||
sessionID, err = s.CreateSession(command.AgentID, command.UserID)
|
||||
if err != nil {
|
||||
return model.RegisteredAgent{}, "", err
|
||||
}
|
||||
}
|
||||
if len(command.Content.Texts) == 0 && command.Message != "" {
|
||||
command.Content.Texts = []model.TextPart{{Message: command.Message}}
|
||||
}
|
||||
if len(command.Content.Texts) == 0 && len(command.Content.Files) == 0 && len(command.Content.InlineDatas) == 0 {
|
||||
return model.RegisteredAgent{}, "", fmt.Errorf("chat content is required")
|
||||
}
|
||||
return registered, sessionID, nil
|
||||
}
|
||||
2
internal/domain/doc.go
Normal file
2
internal/domain/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package domain contains Agent models, services, ports, and Armory logic.
|
||||
package domain
|
||||
19
internal/domain/shared/tree/router.go
Normal file
19
internal/domain/shared/tree/router.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package tree
|
||||
|
||||
import "context"
|
||||
|
||||
type Handler[C any, D any, R any] interface {
|
||||
Apply(ctx context.Context, command C, dynamic D) (R, error)
|
||||
}
|
||||
|
||||
func Route[C any, D any, R any](ctx context.Context, next Handler[C, D, R], command C, dynamic D) (R, error) {
|
||||
if next == nil {
|
||||
return zero[R](), nil
|
||||
}
|
||||
return next.Apply(ctx, command, dynamic)
|
||||
}
|
||||
|
||||
func zero[T any]() T {
|
||||
var value T
|
||||
return value
|
||||
}
|
||||
467
internal/infrastructure/adk/adapter.go
Normal file
467
internal/infrastructure/adk/adapter.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package adk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
"google.golang.org/adk/plugin"
|
||||
"google.golang.org/adk/plugin/loggingplugin"
|
||||
)
|
||||
|
||||
const maxToolCallIterations = 4
|
||||
|
||||
type Factory struct {
|
||||
sessionCounter atomic.Uint64
|
||||
plugins map[string]func() (ports.RunnerPlugin, error)
|
||||
router ports.ToolRouter
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
name string
|
||||
kind string
|
||||
description string
|
||||
instruction string
|
||||
outputKey string
|
||||
chatModel ports.ChatModel
|
||||
router ports.ToolRouter
|
||||
subAgents []ports.Agent
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
appName string
|
||||
agent ports.Agent
|
||||
plugins []ports.RunnerPlugin
|
||||
counter *atomic.Uint64
|
||||
}
|
||||
|
||||
func NewFactory() *Factory {
|
||||
return &Factory{plugins: defaultPlugins()}
|
||||
}
|
||||
|
||||
func (f *Factory) UseToolRouter(router ports.ToolRouter) {
|
||||
f.router = router
|
||||
}
|
||||
|
||||
func (f *Factory) NewLLMAgent(_ context.Context, config model.AgentConfig, chatModel ports.ChatModel) (ports.Agent, error) {
|
||||
if strings.TrimSpace(config.Name) == "" {
|
||||
return nil, fmt.Errorf("agent name is required")
|
||||
}
|
||||
if chatModel == nil {
|
||||
return nil, fmt.Errorf("agent %q requires a chat model", config.Name)
|
||||
}
|
||||
return &Agent{
|
||||
name: config.Name,
|
||||
kind: "llm",
|
||||
description: config.Description,
|
||||
instruction: config.Instruction,
|
||||
outputKey: config.OutputKey,
|
||||
chatModel: chatModel,
|
||||
router: f.router,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Factory) NewLoopAgent(_ context.Context, config model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
|
||||
return newWorkflowAgent("loop", config, subAgents, f.router)
|
||||
}
|
||||
|
||||
func (f *Factory) NewParallelAgent(_ context.Context, config model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
|
||||
return newWorkflowAgent("parallel", config, subAgents, f.router)
|
||||
}
|
||||
|
||||
func (f *Factory) NewSequentialAgent(_ context.Context, config model.AgentWorkflowConfig, subAgents []ports.Agent) (ports.Agent, error) {
|
||||
return newWorkflowAgent("sequential", config, subAgents, f.router)
|
||||
}
|
||||
|
||||
func (f *Factory) NewRunner(_ context.Context, appName string, agent ports.Agent, pluginNames []string) (model.Runner, error) {
|
||||
if strings.TrimSpace(appName) == "" {
|
||||
return nil, fmt.Errorf("app name is required")
|
||||
}
|
||||
if agent == nil {
|
||||
return nil, fmt.Errorf("agent is required")
|
||||
}
|
||||
plugins, err := f.resolvePlugins(pluginNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Runner{appName: appName, agent: agent, plugins: plugins, counter: &f.sessionCounter}, nil
|
||||
}
|
||||
|
||||
func newWorkflowAgent(kind string, config model.AgentWorkflowConfig, subAgents []ports.Agent, router ports.ToolRouter) (ports.Agent, error) {
|
||||
if strings.TrimSpace(config.Name) == "" {
|
||||
return nil, fmt.Errorf("%s agent name is required", kind)
|
||||
}
|
||||
return &Agent{
|
||||
name: config.Name,
|
||||
kind: kind,
|
||||
description: config.Description,
|
||||
subAgents: subAgents,
|
||||
router: router,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) Name() string {
|
||||
return a.name
|
||||
}
|
||||
|
||||
func (a *Agent) Description() string {
|
||||
return a.description
|
||||
}
|
||||
|
||||
func (a *Agent) run(ctx context.Context, content model.ChatContent) (string, error) {
|
||||
return a.runWithVars(ctx, content, map[string]string{})
|
||||
}
|
||||
|
||||
func (a *Agent) runWithVars(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
|
||||
switch a.kind {
|
||||
case "llm":
|
||||
return a.runLLM(ctx, content, vars)
|
||||
case "sequential":
|
||||
return a.runSequential(ctx, content, vars)
|
||||
case "loop", "parallel":
|
||||
return a.runFanOut(ctx, content, vars)
|
||||
default:
|
||||
return "", fmt.Errorf("agent %q has unknown kind %q", a.name, a.kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) stream(ctx context.Context, content model.ChatContent, out chan<- string) error {
|
||||
switch a.kind {
|
||||
case "llm":
|
||||
return a.streamLLM(ctx, content, out, map[string]string{})
|
||||
default:
|
||||
text, err := a.run(ctx, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if text != "" {
|
||||
select {
|
||||
case out <- text:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) runLLM(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
|
||||
messages := initialMessages(applyVars(a.instruction, vars), firstText(content))
|
||||
for iter := 0; iter < maxToolCallIterations; iter++ {
|
||||
reply, err := a.chatModel.Generate(ctx, messages)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(reply.ToolCalls) == 0 {
|
||||
return reply.Content, nil
|
||||
}
|
||||
messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleAssistant, Content: reply.Content, ToolCalls: reply.ToolCalls})
|
||||
toolMessages, err := a.executeToolCalls(ctx, reply.ToolCalls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
messages = append(messages, toolMessages...)
|
||||
}
|
||||
return "", fmt.Errorf("agent %q exceeded tool-call iteration limit %d", a.name, maxToolCallIterations)
|
||||
}
|
||||
|
||||
func (a *Agent) streamLLM(ctx context.Context, content model.ChatContent, out chan<- string, vars map[string]string) error {
|
||||
messages := initialMessages(applyVars(a.instruction, vars), firstText(content))
|
||||
for iter := 0; iter < maxToolCallIterations; iter++ {
|
||||
events, errs := a.chatModel.Stream(ctx, messages)
|
||||
var (
|
||||
finalText strings.Builder
|
||||
toolCalls []ports.ChatToolCall
|
||||
done bool
|
||||
)
|
||||
streamErr := error(nil)
|
||||
streamLoop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
streamErr = ctx.Err()
|
||||
break streamLoop
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
break streamLoop
|
||||
}
|
||||
if ev.Done {
|
||||
done = true
|
||||
}
|
||||
if ev.Delta != "" {
|
||||
finalText.WriteString(ev.Delta)
|
||||
select {
|
||||
case out <- ev.Delta:
|
||||
case <-ctx.Done():
|
||||
streamErr = ctx.Err()
|
||||
break streamLoop
|
||||
}
|
||||
}
|
||||
if len(ev.ToolCalls) > 0 {
|
||||
toolCalls = append(toolCalls, ev.ToolCalls...)
|
||||
}
|
||||
case err, ok := <-errs:
|
||||
if ok && err != nil {
|
||||
streamErr = err
|
||||
}
|
||||
break streamLoop
|
||||
}
|
||||
}
|
||||
if streamErr != nil {
|
||||
return streamErr
|
||||
}
|
||||
if len(toolCalls) == 0 {
|
||||
if !done {
|
||||
return fmt.Errorf("agent %q stream closed without completion", a.name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleAssistant, Content: finalText.String(), ToolCalls: toolCalls})
|
||||
toolMessages, err := a.executeToolCalls(ctx, toolCalls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages = append(messages, toolMessages...)
|
||||
}
|
||||
return fmt.Errorf("agent %q exceeded tool-call iteration limit %d", a.name, maxToolCallIterations)
|
||||
}
|
||||
|
||||
func (a *Agent) executeToolCalls(ctx context.Context, calls []ports.ChatToolCall) ([]ports.ChatMessage, error) {
|
||||
if a.router == nil {
|
||||
return nil, fmt.Errorf("agent %q has no tool router but model requested tool calls", a.name)
|
||||
}
|
||||
out := make([]ports.ChatMessage, 0, len(calls))
|
||||
for _, call := range calls {
|
||||
result, err := a.router.CallTool(ctx, call.Name, call.Arguments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool %q: %w", call.Name, err)
|
||||
}
|
||||
out = append(out, ports.ChatMessage{
|
||||
Role: ports.ChatRoleTool,
|
||||
Content: result,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *Agent) runSequential(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
|
||||
scope := cloneVars(vars)
|
||||
var last string
|
||||
for _, sub := range a.subAgents {
|
||||
impl, ok := sub.(*Agent)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("sub-agent %q is not a runnable agent", sub.Name())
|
||||
}
|
||||
text, err := impl.runWithVars(ctx, content, scope)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
last = text
|
||||
if key := strings.TrimSpace(impl.outputKey); key != "" {
|
||||
scope[key] = text
|
||||
}
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func (a *Agent) runFanOut(ctx context.Context, content model.ChatContent, vars map[string]string) (string, error) {
|
||||
parts := make([]string, 0, len(a.subAgents))
|
||||
for _, sub := range a.subAgents {
|
||||
impl, ok := sub.(*Agent)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("sub-agent %q is not a runnable agent", sub.Name())
|
||||
}
|
||||
text, err := impl.runWithVars(ctx, content, vars)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s] %s", sub.Name(), text))
|
||||
}
|
||||
return strings.Join(parts, "\n"), nil
|
||||
}
|
||||
|
||||
func cloneVars(vars map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(vars)+4)
|
||||
for k, v := range vars {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyVars(template string, vars map[string]string) string {
|
||||
if template == "" || len(vars) == 0 {
|
||||
return template
|
||||
}
|
||||
out := template
|
||||
for k, v := range vars {
|
||||
out = strings.ReplaceAll(out, "{"+k+"}", v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func initialMessages(instruction, userText string) []ports.ChatMessage {
|
||||
messages := make([]ports.ChatMessage, 0, 2)
|
||||
if strings.TrimSpace(instruction) != "" {
|
||||
messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleSystem, Content: instruction})
|
||||
}
|
||||
messages = append(messages, ports.ChatMessage{Role: ports.ChatRoleUser, Content: userText})
|
||||
return messages
|
||||
}
|
||||
|
||||
func (r *Runner) CreateSession(userID string) (string, error) {
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return "", fmt.Errorf("user id is required")
|
||||
}
|
||||
next := r.counter.Add(1)
|
||||
return fmt.Sprintf("%s:%s:%d", r.appName, userID, next), nil
|
||||
}
|
||||
|
||||
func (r *Runner) Run(userID, sessionID string, content model.ChatContent) ([]string, error) {
|
||||
if strings.TrimSpace(userID) == "" || strings.TrimSpace(sessionID) == "" {
|
||||
return nil, fmt.Errorf("user id and session id are required")
|
||||
}
|
||||
if err := r.notifyPlugins(userID, sessionID, content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
impl, ok := r.agent.(*Agent)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("runner agent %q is not runnable", r.agent.Name())
|
||||
}
|
||||
output, err := impl.run(context.Background(), content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
return []string{output}, nil
|
||||
}
|
||||
|
||||
func (r *Runner) Stream(userID, sessionID string, content model.ChatContent) (<-chan string, <-chan error) {
|
||||
outputs := make(chan string, 8)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(outputs)
|
||||
defer close(errs)
|
||||
if strings.TrimSpace(userID) == "" || strings.TrimSpace(sessionID) == "" {
|
||||
errs <- fmt.Errorf("user id and session id are required")
|
||||
return
|
||||
}
|
||||
if err := r.notifyPlugins(userID, sessionID, content); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
impl, ok := r.agent.(*Agent)
|
||||
if !ok {
|
||||
errs <- fmt.Errorf("runner agent %q is not runnable", r.agent.Name())
|
||||
return
|
||||
}
|
||||
if err := impl.stream(context.Background(), content, outputs); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}()
|
||||
return outputs, errs
|
||||
}
|
||||
|
||||
func (r *Runner) notifyPlugins(userID, sessionID string, content model.ChatContent) error {
|
||||
for _, item := range r.plugins {
|
||||
if err := item.OnUserMessage(context.Background(), r.appName, userID, sessionID, r.agent, content); err != nil {
|
||||
return fmt.Errorf("plugin %q on user message: %w", item.Name(), err)
|
||||
}
|
||||
if err := item.BeforeAgent(context.Background(), r.appName, userID, sessionID, r.agent); err != nil {
|
||||
return fmt.Errorf("plugin %q before agent: %w", item.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstText(content model.ChatContent) string {
|
||||
if len(content.Texts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return content.Texts[0].Message
|
||||
}
|
||||
|
||||
func defaultPlugins() map[string]func() (ports.RunnerPlugin, error) {
|
||||
return map[string]func() (ports.RunnerPlugin, error){
|
||||
"myTestPlugin": newMyTestPlugin,
|
||||
"myLogPlugin": func() (ports.RunnerPlugin, error) {
|
||||
p, err := loggingplugin.New("myLogPlugin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adkRunnerPlugin{name: "myLogPlugin", plugin: p}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Factory) resolvePlugins(names []string) ([]ports.RunnerPlugin, error) {
|
||||
if len(names) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
plugins := make([]ports.RunnerPlugin, 0, len(names))
|
||||
for _, name := range names {
|
||||
pluginName := strings.TrimSpace(name)
|
||||
if pluginName == "" {
|
||||
continue
|
||||
}
|
||||
builder, ok := f.plugins[pluginName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("runner plugin %q is not registered", pluginName)
|
||||
}
|
||||
plugin, err := builder()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create runner plugin %q: %w", pluginName, err)
|
||||
}
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
func newMyTestPlugin() (ports.RunnerPlugin, error) {
|
||||
return myTestPlugin{}, nil
|
||||
}
|
||||
|
||||
type myTestPlugin struct{}
|
||||
|
||||
func (myTestPlugin) Name() string {
|
||||
return "myTestPlugin"
|
||||
}
|
||||
|
||||
func (myTestPlugin) OnUserMessage(_ context.Context, _, _, _ string, _ ports.Agent, content model.ChatContent) error {
|
||||
fmt.Printf("[myTestPlugin] 用户输入信息:%s\n", firstText(content))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (myTestPlugin) BeforeAgent(_ context.Context, _, _, _ string, agent ports.Agent) error {
|
||||
fmt.Printf("[myTestPlugin] 智能体名称:%s\n", agent.Name())
|
||||
return nil
|
||||
}
|
||||
|
||||
type adkRunnerPlugin struct {
|
||||
name string
|
||||
plugin *plugin.Plugin
|
||||
}
|
||||
|
||||
func (p adkRunnerPlugin) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p adkRunnerPlugin) OnUserMessage(_ context.Context, _, _, _ string, _ ports.Agent, content model.ChatContent) error {
|
||||
if p.plugin.OnUserMessageCallback() != nil {
|
||||
fmt.Printf("[%s] USER MESSAGE RECEIVED %s\n", p.name, firstText(content))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p adkRunnerPlugin) BeforeAgent(_ context.Context, _, _, _ string, agent ports.Agent) error {
|
||||
if p.plugin.BeforeAgentCallback() != nil {
|
||||
fmt.Printf("[%s] AGENT STARTING %s\n", p.name, agent.Name())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
117
internal/infrastructure/ai/eino_adapter.go
Normal file
117
internal/infrastructure/ai/eino_adapter.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
)
|
||||
|
||||
type EinoProvider struct {
|
||||
requestTimeout time.Duration
|
||||
}
|
||||
|
||||
type EinoAPIConfig struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
CompletionsPath string
|
||||
EmbeddingsPath string
|
||||
}
|
||||
|
||||
type EinoChatModel struct {
|
||||
client *OpenAIClient
|
||||
tools []ports.Tool
|
||||
}
|
||||
|
||||
type EinoTool struct {
|
||||
ToolName string
|
||||
}
|
||||
|
||||
func NewEinoProvider() *EinoProvider {
|
||||
return &EinoProvider{requestTimeout: 5 * time.Minute}
|
||||
}
|
||||
|
||||
func (p *EinoProvider) WithRequestTimeout(timeout time.Duration) *EinoProvider {
|
||||
if timeout > 0 {
|
||||
p.requestTimeout = timeout
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *EinoProvider) NewAPI(_ context.Context, config model.AiAPIConfig) (ports.ModelAPI, error) {
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("base url is required")
|
||||
}
|
||||
if strings.TrimSpace(config.APIKey) == "" {
|
||||
return nil, fmt.Errorf("api key is required")
|
||||
}
|
||||
return EinoAPIConfig{
|
||||
BaseURL: config.BaseURL,
|
||||
APIKey: config.APIKey,
|
||||
CompletionsPath: config.CompletionsPath,
|
||||
EmbeddingsPath: config.EmbeddingsPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *EinoProvider) NewChatModel(_ context.Context, api ports.ModelAPI, config model.ChatModelConfig, tools []ports.Tool) (ports.ChatModel, error) {
|
||||
if api == nil {
|
||||
return nil, fmt.Errorf("model api is required")
|
||||
}
|
||||
if strings.TrimSpace(config.Model) == "" {
|
||||
return nil, fmt.Errorf("model is required")
|
||||
}
|
||||
apiCfg, ok := api.(EinoAPIConfig)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported model api type %T", api)
|
||||
}
|
||||
completionsURL := joinURL(apiCfg.BaseURL, apiCfg.CompletionsPath)
|
||||
timeout := p.requestTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Minute
|
||||
}
|
||||
client := NewOpenAIClient(completionsURL, apiCfg.APIKey, config.Model, timeout)
|
||||
return &EinoChatModel{client: client, tools: tools}, nil
|
||||
}
|
||||
|
||||
func (m *EinoChatModel) Tools() []ports.Tool {
|
||||
return m.tools
|
||||
}
|
||||
|
||||
func (m *EinoChatModel) Generate(ctx context.Context, messages []ports.ChatMessage) (ports.ChatReply, error) {
|
||||
return m.client.Generate(ctx, messages, m.toolDefs())
|
||||
}
|
||||
|
||||
func (m *EinoChatModel) Stream(ctx context.Context, messages []ports.ChatMessage) (<-chan ports.ChatStreamEvent, <-chan error) {
|
||||
return m.client.Stream(ctx, messages, m.toolDefs())
|
||||
}
|
||||
|
||||
func (m *EinoChatModel) toolDefs() []OpenAIToolDef {
|
||||
if len(m.tools) == 0 {
|
||||
return nil
|
||||
}
|
||||
defs := make([]OpenAIToolDef, 0, len(m.tools))
|
||||
for _, tool := range m.tools {
|
||||
desc := ""
|
||||
if d, ok := tool.(ports.ToolDescriptor); ok {
|
||||
desc = d.Description()
|
||||
}
|
||||
defs = append(defs, OpenAIToolDef{Name: tool.Name(), Description: desc})
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func (t EinoTool) Name() string {
|
||||
return t.ToolName
|
||||
}
|
||||
|
||||
func joinURL(base, path string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
path = strings.TrimLeft(path, "/")
|
||||
if path == "" {
|
||||
return base
|
||||
}
|
||||
return base + "/" + path
|
||||
}
|
||||
509
internal/infrastructure/ai/mcp_sse_client.go
Normal file
509
internal/infrastructure/ai/mcp_sse_client.go
Normal file
@@ -0,0 +1,509 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
mcpProtocolVersion = "2024-11-05"
|
||||
mcpClientName = "ai-agent-scaffold-go"
|
||||
mcpClientVersion = "0.1.0"
|
||||
)
|
||||
|
||||
type MCPSSEClient struct {
|
||||
baseURI string
|
||||
endpoint string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
postURL string
|
||||
cancelFn context.CancelFunc
|
||||
pending map[uint64]chan json.RawMessage
|
||||
nextID atomic.Uint64
|
||||
streamErr chan error
|
||||
endpointSig chan struct{}
|
||||
}
|
||||
|
||||
func NewMCPSSEClient(baseURI, endpoint string, requestTimeoutMillis int) *MCPSSEClient {
|
||||
timeout := time.Duration(requestTimeoutMillis) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 120 * time.Second
|
||||
}
|
||||
return &MCPSSEClient{
|
||||
baseURI: strings.TrimRight(baseURI, "/"),
|
||||
endpoint: endpoint,
|
||||
httpClient: &http.Client{Timeout: 0},
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) ensureStarted(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
if c.started {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.pending = make(map[uint64]chan json.RawMessage)
|
||||
c.endpointSig = make(chan struct{})
|
||||
c.streamErr = make(chan error, 1)
|
||||
streamCtx, cancel := context.WithCancel(context.Background())
|
||||
c.cancelFn = cancel
|
||||
c.started = true
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.openSSE(streamCtx); err != nil {
|
||||
c.shutdown()
|
||||
return err
|
||||
}
|
||||
|
||||
waitCtx, waitCancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer waitCancel()
|
||||
select {
|
||||
case <-c.endpointSig:
|
||||
case err := <-c.streamErr:
|
||||
c.shutdown()
|
||||
return err
|
||||
case <-waitCtx.Done():
|
||||
c.shutdown()
|
||||
return fmt.Errorf("mcp sse endpoint event timeout: %w", waitCtx.Err())
|
||||
}
|
||||
|
||||
if err := c.initialize(ctx); err != nil {
|
||||
c.shutdown()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) openSSE(ctx context.Context) error {
|
||||
target := c.baseURI + c.endpoint
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp sse build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp sse open: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return fmt.Errorf("mcp sse status %d: %s", resp.StatusCode, truncate(string(raw), 200))
|
||||
}
|
||||
go c.readLoop(resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) readLoop(resp *http.Response) {
|
||||
defer resp.Body.Close()
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var event string
|
||||
var dataBuf strings.Builder
|
||||
dispatch := func() {
|
||||
defer func() {
|
||||
event = ""
|
||||
dataBuf.Reset()
|
||||
}()
|
||||
data := dataBuf.String()
|
||||
if data == "" {
|
||||
return
|
||||
}
|
||||
switch event {
|
||||
case "endpoint", "":
|
||||
c.handleEndpoint(data, event)
|
||||
case "message":
|
||||
c.handleMessage(data)
|
||||
}
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
c.signalStreamErr(fmt.Errorf("mcp sse read: %w", err))
|
||||
} else {
|
||||
c.signalStreamErr(fmt.Errorf("mcp sse stream closed"))
|
||||
}
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
dispatch()
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "event:"):
|
||||
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
if dataBuf.Len() > 0 {
|
||||
dataBuf.WriteByte('\n')
|
||||
}
|
||||
dataBuf.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) handleEndpoint(data, eventName string) {
|
||||
if c.postURLAlreadySet() {
|
||||
if eventName == "" {
|
||||
c.handleMessage(data)
|
||||
}
|
||||
return
|
||||
}
|
||||
target := c.resolvePostURL(data)
|
||||
c.mu.Lock()
|
||||
if c.postURL == "" {
|
||||
c.postURL = target
|
||||
close(c.endpointSig)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) postURLAlreadySet() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.postURL != ""
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) resolvePostURL(raw string) string {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || !parsed.IsAbs() {
|
||||
base, baseErr := url.Parse(c.baseURI)
|
||||
if baseErr == nil {
|
||||
ref, refErr := url.Parse(raw)
|
||||
if refErr == nil {
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) handleMessage(data string) {
|
||||
var resp struct {
|
||||
ID json.Number `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||||
return
|
||||
}
|
||||
if resp.ID == "" {
|
||||
return
|
||||
}
|
||||
id, err := resp.ID.Int64()
|
||||
if err != nil || id <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
ch, ok := c.pending[uint64(id)]
|
||||
if ok {
|
||||
delete(c.pending, uint64(id))
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if resp.Error != nil {
|
||||
ch <- mustJSON(map[string]any{"__error__": resp.Error.Message, "code": resp.Error.Code})
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
ch <- resp.Result
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
raw, _ := json.Marshal(v)
|
||||
return raw
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) signalStreamErr(err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
select {
|
||||
case c.streamErr <- err:
|
||||
default:
|
||||
}
|
||||
for id, ch := range c.pending {
|
||||
ch <- mustJSON(map[string]any{"__error__": err.Error()})
|
||||
close(ch)
|
||||
delete(c.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) shutdown() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.cancelFn != nil {
|
||||
c.cancelFn()
|
||||
}
|
||||
c.started = false
|
||||
c.postURL = ""
|
||||
c.pending = nil
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) initialize(ctx context.Context) error {
|
||||
_, err := c.callRPC(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": mcpProtocolVersion,
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{
|
||||
"name": mcpClientName,
|
||||
"version": mcpClientVersion,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp initialize: %w", err)
|
||||
}
|
||||
if err := c.notify(ctx, "notifications/initialized", map[string]any{}); err != nil {
|
||||
return fmt.Errorf("mcp notifications/initialized: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
args := map[string]any{}
|
||||
trimmed := strings.TrimSpace(arguments)
|
||||
if trimmed != "" {
|
||||
if err := json.Unmarshal([]byte(trimmed), &args); err != nil {
|
||||
return "", fmt.Errorf("mcp tool %q arguments not valid json: %w", name, err)
|
||||
}
|
||||
}
|
||||
result, err := c.callRPC(ctx, "tools/call", map[string]any{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mcp tools/call %q: %w", name, err)
|
||||
}
|
||||
return extractToolText(result), nil
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) ListTools(ctx context.Context) ([]string, error) {
|
||||
if err := c.ensureStarted(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := c.callRPC(ctx, "tools/list", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var parsed struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(result, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(parsed.Tools))
|
||||
for _, t := range parsed.Tools {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
type toolCallResult struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
|
||||
func extractToolText(raw json.RawMessage) string {
|
||||
var parsed toolCallResult
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
parts := make([]string, 0, len(parsed.Content))
|
||||
for _, item := range parsed.Content {
|
||||
if item.Type == "text" && item.Text != "" {
|
||||
parts = append(parts, item.Text)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return string(raw)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) callRPC(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
id := c.nextID.Add(1)
|
||||
ch := make(chan json.RawMessage, 1)
|
||||
c.mu.Lock()
|
||||
postURL := c.postURL
|
||||
c.pending[id] = ch
|
||||
c.mu.Unlock()
|
||||
if postURL == "" {
|
||||
return nil, fmt.Errorf("mcp post url is not set")
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp marshal request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, postURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp build post: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp post: %w", err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("mcp post status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
waitCtx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
select {
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("mcp call %s closed unexpectedly", method)
|
||||
}
|
||||
var probe struct {
|
||||
Err string `json:"__error__"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &probe); err == nil && probe.Err != "" {
|
||||
return nil, fmt.Errorf("%s", probe.Err)
|
||||
}
|
||||
return msg, nil
|
||||
case <-waitCtx.Done():
|
||||
return nil, fmt.Errorf("mcp call %s timeout: %w", method, waitCtx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MCPSSEClient) notify(ctx context.Context, method string, params any) error {
|
||||
c.mu.Lock()
|
||||
postURL := c.postURL
|
||||
c.mu.Unlock()
|
||||
if postURL == "" {
|
||||
return fmt.Errorf("mcp post url is not set")
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, postURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
type MCPToolRouter struct {
|
||||
clients map[string]*MCPSSEClient
|
||||
tools []ports.Tool
|
||||
registered map[string]struct{}
|
||||
listTimeout time.Duration
|
||||
listFailures map[string]error
|
||||
}
|
||||
|
||||
func NewMCPToolRouter() *MCPToolRouter {
|
||||
return &MCPToolRouter{
|
||||
clients: make(map[string]*MCPSSEClient),
|
||||
registered: make(map[string]struct{}),
|
||||
listTimeout: 10 * time.Second,
|
||||
listFailures: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MCPToolRouter) Register(tool ports.Tool) {
|
||||
r.RegisterAndExpand(tool)
|
||||
}
|
||||
|
||||
func (r *MCPToolRouter) RegisterAndExpand(tool ports.Tool) []ports.Tool {
|
||||
mcp, ok := tool.(MCPTool)
|
||||
if !ok {
|
||||
r.tools = append(r.tools, tool)
|
||||
return []ports.Tool{tool}
|
||||
}
|
||||
if mcp.TransportType != "sse" {
|
||||
r.tools = append(r.tools, mcp)
|
||||
return []ports.Tool{mcp}
|
||||
}
|
||||
if _, exists := r.clients[mcp.ToolName]; exists {
|
||||
return nil
|
||||
}
|
||||
client := NewMCPSSEClient(mcp.BaseURI, mcp.SSEEndpoint, mcp.RequestTimeout)
|
||||
r.clients[mcp.ToolName] = client
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.listTimeout)
|
||||
defer cancel()
|
||||
names, err := client.ListTools(ctx)
|
||||
if err != nil {
|
||||
r.listFailures[mcp.ToolName] = err
|
||||
r.tools = append(r.tools, mcp)
|
||||
return []ports.Tool{mcp}
|
||||
}
|
||||
expanded := make([]ports.Tool, 0, len(names))
|
||||
for _, n := range names {
|
||||
r.clients[n] = client
|
||||
r.registered[n] = struct{}{}
|
||||
t := EinoTool{ToolName: n}
|
||||
r.tools = append(r.tools, t)
|
||||
expanded = append(expanded, t)
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
func (r *MCPToolRouter) Tools() []ports.Tool {
|
||||
return r.tools
|
||||
}
|
||||
|
||||
func (r *MCPToolRouter) CallTool(ctx context.Context, name, arguments string) (string, error) {
|
||||
if client, ok := r.clients[name]; ok {
|
||||
return client.CallTool(ctx, name, arguments)
|
||||
}
|
||||
for _, tool := range r.tools {
|
||||
if tool.Name() != name {
|
||||
continue
|
||||
}
|
||||
switch tool.(type) {
|
||||
case MCPTool:
|
||||
return "", fmt.Errorf("mcp tool %q transport not supported in runtime", name)
|
||||
default:
|
||||
return "", fmt.Errorf("tool %q is not callable in current runtime", name)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("tool %q is not registered", name)
|
||||
}
|
||||
319
internal/infrastructure/ai/openai_client.go
Normal file
319
internal/infrastructure/ai/openai_client.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
)
|
||||
|
||||
type OpenAIClient struct {
|
||||
httpClient *http.Client
|
||||
completionsURL string
|
||||
apiKey string
|
||||
model string
|
||||
}
|
||||
|
||||
type OpenAIToolDef struct {
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
func NewOpenAIClient(completionsURL, apiKey, model string, requestTimeout time.Duration) *OpenAIClient {
|
||||
if requestTimeout <= 0 {
|
||||
requestTimeout = 5 * time.Minute
|
||||
}
|
||||
return &OpenAIClient{
|
||||
httpClient: &http.Client{Timeout: requestTimeout},
|
||||
completionsURL: completionsURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OpenAIClient) Generate(ctx context.Context, messages []ports.ChatMessage, tools []OpenAIToolDef) (ports.ChatReply, error) {
|
||||
body, err := buildRequestBody(c.model, messages, tools, false)
|
||||
if err != nil {
|
||||
return ports.ChatReply{}, err
|
||||
}
|
||||
resp, err := c.do(ctx, body)
|
||||
if err != nil {
|
||||
return ports.ChatReply{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ports.ChatReply{}, fmt.Errorf("openai read body: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return ports.ChatReply{}, fmt.Errorf("openai upstream %d: %s", resp.StatusCode, truncate(string(raw), 400))
|
||||
}
|
||||
|
||||
var parsed openaiCompletion
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return ports.ChatReply{}, fmt.Errorf("openai decode: %w", err)
|
||||
}
|
||||
if len(parsed.Choices) == 0 {
|
||||
return ports.ChatReply{}, fmt.Errorf("openai response has no choices")
|
||||
}
|
||||
choice := parsed.Choices[0].Message
|
||||
reply := ports.ChatReply{Content: choice.Content}
|
||||
for _, tc := range choice.ToolCalls {
|
||||
reply.ToolCalls = append(reply.ToolCalls, ports.ChatToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
})
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (c *OpenAIClient) Stream(ctx context.Context, messages []ports.ChatMessage, tools []OpenAIToolDef) (<-chan ports.ChatStreamEvent, <-chan error) {
|
||||
events := make(chan ports.ChatStreamEvent, 8)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(events)
|
||||
defer close(errs)
|
||||
|
||||
body, err := buildRequestBody(c.model, messages, tools, true)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
resp, err := c.do(ctx, body)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
errs <- fmt.Errorf("openai upstream %d: %s", resp.StatusCode, truncate(string(raw), 400))
|
||||
return
|
||||
}
|
||||
|
||||
toolCallBuf := map[int]*ports.ChatToolCall{}
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
emitToolCalls(events, toolCallBuf)
|
||||
events <- ports.ChatStreamEvent{Done: true}
|
||||
return
|
||||
}
|
||||
errs <- fmt.Errorf("openai stream read: %w", err)
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" || !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "[DONE]" {
|
||||
emitToolCalls(events, toolCallBuf)
|
||||
events <- ports.ChatStreamEvent{Done: true}
|
||||
return
|
||||
}
|
||||
var chunk openaiStreamChunk
|
||||
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||
errs <- fmt.Errorf("openai stream decode: %w", err)
|
||||
return
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
delta := chunk.Choices[0].Delta
|
||||
if delta.Content != "" {
|
||||
select {
|
||||
case events <- ports.ChatStreamEvent{Delta: delta.Content}:
|
||||
case <-ctx.Done():
|
||||
errs <- ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, tc := range delta.ToolCalls {
|
||||
current, ok := toolCallBuf[tc.Index]
|
||||
if !ok {
|
||||
current = &ports.ChatToolCall{}
|
||||
toolCallBuf[tc.Index] = current
|
||||
}
|
||||
if tc.ID != "" {
|
||||
current.ID = tc.ID
|
||||
}
|
||||
if tc.Function.Name != "" {
|
||||
current.Name = tc.Function.Name
|
||||
}
|
||||
if tc.Function.Arguments != "" {
|
||||
current.Arguments += tc.Function.Arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return events, errs
|
||||
}
|
||||
|
||||
func emitToolCalls(events chan<- ports.ChatStreamEvent, buf map[int]*ports.ChatToolCall) {
|
||||
if len(buf) == 0 {
|
||||
return
|
||||
}
|
||||
calls := make([]ports.ChatToolCall, 0, len(buf))
|
||||
for i := 0; i < len(buf); i++ {
|
||||
if call, ok := buf[i]; ok && call != nil {
|
||||
calls = append(calls, *call)
|
||||
}
|
||||
}
|
||||
if len(calls) > 0 {
|
||||
events <- ports.ChatStreamEvent{ToolCalls: calls}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OpenAIClient) do(ctx context.Context, body []byte) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.completionsURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openai build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openai http: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func buildRequestBody(modelName string, messages []ports.ChatMessage, tools []OpenAIToolDef, stream bool) ([]byte, error) {
|
||||
payload := map[string]any{
|
||||
"model": modelName,
|
||||
"messages": encodeMessages(messages),
|
||||
"stream": stream,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
payload["tools"] = encodeTools(tools)
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("openai marshal request: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func encodeMessages(messages []ports.ChatMessage) []map[string]any {
|
||||
encoded := make([]map[string]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
entry := map[string]any{"role": string(m.Role)}
|
||||
if m.Content != "" {
|
||||
entry["content"] = m.Content
|
||||
} else if m.Role != ports.ChatRoleAssistant || len(m.ToolCalls) == 0 {
|
||||
entry["content"] = ""
|
||||
}
|
||||
if m.Name != "" {
|
||||
entry["name"] = m.Name
|
||||
}
|
||||
if m.ToolCallID != "" {
|
||||
entry["tool_call_id"] = m.ToolCallID
|
||||
}
|
||||
if len(m.ToolCalls) > 0 {
|
||||
calls := make([]map[string]any, 0, len(m.ToolCalls))
|
||||
for _, c := range m.ToolCalls {
|
||||
calls = append(calls, map[string]any{
|
||||
"id": c.ID,
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": c.Name,
|
||||
"arguments": c.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
entry["tool_calls"] = calls
|
||||
}
|
||||
encoded = append(encoded, entry)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func encodeTools(tools []OpenAIToolDef) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": t.Name,
|
||||
"description": fallbackDescription(t),
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "自由文本输入或检索查询,工具会按其语义解释",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fallbackDescription(t OpenAIToolDef) string {
|
||||
if strings.TrimSpace(t.Description) != "" {
|
||||
return t.Description
|
||||
}
|
||||
return "外部工具 " + t.Name + ",参数 query 为自由文本"
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
|
||||
type openaiCompletion struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []openaiToolCallV1 `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
type openaiStreamChunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []openaiStreamToolCall `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
type openaiToolCallV1 struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
|
||||
type openaiStreamToolCall struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
}
|
||||
508
internal/infrastructure/ai/tools.go
Normal file
508
internal/infrastructure/ai/tools.go
Normal file
@@ -0,0 +1,508 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ai-agent-scaffold-go/internal/domain/agent/model"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/ports"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ToolFactory struct {
|
||||
router *MCPToolRouter
|
||||
}
|
||||
|
||||
type SkillFactory struct{}
|
||||
|
||||
type MCPTool struct {
|
||||
ToolName string
|
||||
TransportType string
|
||||
BaseURI string
|
||||
SSEEndpoint string
|
||||
Command string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
RequestTimeout int
|
||||
}
|
||||
|
||||
type SkillTool struct {
|
||||
ToolName string
|
||||
SkillName string
|
||||
Description string
|
||||
Path string
|
||||
ManifestPath string
|
||||
}
|
||||
|
||||
func NewToolFactory(router *MCPToolRouter) *ToolFactory {
|
||||
return &ToolFactory{router: router}
|
||||
}
|
||||
|
||||
func NewSkillFactory() *SkillFactory {
|
||||
return &SkillFactory{}
|
||||
}
|
||||
|
||||
func (f *ToolFactory) BuildTools(_ context.Context, config model.ToolMCPConfig) ([]ports.Tool, error) {
|
||||
kind, err := validateMCPConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
tools []ports.Tool
|
||||
)
|
||||
switch {
|
||||
case kind == "local":
|
||||
tools, err = buildLocalMCPTools(config.Local)
|
||||
case kind == "sse":
|
||||
tools, err = buildSSEMCPTools(config.SSE)
|
||||
case kind == "stdio":
|
||||
tools, err = buildStdioMCPTools(config.Stdio)
|
||||
case config.SSE != nil:
|
||||
tools, err = buildSSEMCPTools(config.SSE)
|
||||
case config.Stdio != nil:
|
||||
tools, err = buildStdioMCPTools(config.Stdio)
|
||||
default:
|
||||
return nil, fmt.Errorf("mcp tool config is empty")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.router != nil {
|
||||
expanded := make([]ports.Tool, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
ts := f.router.RegisterAndExpand(t)
|
||||
if len(ts) == 0 {
|
||||
continue
|
||||
}
|
||||
expanded = append(expanded, ts...)
|
||||
}
|
||||
if len(expanded) > 0 {
|
||||
return expanded, nil
|
||||
}
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
func (t MCPTool) Name() string {
|
||||
return t.ToolName
|
||||
}
|
||||
|
||||
func (f *SkillFactory) BuildTools(_ context.Context, config model.ToolSkillsConfig) ([]ports.Tool, error) {
|
||||
skillType := strings.TrimSpace(config.Type)
|
||||
if skillType == "" {
|
||||
skillType = "directory"
|
||||
}
|
||||
if skillType != "directory" && skillType != "resource" {
|
||||
return nil, fmt.Errorf("unsupported skill type %q", config.Type)
|
||||
}
|
||||
|
||||
rawPath := strings.TrimSpace(config.Path)
|
||||
if rawPath == "" {
|
||||
return nil, fmt.Errorf("skill path is required")
|
||||
}
|
||||
|
||||
root, err := resolveSkillRoot(skillType, rawPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifests, err := findSkillManifests(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(manifests) == 0 {
|
||||
return nil, fmt.Errorf("skill path %q has no SKILL.md files", root)
|
||||
}
|
||||
|
||||
tools := make([]ports.Tool, 0, len(manifests))
|
||||
for _, manifest := range manifests {
|
||||
tool, err := loadSkillTool(root, manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
func (t SkillTool) Name() string {
|
||||
return t.ToolName
|
||||
}
|
||||
|
||||
func resolveSkillRoot(skillType, rawPath string) (string, error) {
|
||||
if filepath.IsAbs(rawPath) {
|
||||
return existingPath(rawPath, rawPath)
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve skill path %q: %w", rawPath, err)
|
||||
}
|
||||
|
||||
candidates := skillPathCandidates(cwd, skillType, rawPath)
|
||||
for _, candidate := range candidates {
|
||||
if path, err := existingPath(candidate, rawPath); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("skill path %q cannot be resolved", rawPath)
|
||||
}
|
||||
|
||||
func skillPathCandidates(cwd, skillType, rawPath string) []string {
|
||||
var candidates []string
|
||||
add := func(path string) {
|
||||
clean := filepath.Clean(path)
|
||||
for _, candidate := range candidates {
|
||||
if candidate == clean {
|
||||
return
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, clean)
|
||||
}
|
||||
|
||||
for dir := cwd; ; dir = filepath.Dir(dir) {
|
||||
add(filepath.Join(dir, rawPath))
|
||||
add(filepath.Join(dir, "configs", rawPath))
|
||||
add(filepath.Join(dir, "ai-agent-scaffold-go", rawPath))
|
||||
add(filepath.Join(dir, "ai-agent-scaffold-go", "configs", rawPath))
|
||||
if skillType == "resource" && strings.HasPrefix(rawPath, "agent"+string(filepath.Separator)) {
|
||||
add(filepath.Join(dir, "configs", rawPath))
|
||||
add(filepath.Join(dir, "ai-agent-scaffold-go", "configs", rawPath))
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func existingPath(candidate, original string) (string, error) {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("skill path %q cannot be resolved", original)
|
||||
}
|
||||
if !info.IsDir() && filepath.Base(candidate) != "SKILL.md" {
|
||||
return "", fmt.Errorf("skill path %q is not a directory or SKILL.md file", candidate)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
func findSkillManifests(root string) ([]string, error) {
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return []string{root}, nil
|
||||
}
|
||||
|
||||
var manifests []string
|
||||
err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if entry.Name() == "SKILL.md" {
|
||||
manifests = append(manifests, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan skill path %q: %w", root, err)
|
||||
}
|
||||
sort.Strings(manifests)
|
||||
return manifests, nil
|
||||
}
|
||||
|
||||
func loadSkillTool(root, manifest string) (SkillTool, error) {
|
||||
data, err := os.ReadFile(manifest)
|
||||
if err != nil {
|
||||
return SkillTool{}, fmt.Errorf("read skill manifest %q: %w", manifest, err)
|
||||
}
|
||||
meta, err := parseSkillFrontMatter(string(data))
|
||||
if err != nil {
|
||||
return SkillTool{}, fmt.Errorf("parse skill manifest %q: %w", manifest, err)
|
||||
}
|
||||
|
||||
skillDir := filepath.Dir(manifest)
|
||||
name := strings.TrimSpace(meta["name"])
|
||||
if name == "" {
|
||||
name = filepath.Base(skillDir)
|
||||
}
|
||||
description := strings.TrimSpace(meta["description"])
|
||||
|
||||
return SkillTool{
|
||||
ToolName: "skill_" + sanitizeToolName(name),
|
||||
SkillName: name,
|
||||
Description: description,
|
||||
Path: skillDir,
|
||||
ManifestPath: manifest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sanitizeToolName(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
out = "tool"
|
||||
}
|
||||
if len(out) > 64 {
|
||||
out = out[:64]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseSkillFrontMatter(content string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(content, "---")
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
return result, fmt.Errorf("front matter terminator is required")
|
||||
}
|
||||
|
||||
frontMatter := rest[:end]
|
||||
if err := yaml.Unmarshal([]byte(frontMatter), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateMCPConfig(config model.ToolMCPConfig) (string, error) {
|
||||
count := 0
|
||||
kind := ""
|
||||
if config.Local != nil {
|
||||
count++
|
||||
kind = "local"
|
||||
}
|
||||
if config.SSE != nil {
|
||||
count++
|
||||
kind = "sse"
|
||||
}
|
||||
if config.Stdio != nil {
|
||||
count++
|
||||
kind = "stdio"
|
||||
}
|
||||
|
||||
switch count {
|
||||
case 0:
|
||||
return "", fmt.Errorf("mcp config must define exactly one of local, sse, or stdio")
|
||||
case 1:
|
||||
return kind, nil
|
||||
default:
|
||||
return "", fmt.Errorf("mcp config cannot define multiple transports in one entry")
|
||||
}
|
||||
}
|
||||
|
||||
func buildLocalMCPTools(config *model.LocalToolParameters) ([]ports.Tool, error) {
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("local mcp tool name is required")
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "echoLocalTool":
|
||||
return []ports.Tool{MCPTool{ToolName: name, TransportType: "local"}}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("local mcp tool %q is not registered in go", name)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSSEMCPTools(config *model.SSEServerParameters) ([]ports.Tool, error) {
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("sse mcp tool name is required")
|
||||
}
|
||||
baseURI := strings.TrimSpace(config.BaseURI)
|
||||
if baseURI == "" {
|
||||
return nil, fmt.Errorf("sse mcp tool %q base-uri is required", name)
|
||||
}
|
||||
|
||||
normalizedBaseURI, endpoint, err := normalizeSSETarget(baseURI, strings.TrimSpace(config.SSEEndpoint))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sse mcp tool %q: %w", name, err)
|
||||
}
|
||||
timeout := normalizeTimeout(config.RequestTimeout)
|
||||
|
||||
return []ports.Tool{MCPTool{
|
||||
ToolName: name,
|
||||
TransportType: "sse",
|
||||
BaseURI: normalizedBaseURI,
|
||||
SSEEndpoint: endpoint,
|
||||
RequestTimeout: timeout,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func buildStdioMCPTools(config *model.StdioServerParameters) ([]ports.Tool, error) {
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("stdio mcp tool name is required")
|
||||
}
|
||||
command := strings.TrimSpace(config.ServerParameters.Command)
|
||||
if command == "" {
|
||||
return nil, fmt.Errorf("stdio mcp tool %q command is required", name)
|
||||
}
|
||||
timeout := normalizeTimeout(config.RequestTimeout)
|
||||
|
||||
return []ports.Tool{MCPTool{
|
||||
ToolName: name,
|
||||
TransportType: "stdio",
|
||||
Command: command,
|
||||
Args: append([]string(nil), config.ServerParameters.Args...),
|
||||
Env: cloneEnv(config.ServerParameters.Env),
|
||||
RequestTimeout: timeout,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func normalizeSSETarget(baseURI, endpoint string) (string, string, error) {
|
||||
parsed, err := url.Parse(baseURI)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid base-uri: %w", err)
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", "", fmt.Errorf("base-uri must include scheme and host")
|
||||
}
|
||||
|
||||
host := strings.TrimRight(parsed.Scheme+"://"+parsed.Host, "/")
|
||||
basePath := parsed.RawPath
|
||||
if basePath == "" {
|
||||
basePath = parsed.EscapedPath()
|
||||
}
|
||||
baseQuery := parsed.RawQuery
|
||||
|
||||
if endpoint == "" {
|
||||
if basePath == "" || basePath == "/" {
|
||||
return host, "/sse", nil
|
||||
}
|
||||
merged := basePath
|
||||
if baseQuery != "" {
|
||||
merged += "?" + baseQuery
|
||||
}
|
||||
return host, normalizeEndpoint(merged), nil
|
||||
}
|
||||
|
||||
endpointPath, endpointQuery := splitPathQuery(endpoint)
|
||||
mergedPath := joinPaths(basePath, endpointPath)
|
||||
mergedQuery := mergeQueries(baseQuery, endpointQuery)
|
||||
if mergedQuery != "" {
|
||||
mergedPath += "?" + mergedQuery
|
||||
}
|
||||
return host, normalizeEndpoint(mergedPath), nil
|
||||
}
|
||||
|
||||
func splitPathQuery(raw string) (string, string) {
|
||||
if idx := strings.Index(raw, "?"); idx >= 0 {
|
||||
return raw[:idx], raw[idx+1:]
|
||||
}
|
||||
return raw, ""
|
||||
}
|
||||
|
||||
func joinPaths(base, extra string) string {
|
||||
if extra == "" {
|
||||
if base == "" {
|
||||
return "/"
|
||||
}
|
||||
return base
|
||||
}
|
||||
if strings.HasPrefix(extra, "/") {
|
||||
return extra
|
||||
}
|
||||
if base == "" {
|
||||
return "/" + extra
|
||||
}
|
||||
if strings.HasSuffix(base, "/") {
|
||||
return base + extra
|
||||
}
|
||||
return base + "/" + extra
|
||||
}
|
||||
|
||||
func mergeQueries(base, extra string) string {
|
||||
switch {
|
||||
case base == "" && extra == "":
|
||||
return ""
|
||||
case base == "":
|
||||
return extra
|
||||
case extra == "":
|
||||
return base
|
||||
default:
|
||||
return base + "&" + extra
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEndpoint(endpoint string) string {
|
||||
trimmed := strings.TrimSpace(endpoint)
|
||||
if trimmed == "" {
|
||||
return "/sse"
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
return trimmed
|
||||
}
|
||||
return "/" + trimmed
|
||||
}
|
||||
|
||||
func normalizeTimeout(timeout int) int {
|
||||
if timeout > 0 {
|
||||
return timeout
|
||||
}
|
||||
return 300000
|
||||
}
|
||||
|
||||
func cloneEnv(values map[string]string) map[string]string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
cloned[key] = value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func maskSecretPath(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return raw
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
query := parsed.Query()
|
||||
changed := false
|
||||
for key := range query {
|
||||
upper := strings.ToUpper(key)
|
||||
if strings.Contains(upper, "KEY") || strings.Contains(upper, "TOKEN") || strings.Contains(upper, "SECRET") {
|
||||
query.Set(key, "${"+strings.ToUpper(strings.ReplaceAll(key, "-", "_"))+"}")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func mcpTimeoutString(timeout int) string {
|
||||
return strconv.Itoa(normalizeTimeout(timeout))
|
||||
}
|
||||
35
internal/infrastructure/cache/redis.go
vendored
Normal file
35
internal/infrastructure/cache/redis.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type RedisConfig struct {
|
||||
Required bool
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
func OpenRedis(config RedisConfig) (*redis.Client, error) {
|
||||
if strings.TrimSpace(config.Addr) == "" {
|
||||
if config.Required {
|
||||
return nil, fmt.Errorf("redis addr is required")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: config.Addr,
|
||||
Password: config.Password,
|
||||
DB: config.DB,
|
||||
})
|
||||
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("ping redis: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
20
internal/infrastructure/dependencies/dependencies.go
Normal file
20
internal/infrastructure/dependencies/dependencies.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Package dependencies anchors verified runtime dependencies while adapters are added.
|
||||
package dependencies
|
||||
|
||||
import (
|
||||
_ "github.com/cloudwego/eino/adk"
|
||||
_ "github.com/cloudwego/eino/components/model"
|
||||
_ "github.com/cloudwego/eino/components/tool"
|
||||
_ "github.com/gin-gonic/gin"
|
||||
_ "github.com/redis/go-redis/v9"
|
||||
_ "go.uber.org/zap"
|
||||
_ "google.golang.org/adk/agent"
|
||||
_ "google.golang.org/adk/agent/llmagent"
|
||||
_ "google.golang.org/adk/agent/workflowagents/loopagent"
|
||||
_ "google.golang.org/adk/agent/workflowagents/parallelagent"
|
||||
_ "google.golang.org/adk/agent/workflowagents/sequentialagent"
|
||||
_ "google.golang.org/adk/runner"
|
||||
_ "gopkg.in/yaml.v3"
|
||||
_ "gorm.io/driver/mysql"
|
||||
_ "gorm.io/gorm"
|
||||
)
|
||||
2
internal/infrastructure/doc.go
Normal file
2
internal/infrastructure/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package infrastructure contains adapters for persistence, cache, AI, and runtime providers.
|
||||
package infrastructure
|
||||
10
internal/infrastructure/logging/logger.go
Normal file
10
internal/infrastructure/logging/logger.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package logging
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
func New(env string) (*zap.Logger, error) {
|
||||
if env == "prod" || env == "production" {
|
||||
return zap.NewProduction()
|
||||
}
|
||||
return zap.NewDevelopment()
|
||||
}
|
||||
35
internal/infrastructure/persistence/mysql.go
Normal file
35
internal/infrastructure/persistence/mysql.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MySQLConfig struct {
|
||||
Required bool
|
||||
DSN string
|
||||
}
|
||||
|
||||
func OpenMySQL(config MySQLConfig) (*gorm.DB, error) {
|
||||
if strings.TrimSpace(config.DSN) == "" {
|
||||
if config.Required {
|
||||
return nil, fmt.Errorf("mysql dsn is required")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
db, err := gorm.Open(mysql.Open(config.DSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mysql: %w", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mysql db handle: %w", err)
|
||||
}
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping mysql: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
2
internal/trigger/doc.go
Normal file
2
internal/trigger/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package trigger contains inbound adapters such as HTTP handlers.
|
||||
package trigger
|
||||
109
internal/trigger/http/agent_handler.go
Normal file
109
internal/trigger/http/agent_handler.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"ai-agent-scaffold-go/internal/api/dto"
|
||||
"ai-agent-scaffold-go/internal/api/response"
|
||||
"ai-agent-scaffold-go/internal/domain/agent/service/chat"
|
||||
"ai-agent-scaffold-go/pkg/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterAgentRoutes(router gin.IRouter, service *chat.Service) {
|
||||
group := router.Group("/api/v1")
|
||||
group.GET("/query_ai_agent_config_list", queryAgentConfigList(service))
|
||||
group.POST("/create_session", createSession(service))
|
||||
group.GET("/create_session", createSessionQuery(service))
|
||||
group.POST("/chat", chatMessage(service))
|
||||
group.POST("/chat_stream", chatStream(service))
|
||||
}
|
||||
|
||||
func queryAgentConfigList(service *chat.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
agents := service.QueryAgentConfigList()
|
||||
responses := make([]dto.AiAgentConfigResponse, 0, len(agents))
|
||||
for _, agent := range agents {
|
||||
responses = append(responses, dto.AiAgentConfigResponse{
|
||||
AgentID: agent.AgentID,
|
||||
AgentName: agent.AgentName,
|
||||
AgentDesc: agent.AgentDesc,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, response.Success(responses))
|
||||
}
|
||||
}
|
||||
|
||||
func createSession(service *chat.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request dto.CreateSessionRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
|
||||
return
|
||||
}
|
||||
sessionID, err := service.CreateSession(request.AgentID, request.UserID)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.Success(dto.CreateSessionResponse{SessionID: sessionID}))
|
||||
}
|
||||
}
|
||||
|
||||
func createSessionQuery(service *chat.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sessionID, err := service.CreateSession(c.Query("agentId"), c.Query("userId"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.Success(dto.CreateSessionResponse{SessionID: sessionID}))
|
||||
}
|
||||
}
|
||||
|
||||
func chatMessage(service *chat.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request dto.ChatRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
|
||||
return
|
||||
}
|
||||
outputs, err := service.HandleMessage(request.AgentID, request.UserID, request.SessionID, request.Message)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.Success(dto.ChatResponse{Content: strings.Join(outputs, "\n")}))
|
||||
}
|
||||
}
|
||||
|
||||
func chatStream(service *chat.Service) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var request dto.ChatRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
writeError(c, types.NewAppError(types.CodeIllegalParameter, err.Error()))
|
||||
return
|
||||
}
|
||||
outputs, errs := service.HandleMessageStream(request.AgentID, request.UserID, request.SessionID, request.Message)
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
for output := range outputs {
|
||||
c.SSEvent("message", output)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
if err, ok := <-errs; ok && err != nil {
|
||||
c.SSEvent("error", err.Error())
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(c *gin.Context, err error) {
|
||||
var appErr *types.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
c.JSON(http.StatusOK, response.Failure(appErr.Code, appErr.Info))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.Failure(types.CodeUnknownError, err.Error()))
|
||||
}
|
||||
Reference in New Issue
Block a user