From bbd94a508c95f018ac2bdd886df761bdb3e26aa6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 05:40:27 +0000 Subject: [PATCH 1/2] [dotnet-port-api] Add compaction-backed history provider Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agent/compaction/doc.go | 6 +- agent/compaction/historyprovider.go | 188 ++++++++++++++++++ agent/compaction/historyprovider_test.go | 103 ++++++++++ docs/dotnet-go-sdk-feature-comparison.md | 2 +- .../step17_additional_ai_context/main.go | 11 +- 5 files changed, 305 insertions(+), 5 deletions(-) create mode 100644 agent/compaction/historyprovider.go create mode 100644 agent/compaction/historyprovider_test.go diff --git a/agent/compaction/doc.go b/agent/compaction/doc.go index d4280fe8..6b4abab3 100644 --- a/agent/compaction/doc.go +++ b/agent/compaction/doc.go @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. // Package compaction reduces conversation history to fit a model's context -// window. It provides a context provider and composable strategies — sliding -// window, truncation, summarization, tool-result eviction, and context-window -// sizing — selected by triggers evaluated over a message index. +// window. It provides history and context providers plus composable strategies +// — sliding window, truncation, summarization, tool-result eviction, and +// context-window sizing — selected by triggers evaluated over a message index. package compaction diff --git a/agent/compaction/historyprovider.go b/agent/compaction/historyprovider.go new file mode 100644 index 00000000..7fcc9db3 --- /dev/null +++ b/agent/compaction/historyprovider.go @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft. All rights reserved. + +package compaction + +import ( + "cmp" + "context" + "log/slog" + "runtime" + "slices" + "sync" + "weak" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/message/messagefilter" +) + +const defaultHistoryProviderSourceID = "CompactionHistoryProvider" + +// HistoryProviderConfig configures the provider created by [NewHistoryProvider]. +type HistoryProviderConfig struct { + // Strategy is the compaction strategy applied to persisted history. + Strategy Strategy + + // SourceID identifies messages loaded from this provider. + // When empty, a default compaction history provider source ID is used. + SourceID string + + // StateKey identifies where provider state is stored in the session. + // When empty, SourceID is used. + StateKey string + + // StateInitializer returns initial messages on first use. + // When nil, no initial messages are used. + StateInitializer func(*agent.Session) []*message.Message + + // Optional filter applied to messages loaded from storage before they are included. + // Defaults to passing all loaded messages through. + ProvideOutputMessageFilter messagefilter.Filter + + // Optional filter applied to request messages before storing them. + // Defaults to messages that did not come from a history provider. + StoreInputRequestMessageFilter messagefilter.Filter + + // Optional filter applied to response messages before storing them. + // Defaults to passing all response messages through. + StoreInputResponseMessageFilter messagefilter.Filter + + // TokenCounter computes token counts for message groups. + // When nil, token counts are estimated from UTF-8 byte counts. + TokenCounter TokenCounter + + // Logger emits provider diagnostics when set. + Logger *slog.Logger +} + +type historyProviderState struct { + Messages []*message.Message `json:"messages,omitempty"` +} + +type historyProviderSessionLocks struct { + locks sync.Map // map[weak.Pointer[agent.Session]]*sync.Mutex + nullSessionLock sync.Mutex +} + +func (l *historyProviderSessionLocks) forOptions(options []agent.Option) *sync.Mutex { + session, _ := agent.GetOption(options, agent.WithSession) + if session == nil { + return &l.nullSessionLock + } + key := weak.Make(session) + if existing, ok := l.locks.Load(key); ok { + return existing.(*sync.Mutex) + } + actual, loaded := l.locks.LoadOrStore(key, &sync.Mutex{}) + if !loaded { + runtime.AddCleanup(session, func(k weak.Pointer[agent.Session]) { + l.locks.Delete(k) + }, key) + } + return actual.(*sync.Mutex) +} + +// NewHistoryProvider creates a session-backed history provider that compacts stored history. +// +// The provider stores conversation history in the session like [agent.NewInMemoryHistoryProvider], +// but it automatically applies Strategy whenever history is loaded or updated. This gives history +// providers first-class reducer-trigger behavior without requiring a separate context provider. +func NewHistoryProvider(cfg HistoryProviderConfig) agent.HistoryProvider { + if cfg.Strategy == nil { + panic("Strategy is required") + } + cfg.SourceID = cmp.Or(cfg.SourceID, defaultHistoryProviderSourceID) + cfg.StateKey = cmp.Or(cfg.StateKey, cfg.SourceID) + locks := new(historyProviderSessionLocks) + + return agent.NewHistoryProvider(agent.HistoryProviderConfig{ + SourceID: cfg.SourceID, + ProvideOutputMessageFilter: cfg.ProvideOutputMessageFilter, + StoreInputRequestMessageFilter: cfg.StoreInputRequestMessageFilter, + StoreInputResponseMessageFilter: cfg.StoreInputResponseMessageFilter, + Provide: func(ctx context.Context, invoking agent.InvokingContext) ([]*message.Message, error) { + mu := locks.forOptions(invoking.Options) + mu.Lock() + defer mu.Unlock() + + session, _ := agent.GetOption(invoking.Options, agent.WithSession) + if session == nil { + return nil, nil + } + state, err := getHistoryProviderState(session, cfg.StateKey, cfg.StateInitializer) + if err != nil { + return nil, err + } + if len(state.Messages) == 0 { + return nil, nil + } + + compacted, err := compactHistory(ctx, cfg.Strategy, state.Messages, cfg.TokenCounter, cfg.Logger) + if err != nil { + return nil, err + } + state.Messages = slices.Clone(compacted) + session.Set(cfg.StateKey, state) + return slices.Clone(compacted), nil + }, + Store: func(ctx context.Context, invoked agent.InvokedContext) error { + mu := locks.forOptions(invoked.Options) + mu.Lock() + defer mu.Unlock() + + session, _ := agent.GetOption(invoked.Options, agent.WithSession) + if session == nil { + return nil + } + state, err := getHistoryProviderState(session, cfg.StateKey, cfg.StateInitializer) + if err != nil { + return err + } + + messages := slices.Clone(state.Messages) + messages = append(messages, invoked.RequestMessages...) + messages = append(messages, invoked.ResponseMessages...) + + compacted, err := compactHistory(ctx, cfg.Strategy, messages, cfg.TokenCounter, cfg.Logger) + if err != nil { + return err + } + state.Messages = slices.Clone(compacted) + session.Set(cfg.StateKey, state) + return nil + }, + }) +} + +func getHistoryProviderState(session *agent.Session, stateKey string, initializer func(*agent.Session) []*message.Message) (historyProviderState, error) { + var state historyProviderState + if ok, err := session.Get(stateKey, &state); err != nil { + return state, err + } else if ok { + return state, nil + } + if initializer != nil { + state.Messages = slices.Clone(initializer(session)) + } + session.Set(stateKey, state) + return state, nil +} + +func compactHistory(ctx context.Context, strategy Strategy, messages []*message.Message, tokenCounter TokenCounter, logger *slog.Logger) ([]*message.Message, error) { + if len(messages) == 0 { + return nil, nil + } + index := CreateMessageIndex(messages, tokenCounter) + beforeMessages := index.IncludedMessageCount() + if logger != nil { + logger.DebugContext(ctx, "applying history compaction", slog.Int("messages", beforeMessages)) + } + if _, err := strategy.Compact(ctx, index); err != nil { + return nil, err + } + afterMessages := index.IncludedMessageCount() + if logger != nil && afterMessages < beforeMessages { + logger.DebugContext(ctx, "history compaction applied", slog.Int("before_messages", beforeMessages), slog.Int("after_messages", afterMessages)) + } + return index.IncludedMessages(), nil +} diff --git a/agent/compaction/historyprovider_test.go b/agent/compaction/historyprovider_test.go new file mode 100644 index 00000000..c01858dc --- /dev/null +++ b/agent/compaction/historyprovider_test.go @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +package compaction_test + +import ( + "context" + "encoding/json" + "slices" + "testing" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/compaction" + "github.com/microsoft/agent-framework-go/internal/agenttest" + "github.com/microsoft/agent-framework-go/message" +) + +func invokeHistoryProvider(provider agent.HistoryProvider, ctx context.Context, messages []*message.Message, options ...agent.Option) ([]*message.Message, error) { + return provider.Invoking(ctx, agent.InvokingContext{Messages: messages, Options: options}) +} + +func invokeHistoryProviderInvoked(provider agent.HistoryProvider, ctx context.Context, requestMessages, responseMessages []*message.Message, options ...agent.Option) error { + return provider.Invoked(ctx, agent.InvokedContext{RequestMessages: requestMessages, ResponseMessages: responseMessages, Options: options}) +} + +func TestNewHistoryProvider_CompactsPersistedHistory(t *testing.T) { + session := agenttest.CreateSession() + minimumPreservedGroups := 2 + provider := compaction.NewHistoryProvider(compaction.HistoryProviderConfig{ + SourceID: "compaction-history", + Strategy: &compaction.TruncationStrategy{ + Trigger: compaction.GroupsExceed(2), + MinimumPreservedGroups: &minimumPreservedGroups, + }, + }) + + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{textMessage(message.RoleUser, "u1")}, []*message.Message{textMessage(message.RoleAssistant, "a1")}, agent.WithSession(session)); err != nil { + t.Fatalf("store turn 1: %v", err) + } + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{textMessage(message.RoleUser, "u2")}, []*message.Message{textMessage(message.RoleAssistant, "a2")}, agent.WithSession(session)); err != nil { + t.Fatalf("store turn 2: %v", err) + } + + loaded, err := invokeHistoryProvider(provider, t.Context(), []*message.Message{textMessage(message.RoleUser, "u3")}, agent.WithSession(session)) + if err != nil { + t.Fatalf("load history: %v", err) + } + if got, want := messageTexts(loaded), []string{"u2", "a2", "u3"}; !slices.Equal(got, want) { + t.Fatalf("loaded history = %v, want %v", got, want) + } + if got, want := loaded[0].Source, (message.Source{Type: agent.SourceTypeHistoryProvider, ID: "compaction-history"}); got != want { + t.Fatalf("history source = %#v, want %#v", got, want) + } + + data, err := json.Marshal(session) + if err != nil { + t.Fatalf("marshal session: %v", err) + } + restored := agenttest.CreateSession() + if err := json.Unmarshal(data, restored); err != nil { + t.Fatalf("unmarshal session: %v", err) + } + + var state struct { + Messages []*message.Message `json:"messages,omitempty"` + } + if ok, err := restored.Get("compaction-history", &state); err != nil || !ok { + t.Fatalf("expected persisted state, ok=%v err=%v", ok, err) + } + if got, want := messageTexts(state.Messages), []string{"u2", "a2"}; !slices.Equal(got, want) { + t.Fatalf("persisted history = %v, want %v", got, want) + } +} + +func TestNewHistoryProvider_LoadsCompactedSummaryAsHistory(t *testing.T) { + session := agenttest.CreateSession() + minimumPreservedGroups := 2 + provider := compaction.NewHistoryProvider(compaction.HistoryProviderConfig{ + SourceID: "compaction-history", + Strategy: &compaction.SummarizationStrategy{ + Trigger: compaction.GroupsExceed(2), + Summarizer: compaction.SummarizerFunc(func(context.Context, []*message.Message) (string, error) { return "older context", nil }), + MinimumPreservedGroups: &minimumPreservedGroups, + }, + }) + + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{textMessage(message.RoleUser, "u1")}, []*message.Message{textMessage(message.RoleAssistant, "a1")}, agent.WithSession(session)); err != nil { + t.Fatalf("store turn 1: %v", err) + } + if err := invokeHistoryProviderInvoked(provider, t.Context(), []*message.Message{textMessage(message.RoleUser, "u2")}, []*message.Message{textMessage(message.RoleAssistant, "a2")}, agent.WithSession(session)); err != nil { + t.Fatalf("store turn 2: %v", err) + } + + loaded, err := invokeHistoryProvider(provider, t.Context(), []*message.Message{textMessage(message.RoleUser, "u3")}, agent.WithSession(session)) + if err != nil { + t.Fatalf("load history: %v", err) + } + if got, want := messageTexts(loaded), []string{"[Summary]\nolder context", "u2", "a2", "u3"}; !slices.Equal(got, want) { + t.Fatalf("loaded history = %v, want %v", got, want) + } + if got, want := loaded[0].Source, (message.Source{Type: agent.SourceTypeHistoryProvider, ID: "compaction-history"}); got != want { + t.Fatalf("summary source = %#v, want %#v", got, want) + } +} diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 2b27420d..f05f9919 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -36,7 +36,7 @@ Intentional contract choices in this parity pass: | Core agent abstraction | `AIAgent`, `DelegatingAIAgent`, `AgentRunOptions`, `AgentResponse`, `AgentResponseUpdate`, current run context, metadata, typed structured responses. | `agent.Agent`, `agent.ProviderConfig`, `agent.Config`, `agent.Option`, `Response`, `ResponseUpdate`, `Run`, `RunText`, `RunMessage`, `ResponseStream`; response aggregation preserves provider `RawRepresentation`. | Aligned | .NET exposes extension-method adapters around `Microsoft.Extensions.AI`; Go uses a provider `RunFunc` contract and package-level option wrappers. | | Agent identity and metadata | `AIAgentMetadata`, agent ID/name/description, source attribution extensions. | Agent ID/name/description, provider name, response author stamping, `message.Message.Source`, and `(*message.Message).WithSource`. | Aligned | API shape differs: .NET exposes extension methods around `Microsoft.Extensions.AI`; Go exposes source data through an explicit `message.Source` field and a `WithSource` helper. | | Sessions | `AgentSession`, `AgentSessionStateBag`, session serialization helpers, provider session state. | `agent.Session`, marshal/unmarshal hooks, provider session hooks, local/service ID support. | Aligned | .NET has a richer typed state bag and extension helpers; Go stores provider/session values through its own session abstraction. | -| Chat history | `ChatHistoryProvider`, `InMemoryChatHistoryProvider`, per-service-call persistence, reducer triggers. | `HistoryProvider`, default in-memory history for local sessions, third-party storage example. | Partial | Go has the core lifecycle but fewer built-in storage providers and no first-class reducer trigger options on the history provider. | +| Chat history | `ChatHistoryProvider`, `InMemoryChatHistoryProvider`, per-service-call persistence, reducer triggers. | `HistoryProvider`, default in-memory history for local sessions, compaction-backed history provider, third-party storage example. | Partial | Go now has first-class reducer-trigger support through `agent/compaction.NewHistoryProvider`, but it still has fewer built-in storage providers than .NET. | | Context providers and memory injection | `AIContextProvider`, `MessageAIContextProvider`, provider invoking/invoked lifecycle. | `agent.ContextProvider`, `(*agent.ContextProvider).Middleware`, before/after lifecycle. | Aligned | .NET context providers are integrated with `Microsoft.Extensions.AI`; Go providers directly transform `message.Message` slices and options. | | Memory integrations | Chat history memory, bounded chat history, Mem0, Foundry memory, RAG samples, file memory. | In-memory history/context examples and `foundryprovider.NewMemoryProvider`. | Partial | Go has Foundry memory and primitives to build memory, but no Mem0, RAG, bounded memory package, or file memory provider. | | Compaction and chat reduction | Compaction provider, triggers, message index/groups, sliding window, context window, truncation, summarization, tool-result, pipeline, chat reducer adapter. | `agent/compaction` provider, triggers, message index/groups, sliding window, `ContextWindowStrategy`, truncation, summarization, tool-result, pipeline. | Aligned | `IChatReducer` is a .NET-only abstraction that does not exist in Go; all compaction strategies now align. | diff --git a/examples/02-agents/agents/step17_additional_ai_context/main.go b/examples/02-agents/agents/step17_additional_ai_context/main.go index ec5830a5..6dc19880 100644 --- a/examples/02-agents/agents/step17_additional_ai_context/main.go +++ b/examples/02-agents/agents/step17_additional_ai_context/main.go @@ -16,6 +16,7 @@ import ( "strings" "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/compaction" "github.com/microsoft/agent-framework-go/examples/internal/demo" "github.com/microsoft/agent-framework-go/message" "github.com/microsoft/agent-framework-go/message/messagefilter" @@ -115,8 +116,16 @@ func newChatHistoryProvider() agent.HistoryProvider { // By default, the history provider stores request messages that did not come from chat history. // In this case, we explicitly exclude messages from chat history and AI context providers. // You may want to store these messages, depending on their content and your requirements. - return agent.NewInMemoryHistoryProvider(agent.InMemoryHistoryProviderConfig{ + // + // The compaction history provider keeps the most recent three turns in session state so + // long-running conversations do not grow without bound. + minimumPreservedTurns := 3 + return compaction.NewHistoryProvider(compaction.HistoryProviderConfig{ SourceID: chatHistorySourceID, + Strategy: &compaction.SlidingWindowStrategy{ + Trigger: compaction.TurnsExceed(minimumPreservedTurns), + MinimumPreservedTurns: &minimumPreservedTurns, + }, StoreInputRequestMessageFilter: messagefilter.NotSourceTypes( agent.SourceTypeHistoryProvider, agent.SourceTypeContextProvider, From 941efbc84216bfad3519a6d3a126753401153bf7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:31:02 +0000 Subject: [PATCH 2/2] Fix pre-call history compaction ordering Co-authored-by: michelle-clayton-work <262183035+michelle-clayton-work@users.noreply.github.com> --- agent/compaction/historyprovider.go | 170 +++++++++++++++-------- agent/compaction/historyprovider_test.go | 44 +++++- 2 files changed, 153 insertions(+), 61 deletions(-) diff --git a/agent/compaction/historyprovider.go b/agent/compaction/historyprovider.go index 7fcc9db3..b3cdc46f 100644 --- a/agent/compaction/historyprovider.go +++ b/agent/compaction/historyprovider.go @@ -64,6 +64,11 @@ type historyProviderSessionLocks struct { nullSessionLock sync.Mutex } +type historyProvider struct { + config HistoryProviderConfig + locks *historyProviderSessionLocks +} + func (l *historyProviderSessionLocks) forOptions(options []agent.Option) *sync.Mutex { session, _ := agent.GetOption(options, agent.WithSession) if session == nil { @@ -93,65 +98,112 @@ func NewHistoryProvider(cfg HistoryProviderConfig) agent.HistoryProvider { } cfg.SourceID = cmp.Or(cfg.SourceID, defaultHistoryProviderSourceID) cfg.StateKey = cmp.Or(cfg.StateKey, cfg.SourceID) - locks := new(historyProviderSessionLocks) - - return agent.NewHistoryProvider(agent.HistoryProviderConfig{ - SourceID: cfg.SourceID, - ProvideOutputMessageFilter: cfg.ProvideOutputMessageFilter, - StoreInputRequestMessageFilter: cfg.StoreInputRequestMessageFilter, - StoreInputResponseMessageFilter: cfg.StoreInputResponseMessageFilter, - Provide: func(ctx context.Context, invoking agent.InvokingContext) ([]*message.Message, error) { - mu := locks.forOptions(invoking.Options) - mu.Lock() - defer mu.Unlock() - - session, _ := agent.GetOption(invoking.Options, agent.WithSession) - if session == nil { - return nil, nil - } - state, err := getHistoryProviderState(session, cfg.StateKey, cfg.StateInitializer) - if err != nil { - return nil, err - } - if len(state.Messages) == 0 { - return nil, nil - } - - compacted, err := compactHistory(ctx, cfg.Strategy, state.Messages, cfg.TokenCounter, cfg.Logger) - if err != nil { - return nil, err - } - state.Messages = slices.Clone(compacted) - session.Set(cfg.StateKey, state) - return slices.Clone(compacted), nil - }, - Store: func(ctx context.Context, invoked agent.InvokedContext) error { - mu := locks.forOptions(invoked.Options) - mu.Lock() - defer mu.Unlock() - - session, _ := agent.GetOption(invoked.Options, agent.WithSession) - if session == nil { - return nil - } - state, err := getHistoryProviderState(session, cfg.StateKey, cfg.StateInitializer) - if err != nil { - return err - } - - messages := slices.Clone(state.Messages) - messages = append(messages, invoked.RequestMessages...) - messages = append(messages, invoked.ResponseMessages...) - - compacted, err := compactHistory(ctx, cfg.Strategy, messages, cfg.TokenCounter, cfg.Logger) - if err != nil { - return err - } - state.Messages = slices.Clone(compacted) - session.Set(cfg.StateKey, state) - return nil - }, - }) + return &historyProvider{config: cfg, locks: new(historyProviderSessionLocks)} +} + +func (p *historyProvider) Invoking(ctx context.Context, invoking agent.InvokingContext) ([]*message.Message, error) { + mu := p.locks.forOptions(invoking.Options) + mu.Lock() + defer mu.Unlock() + + session, _ := agent.GetOption(invoking.Options, agent.WithSession) + if session == nil { + return invoking.Messages, nil + } + state, err := getHistoryProviderState(session, p.config.StateKey, p.config.StateInitializer) + if err != nil { + return nil, err + } + + history := slices.Clone(state.Messages) + if p.config.ProvideOutputMessageFilter != nil { + history, err = p.config.ProvideOutputMessageFilter(ctx, history) + if err != nil { + return nil, err + } + } + + source := message.Source{Type: agent.SourceTypeHistoryProvider, ID: p.config.SourceID} + messages := make([]*message.Message, 0, len(history)+len(invoking.Messages)) + for _, msg := range history { + if msg == nil { + messages = append(messages, nil) + } else { + messages = append(messages, msg.WithSource(source)) + } + } + messages = append(messages, invoking.Messages...) + + compacted, err := compactHistory(ctx, p.config.Strategy, messages, p.config.TokenCounter, p.config.Logger) + if err != nil { + return nil, err + } + inputMessages := make(map[*message.Message]struct{}, len(invoking.Messages)) + for _, msg := range invoking.Messages { + inputMessages[msg] = struct{}{} + } + for i, msg := range compacted { + if msg == nil || msg.Source == source { + continue + } + if _, ok := inputMessages[msg]; !ok { + compacted[i] = msg.WithSource(source) + } + } + return compacted, nil +} + +func (p *historyProvider) Invoked(ctx context.Context, invoked agent.InvokedContext) error { + if invoked.Err != nil { + return nil + } + + requestFilter := p.config.StoreInputRequestMessageFilter + if requestFilter == nil { + requestFilter = messagefilter.NotSourceTypes(agent.SourceTypeHistoryProvider) + } + filteredRequest, err := requestFilter(ctx, slices.Clone(invoked.RequestMessages)) + if err != nil { + return err + } + filteredResponse := invoked.ResponseMessages + if p.config.StoreInputResponseMessageFilter != nil { + filteredResponse, err = p.config.StoreInputResponseMessageFilter(ctx, slices.Clone(invoked.ResponseMessages)) + if err != nil { + return err + } + } + + mu := p.locks.forOptions(invoked.Options) + mu.Lock() + defer mu.Unlock() + + session, _ := agent.GetOption(invoked.Options, agent.WithSession) + if session == nil { + return nil + } + state, err := getHistoryProviderState(session, p.config.StateKey, p.config.StateInitializer) + if err != nil { + return err + } + + messages := slices.Clone(state.Messages) + if p.config.ProvideOutputMessageFilter != nil { + messages, err = p.config.ProvideOutputMessageFilter(ctx, messages) + if err != nil { + return err + } + } + messages = append(messages, filteredRequest...) + messages = append(messages, filteredResponse...) + + compacted, err := compactHistory(ctx, p.config.Strategy, messages, p.config.TokenCounter, p.config.Logger) + if err != nil { + return err + } + state.Messages = slices.Clone(compacted) + session.Set(p.config.StateKey, state) + return nil } func getHistoryProviderState(session *agent.Session, stateKey string, initializer func(*agent.Session) []*message.Message) (historyProviderState, error) { diff --git a/agent/compaction/historyprovider_test.go b/agent/compaction/historyprovider_test.go index c01858dc..d043c0be 100644 --- a/agent/compaction/historyprovider_test.go +++ b/agent/compaction/historyprovider_test.go @@ -12,6 +12,7 @@ import ( "github.com/microsoft/agent-framework-go/agent/compaction" "github.com/microsoft/agent-framework-go/internal/agenttest" "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/message/messagefilter" ) func invokeHistoryProvider(provider agent.HistoryProvider, ctx context.Context, messages []*message.Message, options ...agent.Option) ([]*message.Message, error) { @@ -44,9 +45,10 @@ func TestNewHistoryProvider_CompactsPersistedHistory(t *testing.T) { if err != nil { t.Fatalf("load history: %v", err) } - if got, want := messageTexts(loaded), []string{"u2", "a2", "u3"}; !slices.Equal(got, want) { + if got, want := messageTexts(loaded), []string{"a2", "u3"}; !slices.Equal(got, want) { t.Fatalf("loaded history = %v, want %v", got, want) } + if got, want := loaded[0].Source, (message.Source{Type: agent.SourceTypeHistoryProvider, ID: "compaction-history"}); got != want { t.Fatalf("history source = %#v, want %#v", got, want) } @@ -71,6 +73,44 @@ func TestNewHistoryProvider_CompactsPersistedHistory(t *testing.T) { } } +func TestNewHistoryProvider_FiltersHistoryBeforeSummarization(t *testing.T) { + session := agenttest.CreateSession() + minimumPreservedGroups := 2 + secret := textMessage(message.RoleUser, "secret") + secret.Source = message.Source{Type: agent.SourceTypeContextProvider, ID: "private"} + var summarized []*message.Message + provider := compaction.NewHistoryProvider(compaction.HistoryProviderConfig{ + SourceID: "compaction-history", + StateInitializer: func(*agent.Session) []*message.Message { + return []*message.Message{ + secret, + textMessage(message.RoleAssistant, "visible 1"), + textMessage(message.RoleUser, "visible 2"), + } + }, + ProvideOutputMessageFilter: messagefilter.ExternalOnly, + Strategy: &compaction.SummarizationStrategy{ + Trigger: compaction.GroupsExceed(2), + Summarizer: compaction.SummarizerFunc(func(_ context.Context, messages []*message.Message) (string, error) { + summarized = slices.Clone(messages) + return "visible context", nil + }), + MinimumPreservedGroups: &minimumPreservedGroups, + }, + }) + + loaded, err := invokeHistoryProvider(provider, t.Context(), []*message.Message{textMessage(message.RoleUser, "current")}, agent.WithSession(session)) + if err != nil { + t.Fatalf("load history: %v", err) + } + if got, want := messageTexts(loaded), []string{"[Summary]\nvisible context", "visible 2", "current"}; !slices.Equal(got, want) { + t.Fatalf("loaded history = %v, want %v", got, want) + } + if slices.Contains(messageTexts(summarized), "secret") { + t.Fatalf("filtered message was summarized: %v", messageTexts(summarized)) + } +} + func TestNewHistoryProvider_LoadsCompactedSummaryAsHistory(t *testing.T) { session := agenttest.CreateSession() minimumPreservedGroups := 2 @@ -94,7 +134,7 @@ func TestNewHistoryProvider_LoadsCompactedSummaryAsHistory(t *testing.T) { if err != nil { t.Fatalf("load history: %v", err) } - if got, want := messageTexts(loaded), []string{"[Summary]\nolder context", "u2", "a2", "u3"}; !slices.Equal(got, want) { + if got, want := messageTexts(loaded), []string{"[Summary]\nolder context", "a2", "u3"}; !slices.Equal(got, want) { t.Fatalf("loaded history = %v, want %v", got, want) } if got, want := loaded[0].Source, (message.Source{Type: agent.SourceTypeHistoryProvider, ID: "compaction-history"}); got != want {