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

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
}