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
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ jobs:
needs: [test, copilot-integration]
runs-on: ubuntu-latest
if: ${{ always() }}
permissions: {}
steps:
- name: Result
run: |
Expand Down
9 changes: 9 additions & 0 deletions agent/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package agent

import (
"iter"
"maps"
"reflect"
"slices"
"strings"
Expand Down Expand Up @@ -31,6 +32,7 @@ type (
toolModeOpt tool.ToolMode
streamOpt bool
allowBackgroundResponsesOpt bool
additionalPropertiesOpt struct{ props map[string]any }

structuredOutputOpt struct{ any }
)
Expand All @@ -40,6 +42,7 @@ func (o streamOpt) MAFValue() any { return bool(o) }
func (o continuationTokenOpt) MAFValue() any { return string(o) }
func (o instructionsOpt) MAFValue() any { return string(o) }
func (o allowBackgroundResponsesOpt) MAFValue() any { return bool(o) }
func (o additionalPropertiesOpt) MAFValue() any { return o.props }
func (o toolModeOpt) MAFValue() any { return tool.ToolMode(o) }
func (o toolOpt) MAFValue() any { return o.Tool }
func (o structuredOutputOpt) MAFValue() any { return o.any }
Expand Down Expand Up @@ -169,3 +172,9 @@ func WithInstructions(instructions string) Option {
func AllowBackgroundResponses(allow bool) Option {
return allowBackgroundResponsesOpt(allow)
}

// WithAdditionalProperties sets provider-specific or protocol-specific request
// metadata for an agent run.
func WithAdditionalProperties(props map[string]any) Option {
return additionalPropertiesOpt{props: maps.Clone(props)}
}
2 changes: 1 addition & 1 deletion docs/dotnet-go-sdk-feature-comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Intentional contract choices in this parity pass:
| Anthropic provider | Anthropic packages and reasoning/skills/function-tool samples. | `anthropicprovider.NewAgent`, message params option. | Partial | Go has provider support, but sample coverage is smaller. |
| Gemini/provider ecosystem | Google Gemini sample through provider adapters. | `geminiprovider.NewAgent`, generate content config option. | Aligned | .NET reaches more providers through generic `IChatClient` adapters; Go has a direct Gemini package. |
| A2A agent client | `Microsoft.Agents.AI.A2A`, card/client extensions, request metadata, and `A2AAgentSession` with one current context/task ID. | `provider/a2aprovider`, session-only `WithTaskID`, `TaskIDFromSession`, and provider-specific `WithMetadata`, with one current task ID per session. Direct messages and completed tasks finish with `stop`; non-streaming task artifacts remain distinct response messages. | Aligned | Go maps .NET's typed `CreateSessionAsync(contextId, taskId)` overload to `Agent.CreateSession(WithServiceID(contextId), WithTaskID(taskId))`. Both validate nonblank IDs and replace or clear the current task ID as the conversation advances. |
| A2A hosting | A2A hosting packages, ASP.NET Core hosting, task continuation, samples. | `provider/a2aprovider`, executor for a2a-go JSON-RPC and JSON HTTP handlers, incoming request metadata forwarding, task-carried background continuation, and end-to-end client/server sample. | Partial | Go integrates with a2a-go HTTP handlers but does not provide ASP.NET-style hosting/DI integration. It creates fresh native sessions, keeps A2A context IDs separate from provider session IDs, and restores framework continuation tokens from persisted A2A task metadata without a session store. The a2a-go `ExecutorContext` does not expose `SendMessageRequest.Config`, so request configuration cannot currently be forwarded to hosted agents as .NET does. |
| A2A hosting | A2A hosting packages, ASP.NET Core hosting, task continuation, samples. | `provider/a2aprovider`, executor for a2a-go JSON-RPC and JSON HTTP handlers, incoming request metadata and configuration forwarding, task-carried background continuation, and end-to-end client/server sample. | Partial | Go integrates with a2a-go HTTP handlers but does not provide ASP.NET-style hosting/DI integration. It creates fresh native sessions, keeps A2A context IDs separate from provider session IDs, restores framework continuation tokens from persisted A2A task metadata without a session store, and forwards inbound request configuration to hosted-agent additional properties. |
| AGUI agent/client | AGUI chat client and shared conversions. | `provider/aguiprovider`, AGUI SSE client integration. | Aligned | Type models differ but feature categories line up. |
| AGUI hosting | ASP.NET Core AGUI hosting, end-to-end web chat samples. | `provider/aguiprovider`, JSON HTTP handler, backend/frontend tools, HITL, state examples, reasoning event emission. | Partial | Go has handlers and examples, but no ASP.NET/Blazor-style end-to-end web app equivalent. |
| Azure AI Persistent agents | `Microsoft.Agents.AI.AzureAI.Persistent`, lifecycle and persistent conversation samples. | No equivalent package. | .NET only | Go currently uses OpenAI/Azure OpenAI clients, not Azure AI Persistent Agents. |
Expand Down
5 changes: 3 additions & 2 deletions examples/05-end-to-end/a2a_client_server/a2a_server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ func main() {
a2a.NewAgentInterface(url, a2a.TransportProtocolJSONRPC),
}
mux := http.NewServeMux()
requestHandler := a2asrv.NewHandler(
a2aprovider.NewExecutor(hostAgent, a2aprovider.ExecutorConfig{}),
requestHandler := a2aprovider.NewHandler(
hostAgent,
a2aprovider.ExecutorConfig{},
a2asrv.WithExtendedAgentCard(card),
)
mux.Handle("/", a2asrv.NewJSONRPCHandler(requestHandler))
Expand Down
5 changes: 5 additions & 0 deletions provider/a2aprovider/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,11 @@ func (e *executor) newRunOptions(ctx context.Context, execCtx *a2asrv.ExecutorCo
}
if execCtx.Metadata != nil {
runOptions = append(runOptions, WithMetadata(execCtx.Metadata))
if config, ok := execCtx.Metadata[RequestConfigurationPropertyKey]; ok {
runOptions = append(runOptions, agent.WithAdditionalProperties(map[string]any{
RequestConfigurationPropertyKey: config,
}))
}
}
if stream {
runOptions = append(runOptions, agent.Stream(true))
Expand Down
50 changes: 50 additions & 0 deletions provider/a2aprovider/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.

package a2aprovider

import (
"context"
"maps"

"github.com/a2aproject/a2a-go/v2/a2a"
"github.com/a2aproject/a2a-go/v2/a2asrv"
"github.com/microsoft/agent-framework-go/agent"
)

// RequestConfigurationPropertyKey is the agent additional-properties key under
// which inbound A2A message/send configuration is forwarded to hosted agents.
const RequestConfigurationPropertyKey = "a2a.configuration"

// NewHandler creates an A2A request handler for a hosted agent and forwards
// inbound A2A request configuration to the hosted agent's run options.
func NewHandler(hostedAgent *agent.Agent, cfg ExecutorConfig, options ...a2asrv.RequestHandlerOption) a2asrv.RequestHandler {
options = append([]a2asrv.RequestHandlerOption{WithRequestConfigForwarding()}, options...)
return a2asrv.NewHandler(NewExecutor(hostedAgent, cfg), options...)
}

// WithRequestConfigForwarding returns an A2A request-handler option that
// forwards the inbound [a2a.SendMessageRequest.Config] to hosted agents through
// [agent.WithAdditionalProperties] under [RequestConfigurationPropertyKey].
func WithRequestConfigForwarding() a2asrv.RequestHandlerOption {
return a2asrv.WithCallInterceptors(requestConfigForwardingInterceptor{})
}

type requestConfigForwardingInterceptor struct {
a2asrv.PassthroughCallInterceptor
}

func (requestConfigForwardingInterceptor) Before(ctx context.Context, _ *a2asrv.CallContext, req *a2asrv.Request) (context.Context, any, error) {
sendReq, ok := req.Payload.(*a2a.SendMessageRequest)
if !ok || sendReq == nil || sendReq.Config == nil {
return ctx, nil, nil
}

cloned := *sendReq
cloned.Metadata = maps.Clone(sendReq.Metadata)
if cloned.Metadata == nil {
cloned.Metadata = map[string]any{}
}
cloned.Metadata[RequestConfigurationPropertyKey] = cloned.Config
req.Payload = &cloned
return ctx, nil, nil
}
84 changes: 83 additions & 1 deletion provider/a2aprovider/hosting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func newHostedTestAgent(runFn func(context.Context, []*message.Message, ...agent
}

func newRequestHandler(hostedAgent *agent.Agent, cfg a2aprovider.ExecutorConfig, options ...a2asrv.RequestHandlerOption) a2asrv.RequestHandler {
return a2asrv.NewHandler(a2aprovider.NewExecutor(hostedAgent, cfg), options...)
return a2aprovider.NewHandler(hostedAgent, cfg, options...)
}

func TestNewExecutor_PanicsWithoutAgent(t *testing.T) {
Expand Down Expand Up @@ -114,6 +114,53 @@ func TestRequestHandler_OnSendMessage_PreservesContextID(t *testing.T) {
}
}

func TestRequestHandler_OnSendMessage_ForwardsMetadataAndConfigurationToHostedAgent(t *testing.T) {
historyLength := 10
config := &a2a.SendMessageConfig{
AcceptedOutputModes: []string{"text/plain", "image/png"},
HistoryLength: &historyLength,
}

var additionalProperties map[string]any
var runMetadata map[string]any
a := newHostedTestAgent(func(_ context.Context, _ []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] {
additionalProperties, _ = agent.GetOption(options, agent.WithAdditionalProperties)
runMetadata, _ = agent.GetOption(options, a2aprovider.WithMetadata)
return func(yield func(*agent.ResponseUpdate, error) bool) {
yield(&agent.ResponseUpdate{
MessageID: "m-config",
Role: message.RoleAssistant,
Contents: message.Contents{&message.TextContent{Text: "done"}},
}, nil)
}
})

h := newRequestHandler(a, a2aprovider.ExecutorConfig{})
_, err := h.SendMessage(context.Background(), &a2a.SendMessageRequest{
Config: config,
Message: a2a.NewMessage(
a2a.MessageRoleUser,
a2a.NewTextPart("ping"),
),
Metadata: map[string]any{
"key1": "value1",
},
})
if err != nil {
t.Fatalf("OnSendMessage returned error: %v", err)
}

if len(additionalProperties) != 1 {
t.Fatalf("additional property count = %d, want %d", len(additionalProperties), 1)
}
if got := runMetadata["key1"]; got != "value1" {
t.Fatalf("metadata key1 = %v, want %q", got, "value1")
}
if got := additionalProperties[a2aprovider.RequestConfigurationPropertyKey]; got != config {
t.Fatalf("forwarded config = %#v, want %#v", got, config)
}
}

func TestRequestHandler_ContextIDIsNotProviderSessionID(t *testing.T) {
var serviceID string
a := newHostedTestAgent(func(_ context.Context, _ []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] {
Expand Down Expand Up @@ -165,6 +212,41 @@ func TestRequestHandler_ContextIDIsNotProviderSessionIDAndMetadataIsForwarded(t
}
}

func TestRequestHandler_OnSendMessage_WhenConfigurationRequestsImmediateReturn_DoesNotOverrideRunMode(t *testing.T) {
var (
allowBackground bool
additionalProperties map[string]any
)
config := &a2a.SendMessageConfig{ReturnImmediately: true}
a := newHostedTestAgent(func(_ context.Context, _ []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] {
allowBackground, _ = agent.GetOption(options, agent.AllowBackgroundResponses)
additionalProperties, _ = agent.GetOption(options, agent.WithAdditionalProperties)
return func(yield func(*agent.ResponseUpdate, error) bool) {
yield(&agent.ResponseUpdate{
MessageID: "m-run-mode",
Role: message.RoleAssistant,
Contents: message.Contents{&message.TextContent{Text: "done"}},
}, nil)
}
})

h := newRequestHandler(a, a2aprovider.ExecutorConfig{})
_, err := h.SendMessage(context.Background(), &a2a.SendMessageRequest{
Config: config,
Message: a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("ping")),
})
if err != nil {
t.Fatalf("OnSendMessage returned error: %v", err)
}

if allowBackground {
t.Fatal("expected AllowBackgroundResponses=false")
}
if got := additionalProperties[a2aprovider.RequestConfigurationPropertyKey]; got != config {
t.Fatalf("forwarded config = %#v, want %#v", got, config)
}
}

func TestRequestHandler_OnSendMessageContinuation_UsesStoredTaskHistoryOnly(t *testing.T) {
var callCount int
var continuationInputs []string
Expand Down
Loading