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..b3cdc46f --- /dev/null +++ b/agent/compaction/historyprovider.go @@ -0,0 +1,240 @@ +// 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 +} + +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 { + 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) + 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) { + 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..d043c0be --- /dev/null +++ b/agent/compaction/historyprovider_test.go @@ -0,0 +1,143 @@ +// 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" + "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) { + 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{"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_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 + 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", "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,