feat(project): 项目完结
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user