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/...`

<img width="2014" height="1121" alt="image"
src="https://github.com/user-attachments/assets/15327e2a-3286-42ec-9415-c06ada758156"
/>
This commit is contained in:
Hz_
2026-07-21 10:59:46 +08:00
committed by GitHub
parent 4f1820bcaa
commit 161d2f0d7b
2 changed files with 181 additions and 19 deletions

View File

@@ -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
}

View File

@@ -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)
}
}