diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e44037ed..a7dd2790 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,6 +78,7 @@ jobs: needs: [test, copilot-integration] runs-on: ubuntu-latest if: ${{ always() }} + permissions: {} steps: - name: Result run: | diff --git a/agent/options.go b/agent/options.go index 08613b37..1423b5e6 100644 --- a/agent/options.go +++ b/agent/options.go @@ -4,6 +4,7 @@ package agent import ( "iter" + "maps" "reflect" "slices" "strings" @@ -31,6 +32,7 @@ type ( toolModeOpt tool.ToolMode streamOpt bool allowBackgroundResponsesOpt bool + additionalPropertiesOpt struct{ props map[string]any } structuredOutputOpt struct{ any } ) @@ -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 } @@ -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)} +} diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 97afef6b..0f36415b 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -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. | diff --git a/examples/05-end-to-end/a2a_client_server/a2a_server/main.go b/examples/05-end-to-end/a2a_client_server/a2a_server/main.go index c0d9ad9f..073b93c2 100644 --- a/examples/05-end-to-end/a2a_client_server/a2a_server/main.go +++ b/examples/05-end-to-end/a2a_client_server/a2a_server/main.go @@ -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)) diff --git a/provider/a2aprovider/executor.go b/provider/a2aprovider/executor.go index 423599c8..dbb060e3 100644 --- a/provider/a2aprovider/executor.go +++ b/provider/a2aprovider/executor.go @@ -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)) diff --git a/provider/a2aprovider/handler.go b/provider/a2aprovider/handler.go new file mode 100644 index 00000000..1561bd8c --- /dev/null +++ b/provider/a2aprovider/handler.go @@ -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 +} diff --git a/provider/a2aprovider/hosting_test.go b/provider/a2aprovider/hosting_test.go index c1debe82..2439f739 100644 --- a/provider/a2aprovider/hosting_test.go +++ b/provider/a2aprovider/hosting_test.go @@ -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) { @@ -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] { @@ -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