- Introduce a new method to control the maximum number of history messages loaded, prioritizing user-defined options over store settings. - Update the history loading logic to filter out non-semantic message types and convert tool call and action messages into historical summaries for better context. - Refactor the message conversion functions to handle different formats and ensure clarity in the historical context provided to the LLM. - Add comprehensive tests to validate the new behavior and ensure accurate message handling in various scenarios.
432 lines
12 KiB
Go
432 lines
12 KiB
Go
package assistant
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
|
|
jsoniter "github.com/json-iterator/go"
|
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
|
storetypes "github.com/yaoapp/yao/agent/store/types"
|
|
"github.com/yaoapp/yao/trace/types"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Chat History Management
|
|
// =============================================================================
|
|
|
|
// HistoryResult represents the result of history processing
|
|
type HistoryResult struct {
|
|
InputMessages []agentcontext.Message // Clean input messages (without overlap)
|
|
FullMessages []agentcontext.Message // Full messages (history + clean input)
|
|
}
|
|
|
|
// getHistorySize returns the history size with priority: opts.HistorySize > storeSetting.MaxSize > default (20)
|
|
func getHistorySize(opts *agentcontext.Options) int {
|
|
const defaultHistorySize = 20
|
|
if opts != nil && opts.HistorySize > 0 {
|
|
return opts.HistorySize
|
|
}
|
|
if setting := GetStoreSetting(); setting != nil && setting.MaxSize > 0 {
|
|
return setting.MaxSize
|
|
}
|
|
return defaultHistorySize
|
|
}
|
|
|
|
// WithHistory merges the input messages with chat history and traces it
|
|
// Returns HistoryResult containing:
|
|
// - InputMessages: cleaned input (overlap removed)
|
|
// - FullMessages: history + clean input merged
|
|
func (ast *Assistant) WithHistory(ctx *agentcontext.Context, input []agentcontext.Message, agentNode types.Node, options ...*agentcontext.Options) (*HistoryResult, error) {
|
|
|
|
// Get options
|
|
var opts *agentcontext.Options
|
|
if len(options) > 0 && options[0] != nil {
|
|
opts = options[0]
|
|
}
|
|
|
|
// SKIP: History (for internal calls like title/prompt etc.)
|
|
if opts != nil && opts.Skip != nil && opts.Skip.History {
|
|
result := &HistoryResult{
|
|
InputMessages: input,
|
|
FullMessages: input,
|
|
}
|
|
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
|
|
return result, nil
|
|
}
|
|
|
|
// Resolve history size: opts.HistorySize > storeSetting.MaxSize > default (20)
|
|
maxSize := getHistorySize(opts)
|
|
|
|
// Load history from store
|
|
historyMessages, err := ast.loadHistory(ctx, maxSize)
|
|
if err != nil {
|
|
// Log warning but continue without history
|
|
ctx.Logger.Warn("Failed to load history for chat=%s: %v", ctx.ChatID, err)
|
|
result := &HistoryResult{
|
|
InputMessages: input,
|
|
FullMessages: input,
|
|
}
|
|
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
|
|
return result, nil
|
|
}
|
|
|
|
// If no history, return input as is
|
|
if len(historyMessages) == 0 {
|
|
ctx.Logger.HistoryLoad(0, maxSize)
|
|
result := &HistoryResult{
|
|
InputMessages: input,
|
|
FullMessages: input,
|
|
}
|
|
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
|
|
return result, nil
|
|
}
|
|
|
|
// Log history loaded
|
|
ctx.Logger.HistoryLoad(len(historyMessages), maxSize)
|
|
|
|
// Find overlap between history and input
|
|
// Some external clients may include history in their requests
|
|
overlapIndex := ast.findOverlapIndex(historyMessages, input)
|
|
|
|
// Remove overlap from input
|
|
cleanInput := input
|
|
if overlapIndex > 0 {
|
|
cleanInput = input[overlapIndex:]
|
|
ctx.Logger.HistoryOverlap(overlapIndex)
|
|
}
|
|
|
|
// Merge history with clean input
|
|
fullMessages := make([]agentcontext.Message, 0, len(historyMessages)+len(cleanInput))
|
|
fullMessages = append(fullMessages, historyMessages...)
|
|
fullMessages = append(fullMessages, cleanInput...)
|
|
|
|
result := &HistoryResult{
|
|
InputMessages: cleanInput,
|
|
FullMessages: fullMessages,
|
|
}
|
|
|
|
// Log the chat history
|
|
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// loadHistory loads chat history from the store
|
|
// Returns the most recent maxSize messages, ordered by time (oldest first)
|
|
func (ast *Assistant) loadHistory(ctx *agentcontext.Context, maxSize int) ([]agentcontext.Message, error) {
|
|
// Check if chat ID is available
|
|
if ctx.ChatID == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
// Get chat store
|
|
chatStore := GetChatStore()
|
|
if chatStore == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Load messages from store with limit
|
|
filter := storetypes.MessageFilter{
|
|
Limit: maxSize,
|
|
}
|
|
|
|
storeMessages, err := chatStore.GetMessages(ctx.ChatID, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get messages: %w", err)
|
|
}
|
|
|
|
if len(storeMessages) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Convert store messages to context messages
|
|
messages := make([]agentcontext.Message, 0, len(storeMessages))
|
|
for _, msg := range storeMessages {
|
|
// Only include user and assistant messages for LLM context
|
|
// Skip internal types like loading, event, etc.
|
|
if msg.Role != "user" && msg.Role != "assistant" {
|
|
continue
|
|
}
|
|
|
|
// Convert store message to context message
|
|
ctxMsg := ast.convertStoreMessageToContext(msg)
|
|
if ctxMsg != nil {
|
|
messages = append(messages, *ctxMsg)
|
|
}
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
// convertStoreMessageToContext converts a store message to a context message
|
|
func (ast *Assistant) convertStoreMessageToContext(msg *storetypes.Message) *agentcontext.Message {
|
|
if msg == nil {
|
|
return nil
|
|
}
|
|
|
|
// Handle special message types:
|
|
// - tool_call/action: convert to historical summary text for LLM context
|
|
// - loading/event: skip (pure UI/lifecycle signals, no semantic value)
|
|
// - error: kept as-is so LLM can help troubleshoot issues
|
|
switch msg.Type {
|
|
case "tool_call":
|
|
return ast.convertToolCallToContext(msg)
|
|
case "action":
|
|
return ast.convertActionToContext(msg)
|
|
case "loading", "event":
|
|
return nil
|
|
}
|
|
|
|
// Extract content from Props
|
|
content := ast.extractContentFromProps(msg.Props, msg.Type)
|
|
if content == nil {
|
|
return nil
|
|
}
|
|
|
|
// Build context message
|
|
ctxMsg := &agentcontext.Message{
|
|
Role: agentcontext.MessageRole(msg.Role),
|
|
Content: content,
|
|
}
|
|
|
|
// Handle name field
|
|
if msg.Props != nil {
|
|
if name, ok := msg.Props["name"].(string); ok && name != "" {
|
|
ctxMsg.Name = &name
|
|
}
|
|
}
|
|
|
|
return ctxMsg
|
|
}
|
|
|
|
// extractContentFromProps extracts the content from message Props based on message type
|
|
func (ast *Assistant) extractContentFromProps(props map[string]interface{}, msgType string) interface{} {
|
|
if props == nil {
|
|
return nil
|
|
}
|
|
|
|
// For user input, content is stored directly in props["content"]
|
|
if msgType == "user_input" {
|
|
return props["content"]
|
|
}
|
|
|
|
// For text type messages
|
|
if msgType == "text" {
|
|
if text, ok := props["text"].(string); ok {
|
|
return text
|
|
}
|
|
// Also try content field
|
|
if content, ok := props["content"].(string); ok {
|
|
return content
|
|
}
|
|
}
|
|
|
|
// For other types, try to extract content or text
|
|
if content, ok := props["content"]; ok {
|
|
return content
|
|
}
|
|
if text, ok := props["text"]; ok {
|
|
return text
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// convertToolCallToContext converts a tool_call store message to a historical summary text message.
|
|
// This allows the LLM to understand what tools were previously called without re-invoking them.
|
|
//
|
|
// Supports two Props formats:
|
|
// - Standard ToolCallProps: {"name": "tool_name", "arguments": "{...}"}
|
|
// - Raw stream chunks: {"content": "[{\"index\":0,\"id\":\"call_...\",\"function\":{\"name\":\"tool\"}}][...]"}
|
|
func (ast *Assistant) convertToolCallToContext(msg *storetypes.Message) *agentcontext.Message {
|
|
if msg.Props == nil {
|
|
return nil
|
|
}
|
|
|
|
// Try standard ToolCallProps format first
|
|
if name, ok := msg.Props["name"].(string); ok && name != "" {
|
|
args, _ := msg.Props["arguments"].(string)
|
|
const maxArgsLen = 500
|
|
if len(args) > maxArgsLen {
|
|
args = args[:maxArgsLen] + "..."
|
|
}
|
|
return &agentcontext.Message{
|
|
Role: agentcontext.RoleAssistant,
|
|
Content: fmt.Sprintf("[Historical Tool Call Summary] Called tool \"%s\" with arguments: %s", name, args),
|
|
}
|
|
}
|
|
|
|
// Try raw stream chunk format: {"content": "[...][...]..."}
|
|
// Each chunk is a JSON array like [{"index":0,"id":"call_...","function":{"name":"echo__ping"}}]
|
|
// Subsequent chunks append arguments: [{"index":0,"function":{"arguments":"..."}}]
|
|
if raw, ok := msg.Props["content"].(string); ok && raw != "" {
|
|
name, args := parseToolCallRawChunks(raw)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
const maxArgsLen = 500
|
|
if len(args) > maxArgsLen {
|
|
args = args[:maxArgsLen] + "..."
|
|
}
|
|
return &agentcontext.Message{
|
|
Role: agentcontext.RoleAssistant,
|
|
Content: fmt.Sprintf("[Historical Tool Call Summary] Called tool \"%s\" with arguments: %s", name, args),
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseToolCallRawChunks parses concatenated raw stream chunks to extract tool name and arguments.
|
|
// Input format: "[{...}][{...}][{...}]" — multiple JSON arrays concatenated without separator.
|
|
func parseToolCallRawChunks(raw string) (name, args string) {
|
|
// Split concatenated JSON arrays: "][" is the boundary
|
|
// e.g. "[{...}][{...}]" → ["[{...}]", "[{...}]"]
|
|
chunks := splitJSONArrays(raw)
|
|
|
|
var argParts []string
|
|
for _, chunk := range chunks {
|
|
var items []map[string]interface{}
|
|
if err := jsoniter.UnmarshalFromString(chunk, &items); err != nil || len(items) == 0 {
|
|
continue
|
|
}
|
|
item := items[0]
|
|
if fn, ok := item["function"].(map[string]interface{}); ok {
|
|
if n, ok := fn["name"].(string); ok && n != "" && name == "" {
|
|
name = n
|
|
}
|
|
if a, ok := fn["arguments"].(string); ok && a != "" {
|
|
argParts = append(argParts, a)
|
|
}
|
|
}
|
|
}
|
|
|
|
args = ""
|
|
for _, part := range argParts {
|
|
args += part
|
|
}
|
|
return name, args
|
|
}
|
|
|
|
// splitJSONArrays splits a string of concatenated JSON arrays "[...][...][...]" into individual arrays.
|
|
func splitJSONArrays(s string) []string {
|
|
var result []string
|
|
depth := 0
|
|
start := -1
|
|
for i, ch := range s {
|
|
switch ch {
|
|
case '[':
|
|
if depth == 0 {
|
|
start = i
|
|
}
|
|
depth++
|
|
case ']':
|
|
depth--
|
|
if depth == 0 && start >= 0 {
|
|
result = append(result, s[start:i+1])
|
|
start = -1
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// convertActionToContext converts an action store message to a historical summary text message.
|
|
// This allows the LLM to understand what system actions were previously executed.
|
|
func (ast *Assistant) convertActionToContext(msg *storetypes.Message) *agentcontext.Message {
|
|
if msg.Props == nil {
|
|
return nil
|
|
}
|
|
name, _ := msg.Props["name"].(string)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
payload := ""
|
|
if msg.Props["payload"] != nil {
|
|
if payloadStr, err := jsoniter.MarshalToString(msg.Props["payload"]); err == nil {
|
|
const maxPayloadLen = 500
|
|
if len(payloadStr) > maxPayloadLen {
|
|
payloadStr = payloadStr[:maxPayloadLen] + "..."
|
|
}
|
|
payload = payloadStr
|
|
}
|
|
}
|
|
if payload != "" {
|
|
return &agentcontext.Message{
|
|
Role: agentcontext.RoleAssistant,
|
|
Content: fmt.Sprintf("[Historical Action Summary] Executed action \"%s\" with payload: %s", name, payload),
|
|
}
|
|
}
|
|
return &agentcontext.Message{
|
|
Role: agentcontext.RoleAssistant,
|
|
Content: fmt.Sprintf("[Historical Action Summary] Executed action \"%s\"", name),
|
|
}
|
|
}
|
|
|
|
// findOverlapIndex finds the index in input where history messages end
|
|
// Returns the number of input messages that overlap with history
|
|
func (ast *Assistant) findOverlapIndex(history, input []agentcontext.Message) int {
|
|
if len(history) == 0 || len(input) == 0 {
|
|
return 0
|
|
}
|
|
|
|
// We need to find the longest suffix of history that matches a prefix of input
|
|
// Start from the end of history and try to match with the beginning of input
|
|
|
|
maxOverlap := len(history)
|
|
if maxOverlap > len(input) {
|
|
maxOverlap = len(input)
|
|
}
|
|
|
|
// Try different overlap lengths, starting from the largest possible
|
|
for overlapLen := maxOverlap; overlapLen > 0; overlapLen-- {
|
|
// Check if the last 'overlapLen' messages of history match the first 'overlapLen' of input
|
|
historyStart := len(history) - overlapLen
|
|
matched := true
|
|
|
|
for i := 0; i < overlapLen; i++ {
|
|
if !ast.messagesMatch(history[historyStart+i], input[i]) {
|
|
matched = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if matched {
|
|
return overlapLen
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// messagesMatch checks if two messages are equivalent
|
|
func (ast *Assistant) messagesMatch(a, b agentcontext.Message) bool {
|
|
// Must have same role
|
|
if a.Role != b.Role {
|
|
return false
|
|
}
|
|
|
|
// Compare content
|
|
return ast.contentMatches(a.Content, b.Content)
|
|
}
|
|
|
|
// contentMatches compares two content values for equality
|
|
func (ast *Assistant) contentMatches(a, b interface{}) bool {
|
|
// Handle nil cases
|
|
if a == nil && b == nil {
|
|
return true
|
|
}
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
|
|
// If both are strings, compare directly
|
|
aStr, aIsStr := a.(string)
|
|
bStr, bIsStr := b.(string)
|
|
if aIsStr && bIsStr {
|
|
return aStr == bStr
|
|
}
|
|
|
|
// For complex content (arrays, etc.), use deep equal
|
|
return reflect.DeepEqual(a, b)
|
|
}
|