From 161d2f0d7b2f0373e821ef81fc454add5d60f878 Mon Sep 17 00:00:00 2001 From: Hz_ Date: Tue, 21 Jul 2026 10:59:46 +0800 Subject: [PATCH] fix(go-agent): include conversation history in agent prompts (#17137) ## Summary - Load the Agent history window with Python-compatible defaults. - Include prior conversation messages in ReAct prompts without duplicating the current user input. ## Testing - `bash build.sh --test ./internal/agent/...` - `bash build.sh --test ./internal/service/...` image --- internal/agent/component/agent.go | 74 +++++++--- .../agent/component/history_window_test.go | 126 ++++++++++++++++++ 2 files changed, 181 insertions(+), 19 deletions(-) diff --git a/internal/agent/component/agent.go b/internal/agent/component/agent.go index 233b63674f..233d9e07e8 100644 --- a/internal/agent/component/agent.go +++ b/internal/agent/component/agent.go @@ -71,16 +71,17 @@ type AgentComponent struct { // AgentParam captures the (resolved) DSL parameters for an Agent node. type AgentParam struct { - ModelID string - SystemPrompt string - UserPrompt string - Thinking string - TopP *float64 - Tools []string // Agent-visible tool names resolved into Eino BaseTool instances - ToolParams map[string]map[string]any // node-level tool constructor params keyed by tool name - MaxRounds int - OptimizeMultiTurn bool // when true (default), multi-turn history is condensed via full_question LLM call - OptimizeHistoryWindow int // number of history turns to include in the optimization prompt (default 3) + ModelID string + SystemPrompt string + UserPrompt string + Thinking string + TopP *float64 + Tools []string // Agent-visible tool names resolved into Eino BaseTool instances + ToolParams map[string]map[string]any // node-level tool constructor params keyed by tool name + MaxRounds int + MessageHistoryWindowSize int // number of prior conversation turns to include; zero disables history + OptimizeMultiTurn bool + OptimizeHistoryWindow int // Meta is the OpenAI-style function-call schema the Agent exposes // when it is itself called as a tool by a parent component. Mirrors // Python's `meta: ToolMeta` field — describes the Agent's own @@ -98,7 +99,10 @@ type AgentParam struct { BaseURL string } -const agentUserPromptSchemaDefault = "This is the order you need to send to the agent." +const ( + agentUserPromptSchemaDefault = "This is the order you need to send to the agent." + defaultAgentMessageHistoryWindowSize = 13 +) // AgentMeta declares the OpenAI-style function-call interface for the // Agent component. Mirrors ragflow Python's ToolMeta shape. @@ -146,13 +150,14 @@ func runEinoReActAgent(ctx context.Context, p AgentParam) (*schema.Message, erro if err != nil { return nil, fmt.Errorf("build tools: %w", err) } + input := buildAgentInputMessages(ctx, p) agent, err := react.NewAgent(ctx, &react.AgentConfig{ ToolCallingModel: chatModel, ToolsConfig: compose.ToolsNodeConfig{ Tools: tools, }, - MessageModifier: func(ctx context.Context, msgs []*schema.Message) []*schema.Message { + MessageModifier: func(_ context.Context, msgs []*schema.Message) []*schema.Message { if p.SystemPrompt != "" { return append([]*schema.Message{schema.SystemMessage(p.SystemPrompt)}, msgs...) } @@ -164,7 +169,6 @@ func runEinoReActAgent(ctx context.Context, p AgentParam) (*schema.Message, erro return nil, fmt.Errorf("create react agent: %w", err) } - input := []*schema.Message{schema.UserMessage(p.UserPrompt)} opt, future := react.WithMessageFuture() ctx = setArtifactCollector(ctx, future) stream, err := agent.Stream(ctx, input, opt) @@ -201,6 +205,35 @@ func runEinoReActAgent(ctx context.Context, p AgentParam) (*schema.Message, erro return msg, nil } +// buildAgentInputMessages assembles the Python-compatible Agent prompt: the +// configured history window followed by the current user prompt. The current +// in-flight user entry is excluded through SnapshotPriorHistory, because the +// canvas service appends it to state before invoking the workflow. +func buildAgentInputMessages(ctx context.Context, p AgentParam) []*schema.Message { + current := schema.Message{Role: schema.User, Content: p.UserPrompt} + messages := []schema.Message{} + if p.MessageHistoryWindowSize > 0 { + if state, _, err := runtime.GetStateFromContext[*runtime.CanvasState](ctx); err == nil && state != nil { + // Python takes the last 2*N entries from history, which already + // contains the current user input, and then removes that final + // entry before formatting the configured prompt. + priorLimit := p.MessageHistoryWindowSize*2 - 1 + messages = prependHistory(messages, state.SnapshotPriorHistory(), priorLimit) + } + } + if len(messages) > 0 && messages[len(messages)-1].Role == current.Role { + messages[len(messages)-1] = current + } else { + messages = append(messages, current) + } + + input := make([]*schema.Message, len(messages)) + for i := range messages { + input[i] = &messages[i] + } + return input +} + func emitAgentModelStreams(ctx context.Context, future react.MessageFuture) <-chan error { done := make(chan error, 1) go func() { @@ -570,9 +603,9 @@ func (c *AgentComponent) Invoke(ctx context.Context, inputs map[string]any) (map // Multi-turn conversation optimization. When the canvas state // carries prior history and OptimizeMultiTurn is enabled - // (default), rephrase the user prompt into a self-contained - // question via a dedicated LLM call. The rephrased prompt is - // what the Agent runner actually consumes. + // explicitly, rephrase the user prompt into a self-contained question via + // a dedicated LLM call. The rephrased prompt is what the Agent runner + // actually consumes. if p.OptimizeMultiTurn { if state, _, sErr := runtime.GetStateFromContext[*runtime.CanvasState](ctx); sErr == nil && state != nil { if rephrased, err := optimizeMultiTurnQuestion(ctx, p, state.SnapshotPriorHistory()); err == nil && rephrased != "" { @@ -679,8 +712,8 @@ func (c *AgentComponent) Inputs() map[string]string { "tools": "List of tool names to make available to the ReAct agent.", "tool_params": "Optional node-level tool constructor params keyed by tool name (e.g. execute_sql DB config).", "max_rounds": "Maximum ReAct rounds (default 3).", - "optimize_multi_turn": "When true (default), multi-turn history is condensed via full_question LLM call.", - "optimize_history_window": "Number of history turns to include in the optimization prompt (default 3).", + "optimize_multi_turn": "When true, multi-turn history is condensed via a question-rewrite LLM call.", + "optimize_history_window": "Number of history entries to include in the optimization prompt (default 3).", "driver": "Provider driver name", "api_key": "Override API key for this call.", "base_url": "Override the driver default endpoint URL.", @@ -1248,7 +1281,7 @@ func nestedMapFrom(inputs map[string]any, name string) (map[string]map[string]an // init registers AgentComponent with the orchestrator-owned registry. func init() { Register("Agent", func(params map[string]any) (Component, error) { - var p AgentParam + p := AgentParam{MessageHistoryWindowSize: defaultAgentMessageHistoryWindowSize} if v, ok := stringFrom(params, "model_id"); ok { p.ModelID = v } else if v, ok := stringFrom(params, "llm_id"); ok { @@ -1283,6 +1316,9 @@ func init() { if v, ok := intFrom(params, "max_rounds"); ok { p.MaxRounds = v } + if v, ok := intFrom(params, "message_history_window_size"); ok { + p.MessageHistoryWindowSize = v + } if v, ok := stringFrom(params, "driver"); ok { p.Driver = v } diff --git a/internal/agent/component/history_window_test.go b/internal/agent/component/history_window_test.go index e7d731059f..9d831f5a59 100644 --- a/internal/agent/component/history_window_test.go +++ b/internal/agent/component/history_window_test.go @@ -206,3 +206,129 @@ func TestLLM_Invoke_HistoryWindow_ZeroIsNoop(t *testing.T) { t.Errorf("expected only 1 message (no history), got %d", len(stub.captured.Messages)) } } + +func TestAgentFactoryMessageHistoryWindow(t *testing.T) { + tests := []struct { + name string + params map[string]any + want int + }{ + { + name: "python default", + params: map[string]any{"model_id": "stub", "user_prompt": "now"}, + want: defaultAgentMessageHistoryWindowSize, + }, + { + name: "dsl value", + params: map[string]any{ + "model_id": "stub", + "user_prompt": "now", + "message_history_window_size": 12, + }, + want: 12, + }, + { + name: "explicitly disabled", + params: map[string]any{ + "model_id": "stub", + "user_prompt": "now", + "message_history_window_size": 0, + }, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + component, err := New("Agent", tt.params) + if err != nil { + t.Fatalf("New(Agent): %v", err) + } + agent, ok := component.(*AgentComponent) + if !ok { + t.Fatalf("component type = %T, want *AgentComponent", component) + } + if got := agent.param.MessageHistoryWindowSize; got != tt.want { + t.Fatalf("message history window = %d, want %d", got, tt.want) + } + }) + } +} + +func TestBuildAgentInputMessagesIncludesPriorConversation(t *testing.T) { + state := runtime.NewCanvasState("run-agent-history", "task-agent-history") + state.AppendHistory("user", "第 8 章下面的第一个副标题是什么?") + state.AppendHistory("assistant", map[string]any{"content": "第一个是解析器工厂。"}) + state.AppendCurrentUser("第二个呢?") + ctx := runtime.WithState(context.Background(), state) + + messages := buildAgentInputMessages(ctx, AgentParam{ + SystemPrompt: "请根据知识库回答。", + UserPrompt: "第二个呢?", + MessageHistoryWindowSize: 12, + }) + if len(messages) != 3 { + t.Fatalf("message count = %d, want 3", len(messages)) + } + wantRoles := []schema.RoleType{schema.User, schema.Assistant, schema.User} + wantContent := []string{ + "第 8 章下面的第一个副标题是什么?", + "第一个是解析器工厂。", + "第二个呢?", + } + for i := range messages { + if messages[i].Role != wantRoles[i] || messages[i].Content != wantContent[i] { + t.Fatalf("message[%d] = (%q, %q), want (%q, %q)", + i, messages[i].Role, messages[i].Content, wantRoles[i], wantContent[i]) + } + } +} + +func TestBuildAgentInputMessagesUsesConversationTurnWindow(t *testing.T) { + state := runtime.NewCanvasState("run-agent-window", "task-agent-window") + state.AppendHistory("user", "old question") + state.AppendHistory("assistant", "old answer") + state.AppendHistory("user", "recent question") + state.AppendHistory("assistant", "recent answer") + state.AppendCurrentUser("current question") + ctx := runtime.WithState(context.Background(), state) + + messages := buildAgentInputMessages(ctx, AgentParam{ + UserPrompt: "current question", + MessageHistoryWindowSize: 1, + }) + if len(messages) != 2 { + t.Fatalf("message count = %d, want Python's final prior entry plus current user", len(messages)) + } + if messages[0].Content != "recent answer" || messages[1].Content != "current question" { + t.Fatalf("messages = %#v", messages) + } +} + +func TestBuildAgentInputMessagesZeroWindowDisablesHistory(t *testing.T) { + state := runtime.NewCanvasState("run-agent-no-history", "task-agent-no-history") + state.AppendHistory("user", "ignored question") + state.AppendHistory("assistant", "ignored answer") + state.AppendCurrentUser("current question") + ctx := runtime.WithState(context.Background(), state) + + messages := buildAgentInputMessages(ctx, AgentParam{UserPrompt: "current question"}) + if len(messages) != 1 || messages[0].Content != "current question" { + t.Fatalf("messages = %#v, want current user only", messages) + } +} + +func TestBuildAgentInputMessagesReplacesTrailingUserLikePython(t *testing.T) { + state := runtime.NewCanvasState("run-agent-trailing-user", "task-agent-trailing-user") + state.AppendHistory("user", "unanswered previous input") + state.AppendCurrentUser("current question") + ctx := runtime.WithState(context.Background(), state) + + messages := buildAgentInputMessages(ctx, AgentParam{ + UserPrompt: "current question", + MessageHistoryWindowSize: 12, + }) + if len(messages) != 1 || messages[0].Content != "current question" { + t.Fatalf("messages = %#v, want trailing user replaced by current prompt", messages) + } +}