Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions agent/harness/loop/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,19 +281,28 @@ func evaluate(ctx context.Context, evaluators []Evaluator, loopCtx *Context) (Ev
}

func nextMessages(cfg Config, loopCtx *Context, evaluation Evaluation) (messages []*message.Message, surfaced []*message.Message) {
if len(evaluation.Messages) > 0 {
cloned := cloneMessages(evaluation.Messages)
return cloned, cloned
}
if cfg.FreshContextPerIteration {
// Fresh mode always restarts from the original input (the session is also
// reset to a pristine snapshot). Explicit ContinueWithMessages compose
// with that fresh context rather than replacing it, so the original input
// is not lost.
nextMessages := cloneMessages(loopCtx.InitialMessages)
if len(evaluation.Messages) > 0 {
explicit := cloneMessages(evaluation.Messages)
nextMessages = append(nextMessages, explicit...)
return nextMessages, explicit
Comment on lines 289 to +293

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity issue with upstream FreshContextPerIteration + explicit "continue with messages" behavior

When FreshContextPerIteration is true and the evaluator supplies explicit messages, this composes them on top of loopCtx.InitialMessages (nextMessages = append(cloneMessages(loopCtx.InitialMessages), explicit...)). Both upstream implementations treat an evaluator-supplied explicit next-input as a full, verbatim override of the message list — not something that gets composed with the initial/original input — while FreshContextPerIteration/fresh_context only governs session and default-feedback-input state, not this override.

  • .NET: dotnet/src/Microsoft.Agents.AI/Harness/Loop/LoopAgent.cs, EvaluateAndBuildNextAsync returns LoopNextStep.Continue(winner.Messages, ...) verbatim when winner.Messages is not null, before BuildNextMessages (the method that re-seeds InitialMessages) is ever invoked. The doc comment states ContinueWithMessages "bypass[es] this construction" (the fresh-context re-seed).
  • .NET test RunAsync_Fresh_WithContinueWithMessages_RecreatesSessionAsync asserts capture.MessagesPerCall[1] == ["explicit"] exactly (no trace of the original "go" input), while confirming the session is still reset each iteration — i.e. fresh-context only resets session state, not the message override.
  • Python: python/packages/core/agent_framework/_harness/_loop.py, _resolve_next_message sends next_msgs (the caller's explicit override) as the entire next input; fresh_context there only changes the default-nudge fallback and session/progress handling, not an explicit override.

Suggested resolution: when evaluation.Messages is non-empty under FreshContextPerIteration, send those messages verbatim (as the pre-PR non-fresh branch already does), and let fresh-context mode continue to reset only the session state — matching ContinueWithMessages's documented "bypass" semantics in both upstream SDKs. If the original bug report's concern (losing the original input entirely) still needs addressing, that should be solved via a separate opt-in mechanism analogous to upstream rather than by unconditionally prepending InitialMessages to every explicit-messages continuation.

}
feedbackMessage := aggregatedFeedbackMessage(loopCtx.Feedback, cfg.OnBehalfOfAuthorName)
if feedbackMessage == nil {
return nextMessages, nil
}
nextMessages = append(nextMessages, feedbackMessage)
return nextMessages, []*message.Message{feedbackMessage}
}
if len(evaluation.Messages) > 0 {
cloned := cloneMessages(evaluation.Messages)
return cloned, cloned
}
if evaluation.Feedback == "" {
return nil, nil
}
Expand Down
29 changes: 29 additions & 0 deletions agent/harness/loop/loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,32 @@ func cloneMessages(messages []*message.Message) []*message.Message {
}
return out
}

func TestLoop_FreshContextPerIteration_ContinueWithMessagesKeepsInitial(t *testing.T) {
capture := newCaptureAgent(func(int, []*message.Message) []*agent.ResponseUpdate {
return textUpdates("ack")
})
a := agent.New(capture.provider(), agent.Config{
Middlewares: []agent.Middleware{loop.New(loop.Config{
FreshContextPerIteration: true,
Evaluators: []loop.Evaluator{loop.EvaluatorFunc(func(_ context.Context, ctx *loop.Context) (loop.Evaluation, error) {
if ctx.Iteration == 1 {
return loop.ContinueWithMessages([]*message.Message{message.NewText("explicit")}), nil
}
return loop.Stop(), nil
})},
})},
})

if _, err := a.RunText(context.Background(), "original").Collect(); err != nil {
t.Fatal(err)
}
secondCall := messageTexts(capture.messagesPerCall[1])
// Fresh mode must re-seed the original input; explicit messages compose with it.
if len(secondCall) == 0 || secondCall[0] != "original" {
t.Fatalf("second call = %v, want the original input preserved in fresh mode", secondCall)
}
if !slices.Contains(secondCall, "explicit") {
t.Fatalf("second call = %v, want the explicit message included", secondCall)
}
}
Loading