diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 97afef6b..25d4dd4f 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, isolation-key scoped task-store wrapper for multi-tenant hosts, incoming request metadata forwarding, task-carried background continuation, and end-to-end client/server sample. | Partial | Go now includes task-store isolation scoping for multi-tenant A2A hosts and 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. | | 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/provider/a2aprovider/isolation_taskstore_test.go b/provider/a2aprovider/isolation_taskstore_test.go new file mode 100644 index 00000000..8ee7f7bb --- /dev/null +++ b/provider/a2aprovider/isolation_taskstore_test.go @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft. All rights reserved. + +package a2aprovider_test + +import ( + "context" + "errors" + "iter" + "testing" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv/taskstore" + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/provider/a2aprovider" +) + +type isolationKeyContextKey struct{} + +func contextWithIsolationKey(ctx context.Context, key string) context.Context { + return context.WithValue(ctx, isolationKeyContextKey{}, key) +} + +func isolationKeyProvider(ctx context.Context) (string, error) { + key, _ := ctx.Value(isolationKeyContextKey{}).(string) + return key, nil +} + +type isolationTestInterceptor struct { + a2asrv.PassthroughCallInterceptor + user string + key string +} + +func (i isolationTestInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallContext, _ *a2asrv.Request) (context.Context, any, error) { + callCtx.User = a2asrv.NewAuthenticatedUser(i.user, nil) + return contextWithIsolationKey(ctx, i.key), nil, nil +} + +func TestIsolationKeyScopedTaskStore_AllowsDuplicateBareTaskIDsAcrossKeys(t *testing.T) { + baseStore := taskstore.NewInMemory(&taskstore.InMemoryStoreConfig{ + Authenticator: func(context.Context) (string, error) { return "alice", nil }, + }) + store := a2aprovider.NewIsolationKeyScopedTaskStore(baseStore, isolationKeyProvider, true) + + taskA := &a2a.Task{ID: "task-1", ContextID: "ctx-1"} + if _, err := store.Create(contextWithIsolationKey(context.Background(), "tenant-a"), taskA); err != nil { + t.Fatalf("Create(tenant-a) returned error: %v", err) + } + taskB := &a2a.Task{ID: "task-1", ContextID: "ctx-1"} + if _, err := store.Create(contextWithIsolationKey(context.Background(), "tenant-b"), taskB); err != nil { + t.Fatalf("Create(tenant-b) returned error: %v", err) + } + + gotA, err := store.Get(contextWithIsolationKey(context.Background(), "tenant-a"), "task-1") + if err != nil { + t.Fatalf("Get(tenant-a) returned error: %v", err) + } + if gotA.Task.ID != "task-1" || gotA.Task.ContextID != "ctx-1" { + t.Fatalf("tenant-a task = (%q, %q), want bare IDs", gotA.Task.ID, gotA.Task.ContextID) + } + if _, err := store.Get(contextWithIsolationKey(context.Background(), "tenant-c"), "task-1"); !errors.Is(err, a2a.ErrTaskNotFound) { + t.Fatalf("Get(tenant-c) error = %v, want %v", err, a2a.ErrTaskNotFound) + } + + listA, err := store.List(contextWithIsolationKey(context.Background(), "tenant-a"), &a2a.ListTasksRequest{}) + if err != nil { + t.Fatalf("List(tenant-a) returned error: %v", err) + } + if len(listA.Tasks) != 1 { + t.Fatalf("List(tenant-a) task count = %d, want 1", len(listA.Tasks)) + } + if listA.Tasks[0].ID != "task-1" || listA.Tasks[0].ContextID != "ctx-1" { + t.Fatalf("List(tenant-a) task = (%q, %q), want bare IDs", listA.Tasks[0].ID, listA.Tasks[0].ContextID) + } + if listA.TotalSize != 2 { + t.Fatalf("List(tenant-a) totalSize = %d, want 2", listA.TotalSize) + } + if listA.PageSize != 1 { + t.Fatalf("List(tenant-a) pageSize = %d, want 1", listA.PageSize) + } +} + +func TestIsolationKeyScopedTaskStore_UpdateAndListUseScopedContext(t *testing.T) { + baseStore := taskstore.NewInMemory(&taskstore.InMemoryStoreConfig{ + Authenticator: func(context.Context) (string, error) { return "alice", nil }, + }) + store := a2aprovider.NewIsolationKeyScopedTaskStore(baseStore, isolationKeyProvider, true) + ctxA := contextWithIsolationKey(context.Background(), "tenant-a") + ctxB := contextWithIsolationKey(context.Background(), "tenant-b") + + task := &a2a.Task{ + ID: "task-1", + ContextID: "ctx-1", + Status: a2a.TaskStatus{State: a2a.TaskStateSubmitted}, + } + version, err := store.Create(ctxA, task) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + + updated := &a2a.Task{ + ID: "task-1", + ContextID: "ctx-1", + Status: a2a.TaskStatus{State: a2a.TaskStateCompleted}, + } + if _, err := store.Update(ctxA, &taskstore.UpdateRequest{ + Task: updated, + PrevTask: task, + PrevVersion: version, + }); err != nil { + t.Fatalf("Update(tenant-a) returned error: %v", err) + } + if _, err := store.Update(ctxB, &taskstore.UpdateRequest{ + Task: updated, + PrevTask: task, + PrevVersion: version, + }); !errors.Is(err, a2a.ErrTaskNotFound) { + t.Fatalf("Update(tenant-b) error = %v, want %v", err, a2a.ErrTaskNotFound) + } + + listA, err := store.List(ctxA, &a2a.ListTasksRequest{ContextID: "ctx-1", Status: a2a.TaskStateCompleted}) + if err != nil { + t.Fatalf("List(tenant-a) returned error: %v", err) + } + if len(listA.Tasks) != 1 { + t.Fatalf("List(tenant-a) task count = %d, want 1", len(listA.Tasks)) + } + if listA.Tasks[0].ContextID != "ctx-1" { + t.Fatalf("List(tenant-a) context id = %q, want %q", listA.Tasks[0].ContextID, "ctx-1") + } + + listB, err := store.List(ctxB, &a2a.ListTasksRequest{ContextID: "ctx-1"}) + if err != nil { + t.Fatalf("List(tenant-b) returned error: %v", err) + } + if len(listB.Tasks) != 0 { + t.Fatalf("List(tenant-b) task count = %d, want 0", len(listB.Tasks)) + } +} + +func TestIsolationKeyScopedTaskStore_StrictModeRequiresKey(t *testing.T) { + baseStore := taskstore.NewInMemory(&taskstore.InMemoryStoreConfig{ + Authenticator: func(context.Context) (string, error) { return "alice", nil }, + }) + store := a2aprovider.NewIsolationKeyScopedTaskStore(baseStore, isolationKeyProvider, true) + + _, err := store.Create(context.Background(), &a2a.Task{ID: "task-1", ContextID: "ctx-1"}) + if err == nil { + t.Fatal("Create() error = nil, want non-nil") + } + if !errors.Is(err, a2aprovider.ErrTaskStoreIsolationKeyRequired) { + t.Fatalf("Create() error = %v, want ErrTaskStoreIsolationKeyRequired", err) + } +} + +func TestRequestHandler_WithIsolationKeyScopedTaskStore_IsolatesTasksByKey(t *testing.T) { + hostedAgent := newHostedTestAgent(func(_ context.Context, _ []*message.Message, _ ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{ + MessageID: "m1", + Role: message.RoleAssistant, + Contents: message.Contents{&message.TextContent{Text: "hello from agent"}}, + }, nil) + } + }) + + baseStore := taskstore.NewInMemory(&taskstore.InMemoryStoreConfig{ + Authenticator: a2asrv.NewTaskStoreAuthenticator(), + }) + store := a2aprovider.NewIsolationKeyScopedTaskStore(baseStore, isolationKeyProvider, true) + handlerA := newRequestHandler( + hostedAgent, + a2aprovider.ExecutorConfig{}, + a2asrv.WithTaskStore(store), + a2asrv.WithCallInterceptors(isolationTestInterceptor{user: "alice", key: "tenant-a"}), + ) + handlerB := newRequestHandler( + hostedAgent, + a2aprovider.ExecutorConfig{}, + a2asrv.WithTaskStore(store), + a2asrv.WithCallInterceptors(isolationTestInterceptor{user: "alice", key: "tenant-b"}), + ) + + task := collectFirstStreamingTask(t, handlerA.SendStreamingMessage(context.Background(), &a2a.SendMessageRequest{ + Message: a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("ping")), + })) + if task.ID == "" { + t.Fatal("expected task id") + } + + gotTask, err := handlerA.GetTask(context.Background(), &a2a.GetTaskRequest{ID: task.ID}) + if err != nil { + t.Fatalf("GetTask(tenant-a) returned error: %v", err) + } + if gotTask.ID != task.ID { + t.Fatalf("GetTask(tenant-a) task id = %q, want %q", gotTask.ID, task.ID) + } + if _, err := handlerB.GetTask(context.Background(), &a2a.GetTaskRequest{ID: task.ID}); !errors.Is(err, a2a.ErrTaskNotFound) { + t.Fatalf("GetTask(tenant-b) error = %v, want %v", err, a2a.ErrTaskNotFound) + } + + listB, err := handlerB.ListTasks(context.Background(), &a2a.ListTasksRequest{}) + if err != nil { + t.Fatalf("ListTasks(tenant-b) returned error: %v", err) + } + if len(listB.Tasks) != 0 { + t.Fatalf("ListTasks(tenant-b) task count = %d, want 0", len(listB.Tasks)) + } + if listB.TotalSize != 1 { + t.Fatalf("ListTasks(tenant-b) totalSize = %d, want 1", listB.TotalSize) + } + if listB.PageSize != 0 { + t.Fatalf("ListTasks(tenant-b) pageSize = %d, want 0", listB.PageSize) + } +} diff --git a/provider/a2aprovider/taskstore.go b/provider/a2aprovider/taskstore.go new file mode 100644 index 00000000..0a2fbf45 --- /dev/null +++ b/provider/a2aprovider/taskstore.go @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft. All rights reserved. + +package a2aprovider + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv/taskstore" +) + +// ErrTaskStoreIsolationKeyRequired is returned by a strict +// [IsolationKeyScopedTaskStore] when no isolation key is available for an +// operation. Callers can match it with [errors.Is]. +var ErrTaskStoreIsolationKeyRequired = errors.New("task store isolation key is required but was not provided") + +// TaskStoreIsolationKeyProvider returns the current logical isolation key used +// to scope task-store operations. Returning an empty string disables scoping for +// that call unless [NewIsolationKeyScopedTaskStore] was configured as strict. +type TaskStoreIsolationKeyProvider func(context.Context) (string, error) + +// IsolationKeyScopedTaskStore scopes A2A task-store operations by an isolation +// key so callers with different logical identities can reuse bare task and +// context IDs without seeing each other's tasks. +type IsolationKeyScopedTaskStore struct { + inner taskstore.Store + keyProvider TaskStoreIsolationKeyProvider + strict bool +} + +var _ taskstore.Store = (*IsolationKeyScopedTaskStore)(nil) + +// NewIsolationKeyScopedTaskStore wraps an existing A2A task store with +// isolation-key scoping. It panics if inner is nil. +func NewIsolationKeyScopedTaskStore(inner taskstore.Store, keyProvider TaskStoreIsolationKeyProvider, strict bool) *IsolationKeyScopedTaskStore { + if inner == nil { + panic("a2aprovider: task store cannot be nil") + } + return &IsolationKeyScopedTaskStore{ + inner: inner, + keyProvider: keyProvider, + strict: strict, + } +} + +func (s *IsolationKeyScopedTaskStore) Create(ctx context.Context, task *a2a.Task) (taskstore.TaskVersion, error) { + key, err := s.isolationKey(ctx) + if err != nil { + return taskstore.TaskVersionMissing, err + } + scopedTask, err := scopeTask(task, key) + if err != nil { + return taskstore.TaskVersionMissing, err + } + return s.inner.Create(ctx, scopedTask) +} + +func (s *IsolationKeyScopedTaskStore) Update(ctx context.Context, req *taskstore.UpdateRequest) (taskstore.TaskVersion, error) { + key, err := s.isolationKey(ctx) + if err != nil { + return taskstore.TaskVersionMissing, err + } + scopedTask, err := scopeTask(req.Task, key) + if err != nil { + return taskstore.TaskVersionMissing, err + } + scopedPrevTask, err := scopeTask(req.PrevTask, key) + if err != nil { + return taskstore.TaskVersionMissing, err + } + return s.inner.Update(ctx, &taskstore.UpdateRequest{ + Task: scopedTask, + Event: req.Event, + PrevTask: scopedPrevTask, + PrevVersion: req.PrevVersion, + }) +} + +func (s *IsolationKeyScopedTaskStore) Get(ctx context.Context, taskID a2a.TaskID) (*taskstore.StoredTask, error) { + key, err := s.isolationKey(ctx) + if err != nil { + return nil, err + } + storedTask, err := s.inner.Get(ctx, a2a.TaskID(scopeID(string(taskID), key))) + if err != nil { + return nil, err + } + task, err := unscopeTask(storedTask.Task, key) + if err != nil { + return nil, err + } + return &taskstore.StoredTask{ + Task: task, + Version: storedTask.Version, + User: storedTask.User, + }, nil +} + +func (s *IsolationKeyScopedTaskStore) List(ctx context.Context, req *a2a.ListTasksRequest) (*a2a.ListTasksResponse, error) { + key, err := s.isolationKey(ctx) + if err != nil { + return nil, err + } + request := req + if key != "" && req.ContextID != "" { + request = cloneListTasksRequestWithContextID(req, scopeID(req.ContextID, key)) + } + response, err := s.inner.List(ctx, request) + if err != nil { + return nil, err + } + if key == "" { + return response, nil + } + + scopedTasks := make([]*a2a.Task, 0, len(response.Tasks)) + for _, task := range response.Tasks { + if !taskIsInScope(task, key) { + continue + } + unscopedTask, err := unscopeTask(task, key) + if err != nil { + return nil, err + } + scopedTasks = append(scopedTasks, unscopedTask) + } + + out := *response + out.Tasks = scopedTasks + out.PageSize = len(scopedTasks) + return &out, nil +} + +func (s *IsolationKeyScopedTaskStore) isolationKey(ctx context.Context) (string, error) { + if s.keyProvider == nil { + if s.strict { + return "", ErrTaskStoreIsolationKeyRequired + } + return "", nil + } + + key, err := s.keyProvider(ctx) + if err != nil { + return "", err + } + if key == "" && s.strict { + return "", ErrTaskStoreIsolationKeyRequired + } + return key, nil +} + +func scopeTask(task *a2a.Task, key string) (*a2a.Task, error) { + if task == nil || key == "" { + return task, nil + } + return cloneTask(task, func(clone *a2a.Task) { + clone.ID = a2a.TaskID(scopeID(string(clone.ID), key)) + clone.ContextID = scopeID(clone.ContextID, key) + }) +} + +func unscopeTask(task *a2a.Task, key string) (*a2a.Task, error) { + if task == nil || key == "" { + return task, nil + } + return cloneTask(task, func(clone *a2a.Task) { + clone.ID = a2a.TaskID(unscopeID(string(clone.ID), key)) + clone.ContextID = unscopeID(clone.ContextID, key) + }) +} + +func cloneTask(task *a2a.Task, mutate func(*a2a.Task)) (*a2a.Task, error) { + data, err := json.Marshal(task) + if err != nil { + return nil, err + } + var clone a2a.Task + if err := json.Unmarshal(data, &clone); err != nil { + return nil, err + } + mutate(&clone) + return &clone, nil +} + +func cloneListTasksRequestWithContextID(req *a2a.ListTasksRequest, contextID string) *a2a.ListTasksRequest { + clone := *req + clone.ContextID = contextID + return &clone +} + +func taskIsInScope(task *a2a.Task, key string) bool { + return strings.HasPrefix(task.ContextID, scopedPrefix(key)) +} + +func scopeID(id string, key string) string { + if key == "" { + return id + } + return scopedPrefix(key) + id +} + +func unscopeID(scopedID string, key string) string { + prefix := scopedPrefix(key) + if !strings.HasPrefix(scopedID, prefix) { + return scopedID + } + return scopedID[len(prefix):] +} + +func scopedPrefix(key string) string { + return escapeIsolationKey(key) + "::" +} + +func escapeIsolationKey(key string) string { + key = strings.ReplaceAll(key, `\`, `\\`) + return strings.ReplaceAll(key, ":", `\:`) +}