feat(project): 项目完结

This commit is contained in:
peakxy
2026-05-30 23:16:49 +08:00
commit 60e7777cbd
97 changed files with 15027 additions and 0 deletions

2
internal/api/doc.go Normal file
View 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
View 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"`
}

View 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,
}
}

View 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
}

View 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
}

View 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
View File

@@ -0,0 +1,2 @@
// Package app wires configuration, application services, and infrastructure.
package app

View 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)
}

View 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"`
}

View 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
}

View 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)
}

View 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)
}

View 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)
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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]

View File

@@ -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)
}

View 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)
}

View 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 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)
}

View 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 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)
}

View 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
View File

@@ -0,0 +1,2 @@
// Package domain contains Agent models, services, ports, and Armory logic.
package domain

View 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
}

View 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
}

View 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
}

View 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)
}

View 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"`
}

View 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
View 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
}

View 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"
)

View File

@@ -0,0 +1,2 @@
// Package infrastructure contains adapters for persistence, cache, AI, and runtime providers.
package infrastructure

View 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()
}

View 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
View File

@@ -0,0 +1,2 @@
// Package trigger contains inbound adapters such as HTTP handlers.
package trigger

View 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()))
}