diff --git a/agent/context.go b/agent/context.go index 33dcf4cc..4fb58f91 100644 --- a/agent/context.go +++ b/agent/context.go @@ -149,13 +149,7 @@ func (p *defaultContextProvider) Invoking(ctx context.Context, invoking Invoking copy(outMessages, invoking.Messages) source := message.Source{Type: SourceTypeContextProvider, ID: p.config.SourceID} for _, msg := range providedMessages { - if msg == nil || msg.Source == source { - outMessages = append(outMessages, msg) - continue - } - marked := msg.Clone() - marked.Source = source - outMessages = append(outMessages, marked) + outMessages = append(outMessages, msg.WithSource(source)) } } diff --git a/agent/history.go b/agent/history.go index ab19ff32..142d1934 100644 --- a/agent/history.go +++ b/agent/history.go @@ -118,13 +118,7 @@ func (p *defaultHistoryProvider) Invoking(ctx context.Context, invoking Invoking outMessages := make([]*message.Message, 0, len(providedMessages)+len(invoking.Messages)) source := message.Source{Type: SourceTypeHistoryProvider, ID: p.config.SourceID} for _, msg := range providedMessages { - if msg == nil || msg.Source == source { - outMessages = append(outMessages, msg) - continue - } - marked := msg.Clone() - marked.Source = source - outMessages = append(outMessages, marked) + outMessages = append(outMessages, msg.WithSource(source)) } outMessages = append(outMessages, invoking.Messages...) diff --git a/agent/middleware.go b/agent/middleware.go index 46ad9875..c1e93837 100644 --- a/agent/middleware.go +++ b/agent/middleware.go @@ -71,13 +71,11 @@ func (mr middlewareRunner) Run(ctx context.Context, messages []*message.Message, if msg == nil || msg.Source != (message.Source{}) { continue } - marked := msg.Clone() - marked.Source = message.Source{Type: SourceTypeMiddleware} if !outMessagesCloned { outMessages = slices.Clone(outMessages) outMessagesCloned = true } - outMessages[i] = marked + outMessages[i] = msg.WithSource(message.Source{Type: SourceTypeMiddleware}) } return mr.next(ctx, outMessages, opts...) } diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 97afef6b..de62c35c 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -34,7 +34,7 @@ Intentional contract choices in this parity pass: | Feature area | .NET SDK | Go SDK | Status | Misalignment | | --- | --- | --- | --- | --- | | 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. | Partial | Go does not expose the same request source attribution helpers as .NET. | +| Agent identity and metadata | `AIAgentMetadata`, agent ID/name/description, source attribution extensions. | Agent ID/name/description, provider name, response author stamping, `message.Message` source attribution helpers. | Aligned | API shape differs: .NET exposes extension methods around `Microsoft.Extensions.AI`; Go exposes an explicit `message.Source` field plus helper methods. | | 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. | | 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. | diff --git a/message/message.go b/message/message.go index 7070cbf3..01cc37fd 100644 --- a/message/message.go +++ b/message/message.go @@ -75,6 +75,37 @@ func (m *Message) Usage() UsageDetails { return m.Contents.Usage() } +// SourceType returns the message source type. +// +// When no explicit source type is set, [SourceTypeExternal] is returned. +func (m *Message) SourceType() SourceType { + if m == nil { + return SourceTypeExternal + } + return m.Source.Type +} + +// SourceID returns the message source identifier. +func (m *Message) SourceID() string { + if m == nil { + return "" + } + return m.Source.ID +} + +// WithSource returns the message tagged with the provided source. +// +// If the message already has the requested source, the original message is +// returned. Otherwise, a cloned message is returned with the updated source. +func (m *Message) WithSource(source Source) *Message { + if m == nil || m.Source == source { + return m + } + v := m.Clone() + v.Source = source + return v +} + // Clone creates a shallow copy of the message, cloning its top-level map and // slice containers while sharing their values and content objects. func (m *Message) Clone() *Message { diff --git a/message/message_test.go b/message/message_test.go index d074d09d..d0f7f64a 100644 --- a/message/message_test.go +++ b/message/message_test.go @@ -27,6 +27,58 @@ func TestMessage_Clone_ClonesAdditionalProperties(t *testing.T) { } } +func TestMessage_SourceType_DefaultsToExternal(t *testing.T) { + msg := message.NewText("hello") + + if got := msg.SourceType(); got != message.SourceTypeExternal { + t.Fatalf("SourceType() = %q, want %q", got, message.SourceTypeExternal) + } +} + +func TestMessage_SourceTypeAndSourceID_ReturnExplicitSource(t *testing.T) { + msg := message.NewText("hello") + msg.Source = message.Source{Type: message.SourceType("context-provider"), ID: "ctx"} + + if got := msg.SourceType(); got != message.SourceType("context-provider") { + t.Fatalf("SourceType() = %q, want %q", got, message.SourceType("context-provider")) + } + if got := msg.SourceID(); got != "ctx" { + t.Fatalf("SourceID() = %q, want %q", got, "ctx") + } +} + +func TestMessage_WithSource_ClonesWhenSourceChanges(t *testing.T) { + original := message.NewText("hello") + original.AdditionalProperties = map[string]any{"k": "v"} + + got := original.WithSource(message.Source{Type: message.SourceType("history-provider"), ID: "history"}) + if got == nil { + t.Fatal("expected sourced message") + } + if got == original { + t.Fatal("expected WithSource to clone when source changes") + } + if got.Source != (message.Source{Type: message.SourceType("history-provider"), ID: "history"}) { + t.Fatalf("WithSource source = %#v", got.Source) + } + if got.AdditionalProperties["k"] != "v" { + t.Fatalf("expected cloned additional properties, got %v", got.AdditionalProperties["k"]) + } + if original.Source != (message.Source{}) { + t.Fatalf("expected original source to remain unchanged, got %#v", original.Source) + } +} + +func TestMessage_WithSource_ReturnsOriginalWhenUnchanged(t *testing.T) { + original := message.NewText("hello") + original.Source = message.Source{Type: message.SourceType("context-provider"), ID: "ctx"} + + got := original.WithSource(message.Source{Type: message.SourceType("context-provider"), ID: "ctx"}) + if got != original { + t.Fatal("expected WithSource to return original message when source is unchanged") + } +} + func TestMessage_Clone_ClonesContentsSlice(t *testing.T) { content := &message.TextContent{Text: "original"} original := message.New(content)