diff --git a/agent/assistant/history.go b/agent/assistant/history.go index 0992dad8..c1a7f7ad 100644 --- a/agent/assistant/history.go +++ b/agent/assistant/history.go @@ -4,6 +4,7 @@ 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" @@ -19,6 +20,18 @@ type HistoryResult struct { 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) @@ -41,14 +54,11 @@ func (ast *Assistant) WithHistory(ctx *agentcontext.Context, input []agentcontex return result, nil } - // Get MaxSize from store setting - maxSize := 20 // default - if storeSetting := GetStoreSetting(); storeSetting != nil && storeSetting.MaxSize > 0 { - maxSize = storeSetting.MaxSize - } + // Resolve history size: opts.HistorySize > storeSetting.MaxSize > default (20) + maxSize := getHistorySize(opts) // Load history from store - historyMessages, err := ast.loadHistory(ctx) + 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) @@ -102,8 +112,8 @@ func (ast *Assistant) WithHistory(ctx *agentcontext.Context, input []agentcontex } // 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) ([]agentcontext.Message, error) { +// 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 @@ -115,13 +125,6 @@ func (ast *Assistant) loadHistory(ctx *agentcontext.Context) ([]agentcontext.Mes return nil, nil } - // Get store setting for MaxSize - setting := GetStoreSetting() - maxSize := 20 // default - if setting != nil && setting.MaxSize > 0 { - maxSize = setting.MaxSize - } - // Load messages from store with limit filter := storetypes.MessageFilter{ Limit: maxSize, @@ -161,11 +164,16 @@ func (ast *Assistant) convertStoreMessageToContext(msg *storetypes.Message) *age return nil } - // Skip internal message types that should not be included in LLM context - // These types are for UI/internal use only and can confuse the LLM - // Note: "error" is kept so LLM can help troubleshoot issues + // 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", "loading", "action", "event": + case "tool_call": + return ast.convertToolCallToContext(msg) + case "action": + return ast.convertActionToContext(msg) + case "loading", "event": return nil } @@ -224,6 +232,137 @@ func (ast *Assistant) extractContentFromProps(props map[string]interface{}, msgT 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 { diff --git a/agent/assistant/history_test.go b/agent/assistant/history_test.go index 33fdf928..3e365524 100644 --- a/agent/assistant/history_test.go +++ b/agent/assistant/history_test.go @@ -415,7 +415,10 @@ func TestHistoryLoading(t *testing.T) { }() // Add various message types (only user/assistant roles allowed by DB constraint) - // loadHistory filters by role (user/assistant only) and converts based on type + // loadHistory filters by role (user/assistant only) and converts based on type: + // - loading/event: skipped (no semantic value) + // - tool_call/action: converted to historical summary text + // - text/user_input/error: kept as-is err = chatStore.SaveMessages(chatID, []*storetypes.Message{ { MessageID: fmt.Sprintf("filter_1_%s", reqID), @@ -461,11 +464,9 @@ func TestHistoryLoading(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) - // User and assistant roles are included - // loading type messages with role=assistant are included (role-based filtering) - // History contains: 1 user + 2 assistant (loading + text) = 3 messages - // Plus 1 new input = 4 total - assert.GreaterOrEqual(t, len(result.FullMessages), 3) // At least 1 user + 1 assistant from history + 1 new + // loading type is skipped (no semantic value) + // History: user_input + text = 2 messages; plus 1 new input = 3 total + assert.Len(t, result.FullMessages, 3) // Verify only user and assistant roles for _, msg := range result.FullMessages { @@ -473,7 +474,217 @@ func TestHistoryLoading(t *testing.T) { "Expected user or assistant role, got: %s", msg.Role) } - t.Log("✓ Only user and assistant roles included in history") + t.Log("✓ Loading type filtered, user/assistant roles kept") + }) + + t.Run("ToolCallConvertedToSummary", func(t *testing.T) { + chatID := fmt.Sprintf("test_toolcall_%s", uuid.New().String()[:8]) + ctx := newHistoryTestContext(chatID) + reqID := uuid.New().String()[:8] + + err := chatStore.CreateChat(&storetypes.Chat{ + ChatID: chatID, + AssistantID: ast.ID, + Status: "active", + Share: "private", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + require.NoError(t, err) + defer func() { + chatStore.DeleteMessages(chatID, nil) + chatStore.DeleteChat(chatID) + }() + + // Add tool_call messages in both formats + err = chatStore.SaveMessages(chatID, []*storetypes.Message{ + { + MessageID: fmt.Sprintf("tc_1_%s", reqID), + ChatID: chatID, + RequestID: fmt.Sprintf("req_tc_%s", reqID), + Role: "user", + Type: "user_input", + Props: map[string]interface{}{"content": "echo 3 ping 4"}, + Sequence: 1, + AssistantID: ast.ID, + CreatedAt: time.Now().Add(-3 * time.Minute), + }, + // Raw stream chunk format (actual DB format) + { + MessageID: fmt.Sprintf("tc_2_%s", reqID), + ChatID: chatID, + RequestID: fmt.Sprintf("req_tc_%s", reqID), + Role: "assistant", + Type: "tool_call", + Props: map[string]interface{}{"content": `[{"index":0,"id":"call_abc","type":"function","function":{"name":"echo__ping"}}][{"index":0,"function":{"arguments":"{\"count\":3}"}}]`}, + Sequence: 2, + AssistantID: ast.ID, + CreatedAt: time.Now().Add(-2 * time.Minute), + }, + // Standard ToolCallProps format + { + MessageID: fmt.Sprintf("tc_3_%s", reqID), + ChatID: chatID, + RequestID: fmt.Sprintf("req_tc_%s", reqID), + Role: "assistant", + Type: "tool_call", + Props: map[string]interface{}{"name": "echo__echo", "arguments": `{"message":"hello"}`}, + Sequence: 3, + AssistantID: ast.ID, + CreatedAt: time.Now().Add(-1 * time.Minute), + }, + }) + require.NoError(t, err) + + input := []agentcontext.Message{ + {Role: agentcontext.RoleUser, Content: "echo 5 ping 6"}, + } + + result, err := ast.WithHistory(ctx, input, nil) + require.NoError(t, err) + require.NotNil(t, result) + + // 1 user_input + 2 tool_call summaries + 1 new input = 4 + assert.Len(t, result.FullMessages, 4) + + // Verify tool_call messages are converted to summary text + tcMsg1 := result.FullMessages[1] + assert.Equal(t, agentcontext.RoleAssistant, tcMsg1.Role) + assert.Contains(t, tcMsg1.Content, "[Historical Tool Call Summary]") + assert.Contains(t, tcMsg1.Content, "echo__ping") + assert.Contains(t, tcMsg1.Content, `{"count":3}`) + + tcMsg2 := result.FullMessages[2] + assert.Equal(t, agentcontext.RoleAssistant, tcMsg2.Role) + assert.Contains(t, tcMsg2.Content, "[Historical Tool Call Summary]") + assert.Contains(t, tcMsg2.Content, "echo__echo") + assert.Contains(t, tcMsg2.Content, `{"message":"hello"}`) + + t.Log("✓ Tool call messages converted to historical summaries") + }) + + t.Run("ActionConvertedToSummary", func(t *testing.T) { + chatID := fmt.Sprintf("test_action_%s", uuid.New().String()[:8]) + ctx := newHistoryTestContext(chatID) + reqID := uuid.New().String()[:8] + + err := chatStore.CreateChat(&storetypes.Chat{ + ChatID: chatID, + AssistantID: ast.ID, + Status: "active", + Share: "private", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + require.NoError(t, err) + defer func() { + chatStore.DeleteMessages(chatID, nil) + chatStore.DeleteChat(chatID) + }() + + err = chatStore.SaveMessages(chatID, []*storetypes.Message{ + { + MessageID: fmt.Sprintf("act_1_%s", reqID), + ChatID: chatID, + RequestID: fmt.Sprintf("req_act_%s", reqID), + Role: "user", + Type: "user_input", + Props: map[string]interface{}{"content": "Do something"}, + Sequence: 1, + AssistantID: ast.ID, + CreatedAt: time.Now().Add(-2 * time.Minute), + }, + // Action with payload + { + MessageID: fmt.Sprintf("act_2_%s", reqID), + ChatID: chatID, + RequestID: fmt.Sprintf("req_act_%s", reqID), + Role: "assistant", + Type: "action", + Props: map[string]interface{}{ + "name": "robot.execute", + "payload": map[string]interface{}{"goals": "test goal", "robot_id": "12345"}, + }, + Sequence: 2, + AssistantID: ast.ID, + CreatedAt: time.Now().Add(-1 * time.Minute), + }, + }) + require.NoError(t, err) + + input := []agentcontext.Message{ + {Role: agentcontext.RoleUser, Content: "What happened?"}, + } + + result, err := ast.WithHistory(ctx, input, nil) + require.NoError(t, err) + require.NotNil(t, result) + + // 1 user_input + 1 action summary + 1 new input = 3 + assert.Len(t, result.FullMessages, 3) + + actMsg := result.FullMessages[1] + assert.Equal(t, agentcontext.RoleAssistant, actMsg.Role) + assert.Contains(t, actMsg.Content, "[Historical Action Summary]") + assert.Contains(t, actMsg.Content, "robot.execute") + assert.Contains(t, actMsg.Content, "test goal") + assert.Contains(t, actMsg.Content, "12345") + + t.Log("✓ Action messages converted to historical summaries with payload") + }) + + t.Run("ActionWithoutPayload", func(t *testing.T) { + chatID := fmt.Sprintf("test_action_nopay_%s", uuid.New().String()[:8]) + ctx := newHistoryTestContext(chatID) + reqID := uuid.New().String()[:8] + + err := chatStore.CreateChat(&storetypes.Chat{ + ChatID: chatID, + AssistantID: ast.ID, + Status: "active", + Share: "private", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + require.NoError(t, err) + defer func() { + chatStore.DeleteMessages(chatID, nil) + chatStore.DeleteChat(chatID) + }() + + err = chatStore.SaveMessages(chatID, []*storetypes.Message{ + { + MessageID: fmt.Sprintf("actnp_1_%s", reqID), + ChatID: chatID, + RequestID: fmt.Sprintf("req_actnp_%s", reqID), + Role: "assistant", + Type: "action", + Props: map[string]interface{}{"name": "navigate"}, + Sequence: 1, + AssistantID: ast.ID, + CreatedAt: time.Now(), + }, + }) + require.NoError(t, err) + + input := []agentcontext.Message{ + {Role: agentcontext.RoleUser, Content: "What happened?"}, + } + + result, err := ast.WithHistory(ctx, input, nil) + require.NoError(t, err) + require.NotNil(t, result) + + // 1 action summary + 1 new input = 2 + assert.Len(t, result.FullMessages, 2) + + actMsg := result.FullMessages[0] + assert.Equal(t, agentcontext.RoleAssistant, actMsg.Role) + assert.Contains(t, actMsg.Content, "[Historical Action Summary]") + assert.Contains(t, actMsg.Content, "navigate") + assert.NotContains(t, actMsg.Content, "payload") + + t.Log("✓ Action without payload handled correctly") }) t.Run("ContentExtraction", func(t *testing.T) { diff --git a/agent/context/types.go b/agent/context/types.go index 34a20af9..5f5f39ac 100644 --- a/agent/context/types.go +++ b/agent/context/types.go @@ -319,6 +319,11 @@ type Options struct { // Metadata for passing custom data to hooks (e.g., scenario selection) Metadata map[string]any `json:"metadata,omitempty"` // Custom metadata passed to Create/Next hooks + // HistorySize controls the max number of history messages loaded for LLM context. + // Priority: HistorySize > StoreSetting.MaxSize > default (20) + // 0 means use StoreSetting or default. + HistorySize int `json:"history_size,omitempty"` + // OnMessage is called for each message sent via ctx.Send() // Used by ctx.agent.Call with onChunk callback to receive SSE messages // Returns: 0 = continue, non-zero = stop