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>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
// Package history 是会话短期多轮历史的存储后端(Redis CacheDB)。
|
||||
// 与长期偏好记忆(Postgres)分开:历史是易失的、按会话滚动保留最近 N 轮。
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// maxTurns 是单会话保留的最近消息条数(user/assistant 各算一条)。
|
||||
const maxTurns = 20
|
||||
|
||||
// Turn 是一条历史消息。
|
||||
type Turn struct {
|
||||
Role string `json:"role"` // user / assistant
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Store 封装会话历史读写。rdb 为 nil 表示降级(无 Redis 时历史为空,不阻断)。
|
||||
type Store struct{ rdb *redis.Client }
|
||||
|
||||
// Open 连接 Redis。失败不 fatal:返回降级实例。
|
||||
func Open(addr string) *Store {
|
||||
rdb := redis.NewClient(&redis.Options{Addr: addr})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
log.Printf("[history] redis 不可用,会话历史降级(为空): %v", err)
|
||||
_ = rdb.Close()
|
||||
return &Store{}
|
||||
}
|
||||
log.Println("[history] redis connected")
|
||||
return &Store{rdb: rdb}
|
||||
}
|
||||
|
||||
func key(session string) string { return "sundynix:history:" + session }
|
||||
|
||||
// Get 返回某会话最近的历史(按时间正序)。
|
||||
func (s *Store) Get(ctx context.Context, session string) ([]Turn, error) {
|
||||
if s.rdb == nil || session == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// 列表头插尾老:取全部后反转为正序。
|
||||
vals, err := s.rdb.LRange(ctx, key(session), 0, maxTurns-1).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
turns := make([]Turn, 0, len(vals))
|
||||
for i := len(vals) - 1; i >= 0; i-- { // 反转 → 正序
|
||||
var t Turn
|
||||
if json.Unmarshal([]byte(vals[i]), &t) == nil {
|
||||
turns = append(turns, t)
|
||||
}
|
||||
}
|
||||
return turns, nil
|
||||
}
|
||||
|
||||
// Append 追加一条消息并裁剪到最近 maxTurns 条,刷新 TTL。
|
||||
func (s *Store) Append(ctx context.Context, session, role, content string) error {
|
||||
if s.rdb == nil || session == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(Turn{Role: role, Content: content})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pipe := s.rdb.TxPipeline()
|
||||
pipe.LPush(ctx, key(session), data)
|
||||
pipe.LTrim(ctx, key(session), 0, maxTurns-1)
|
||||
pipe.Expire(ctx, key(session), 24*time.Hour)
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close 释放连接。
|
||||
func (s *Store) Close() {
|
||||
if s.rdb != nil {
|
||||
_ = s.rdb.Close()
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,28 @@ 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
|
||||
bus *sharedbus.Bus
|
||||
search *search.Hybrid
|
||||
memory *memory.Store
|
||||
history *history.Store
|
||||
}
|
||||
|
||||
func NewGateway(b *sharedbus.Bus, s *search.Hybrid, m *memory.Store) *Gateway {
|
||||
return &Gateway{bus: b, search: s, memory: m}
|
||||
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.>,按工具名分发并阻塞。
|
||||
@@ -31,7 +34,7 @@ func (g *Gateway) Serve(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = unsub() }()
|
||||
log.Printf("[mcp_go] tools ready on %s (queue=%s): wiki_search, memory_get, memory_upsert, echo",
|
||||
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()
|
||||
@@ -47,6 +50,10 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra
|
||||
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:
|
||||
@@ -64,6 +71,31 @@ func (g *Gateway) memoryGet(ctx context.Context, call *contract.ToolCall) *contr
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user