Files
sundynix-agentix/sundynix-mcp-go/internal/mcp/gateway.go
T
Blizzard 4928ffc0f7 feat: 短期多轮历史接入 Eino 图 MessagesPlaceholder (⑨)
会话历史(Redis,易失,与长期画像分开)经 MCP 工具进出 Eino 图:
recall 召回历史填 MessagesPlaceholder,写回把本轮 user/assistant 落历史。

- mcp-go: internal/history(go-redis, sundynix:history:<session>, LPUSH+LTRIM 保留近20条,
  24h TTL) + 工具 history_get(返回JSON turns)/history_append; main 开 Redis(降级)
- dispatcher Eino: 模板加 MessagesPlaceholder('history'); recall 调 history_get→转 schema.Message;
  Handle 累积 answer; memorize 异步 history_append(user+assistant)
- shared: contract.MetaSessionID; gateway: SubmitTask 注入 Meta[session_id](X-Session-ID 头,缺省 default)
- demo.sh: 同会话两轮提交,验证第2轮召回第1轮历史
- 验证: 4 模块 build✓ + 3 e2e PASS; live 跑通——轮1=0轮历史→落库, 轮2 history_get 命中→注入

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:18:45 +08:00

134 lines
4.9 KiB
Go

// Package mcp 实现 MCP 协议网关,把工具注册到 NATS 并响应调用。
package mcp
import (
"context"
"encoding/json"
"fmt"
"log"
sharedbus "github.com/sundynix/sundynix-shared/bus"
"github.com/sundynix/sundynix-shared/contract"
"github.com/sundynix/sundynix-mcp-go/internal/history"
"github.com/sundynix/sundynix-mcp-go/internal/memory"
"github.com/sundynix/sundynix-mcp-go/internal/search"
)
// Gateway 暴露 MCP 协议端点,经共享 bus 订阅 sundynix.tools.go.* 响应调用。
type Gateway struct {
bus *sharedbus.Bus
search *search.Hybrid
memory *memory.Store
history *history.Store
}
func NewGateway(b *sharedbus.Bus, s *search.Hybrid, m *memory.Store, h *history.Store) *Gateway {
return &Gateway{bus: b, search: s, memory: m, history: h}
}
// Serve 以队列组通配订阅 sundynix.tools.go.>,按工具名分发并阻塞。
func (g *Gateway) Serve(ctx context.Context) error {
unsub, err := g.bus.ServeTool(contract.SubjectToolsGoAll, contract.QueueToolsGo, g.dispatch)
if err != nil {
return err
}
defer func() { _ = unsub() }()
log.Printf("[mcp_go] tools ready on %s (queue=%s): wiki_search, memory_get, memory_upsert, history_get, history_append, echo",
contract.SubjectToolsGoAll, contract.QueueToolsGo)
<-ctx.Done()
return ctx.Err()
}
// dispatch 按 ToolCall.Tool 路由到具体工具实现。
func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
log.Printf("[mcp_go] tool=%s task=%s args=%v", call.Tool, call.TaskID, call.Args)
switch call.Tool {
case "wiki_search":
return g.wikiSearch(ctx, call)
case "memory_get":
return g.memoryGet(ctx, call)
case "memory_upsert":
return g.memoryUpsert(ctx, call)
case "history_get":
return g.historyGet(ctx, call)
case "history_append":
return g.historyAppend(ctx, call)
case "echo":
return &contract.ToolResult{OK: true, Content: fmt.Sprint(call.Args["text"])}
default:
return &contract.ToolResult{OK: false, Error: "unknown tool: " + call.Tool}
}
}
// memoryGet 召回某用户的常驻画像(已渲染为可注入 prompt 的多行文本)。
func (g *Gateway) memoryGet(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
profile, err := g.memory.Get(ctx, uid)
if err != nil {
return &contract.ToolResult{OK: false, Error: "memory_get: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: profile}
}
// historyGet 召回某会话最近多轮历史,Content 为 JSON 数组 [{role,content},...](正序)。
func (g *Gateway) historyGet(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
session, _ := call.Args["session_id"].(string)
turns, err := g.history.Get(ctx, session)
if err != nil {
return &contract.ToolResult{OK: false, Error: "history_get: " + err.Error()}
}
data, _ := json.Marshal(turns)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// historyAppend 追加一条会话消息(session_id + role + content)。
func (g *Gateway) historyAppend(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
session, _ := call.Args["session_id"].(string)
role, _ := call.Args["role"].(string)
content, _ := call.Args["content"].(string)
if session == "" || role == "" {
return &contract.ToolResult{OK: false, Error: "history_append: session_id 和 role 必填"}
}
if err := g.history.Append(ctx, session, role, content); err != nil {
return &contract.ToolResult{OK: false, Error: "history_append: " + err.Error()}
}
return &contract.ToolResult{OK: true}
}
// memoryUpsert 写入/更新一条画像偏好(user_id + key + value)。
func (g *Gateway) memoryUpsert(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
key, _ := call.Args["key"].(string)
val, _ := call.Args["value"].(string)
if uid == "" || key == "" {
return &contract.ToolResult{OK: false, Error: "memory_upsert: user_id 和 key 必填"}
}
if err := g.memory.Upsert(ctx, uid, key, val); err != nil {
return &contract.ToolResult{OK: false, Error: "memory_upsert: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: fmt.Sprintf("已记住 %s 的「%s」", uid, key)}
}
// wikiSearch 调 Hybrid 混合检索引擎。引擎目前为桩(返回空),
// 这里仍把调用链路做真:真实接入 Bleve/Milvus/Neo4j 后无需改动协议。
func (g *Gateway) wikiSearch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
q, _ := call.Args["q"].(string)
results, err := g.search.Query(ctx, q, 5)
if err != nil {
return &contract.ToolResult{OK: false, Error: "wiki_search: " + err.Error()}
}
return &contract.ToolResult{
OK: true,
Content: fmt.Sprintf("[wiki_search] 命中 %d 条(Bleve+Milvus+Neo4j 混合检索桩)查询=%q", len(results), preview(q)),
}
}
func preview(s string) string {
r := []rune(s)
if len(r) > 40 {
return string(r[:40]) + "…"
}
return s
}