diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json index 40b60b50e60..c767d8691fb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/hosted-agent-regions.json @@ -21,6 +21,7 @@ "switzerlandnorth", "uksouth", "westus", + "westus2", "westus3" ] } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 606cab5610f..31f89da1ffa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -20,6 +20,7 @@ import ( osExec "os/exec" "path/filepath" "regexp" + "slices" "strings" "time" @@ -657,6 +658,51 @@ func preBuiltImageForInit(agentManifest *agent_yaml.AgentManifest, flagImage str return strings.TrimSpace(ca.Image) } +func protocolRecordsForImageManifest(flagProtocols []string) ([]agent_yaml.ProtocolVersionRecord, error) { + if len(flagProtocols) == 0 { + return []agent_yaml.ProtocolVersionRecord{{Protocol: "responses", Version: "2.0.0"}}, nil + } + if slices.Contains(flagProtocols, "activity") { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "--protocol activity is not supported with --image", + "use code init for activity agents, or choose a different protocol for --image", + ) + } + return resolveKnownProtocols(flagProtocols) +} + +// resolveKnownProtocols validates and resolves the given protocol names against the +// known protocols list, returning the corresponding ProtocolVersionRecords. Duplicate +// names are silently deduplicated; unknown names produce a validation error. +func resolveKnownProtocols(flagProtocols []string) ([]agent_yaml.ProtocolVersionRecord, error) { + versionOf := make(map[string]string, len(knownProtocols)) + for _, p := range knownProtocols { + versionOf[p.Name] = p.Version + } + + seen := make(map[string]bool, len(flagProtocols)) + records := make([]agent_yaml.ProtocolVersionRecord, 0, len(flagProtocols)) + for _, name := range flagProtocols { + if seen[name] { + continue + } + seen[name] = true + + version, ok := versionOf[name] + if !ok { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("unknown protocol %q; supported values: %s", name, knownProtocolNames()), + "choose one of the supported protocols or omit --protocol to use the default", + ) + } + records = append(records, agent_yaml.ProtocolVersionRecord{Protocol: name, Version: version}) + } + + return records, nil +} + // synthesizeImageManifestFile writes a minimal hosted container agent manifest to a // temporary file for the bring-your-own-image flow (--image without --manifest). // Routing through the manifest path lets init skip template/language selection and code @@ -664,8 +710,12 @@ func preBuiltImageForInit(agentManifest *agent_yaml.AgentManifest, flagImage str // the temp directory; callers should defer it. The image is intentionally not embedded // in the temporary manifest; init writes --image to the generated azure.yaml service's // top-level image field. -func synthesizeImageManifestFile(agentName, image string) (string, func(), error) { +func synthesizeImageManifestFile(agentName, image string, flagProtocols []string) (string, func(), error) { noop := func() {} + protocols, err := protocolRecordsForImageManifest(flagProtocols) + if err != nil { + return "", noop, err + } tmpDir, err := os.MkdirTemp("", "azd-agent-image-") if err != nil { @@ -673,15 +723,18 @@ func synthesizeImageManifestFile(agentName, image string) (string, func(), error } cleanup := func() { _ = os.RemoveAll(tmpDir) } + protocolDocs := make([]map[string]any, 0, len(protocols)) + for _, p := range protocols { + protocolDocs = append(protocolDocs, map[string]any{"protocol": p.Protocol, "version": p.Version}) + } + doc := map[string]any{ "name": agentName, "template": map[string]any{ "kind": string(agent_yaml.AgentKindHosted), "name": agentName, "description": fmt.Sprintf("Hosted container agent using pre-built image %s", image), - "protocols": []map[string]any{ - {"protocol": "responses", "version": "2.0.0"}, - }, + "protocols": protocolDocs, }, } @@ -1143,7 +1196,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "pass --agent-name (or provide --manifest with the agent definition)", ) } - manifestPath, cleanup, err := synthesizeImageManifestFile(flags.agentName, flags.image) + manifestPath, cleanup, err := synthesizeImageManifestFile(flags.agentName, flags.image, flags.protocols) if err != nil { return err } @@ -1510,7 +1563,8 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Directory to download the agent definition to (defaults to 'src/')") cmd.Flags().StringSliceVar(&flags.protocols, "protocol", nil, - "Protocols supported by the agent (e.g., 'responses', 'invocations'). Can be specified multiple times.") + "Protocols supported by the agent (e.g., 'responses', 'invocations', 'invocations_ws'). "+ + "Can be specified multiple times.") cmd.Flags().StringVar(&flags.deployMode, "deploy-mode", "", "Deployment mode: 'container' (Docker image) or 'code' (ZIP upload). Defaults to 'code' for Python/.NET projects in --no-prompt.") diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 7eb3264bd54..252ec748d58 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -906,6 +906,7 @@ type protocolInfo struct { var knownProtocols = []protocolInfo{ {Name: "responses", Version: "2.0.0"}, {Name: "invocations", Version: "1.0.0"}, + {Name: "invocations_ws", Version: "2.0.0"}, // "activity" is the canonical protocol name (legacy alias: "activity_protocol"). // The version selects the platform's internal container route ("v1"/"1.0.0" -> // /api/messages, "2.0.0" -> /activity/messages), but that hop is Bot Service -> @@ -925,37 +926,9 @@ func promptProtocols( noPrompt bool, flagProtocols []string, ) ([]agent_yaml.ProtocolVersionRecord, error) { - // Build a lookup from protocol name → version for known protocols. - versionOf := make(map[string]string, len(knownProtocols)) - for _, p := range knownProtocols { - versionOf[p.Name] = p.Version - } - - // If explicit flag values were provided, use them directly (with dedup). + // If explicit flag values were provided, delegate to the shared resolver (with dedup). if len(flagProtocols) > 0 { - seen := make(map[string]bool, len(flagProtocols)) - records := make([]agent_yaml.ProtocolVersionRecord, 0, len(flagProtocols)) - for _, name := range flagProtocols { - if seen[name] { - continue - } - seen[name] = true - - version, ok := versionOf[name] - if !ok { - return nil, exterrors.Validation( - exterrors.CodeInvalidAgentManifest, - fmt.Sprintf("unknown protocol %q; supported values: %s", - name, knownProtocolNames()), - fmt.Sprintf("Use one of the supported protocol values: %s", knownProtocolNames()), - ) - } - records = append(records, agent_yaml.ProtocolVersionRecord{ - Protocol: name, - Version: version, - }) - } - return records, nil + return resolveKnownProtocols(flagProtocols) } // Non-interactive mode: default to responses. @@ -965,6 +938,12 @@ func promptProtocols( }, nil } + // Build a lookup from protocol name → version for known protocols. + versionOf := make(map[string]string, len(knownProtocols)) + for _, p := range knownProtocols { + versionOf[p.Name] = p.Version + } + // Build multi-select choices; "responses" is pre-selected. choices := make([]*azdext.MultiSelectChoice, 0, len(knownProtocols)) for _, p := range knownProtocols { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index 675a67d72fd..c7d3166c748 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -597,6 +598,13 @@ func TestPromptProtocols_FlagValues(t *testing.T) { {Protocol: "invocations", Version: "1.0.0"}, }, }, + { + name: "invocations_ws only", + flagProtocols: []string{"invocations_ws"}, + wantProtocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "invocations_ws", Version: "2.0.0"}, + }, + }, { name: "both protocols", flagProtocols: []string{"responses", "invocations"}, @@ -696,6 +704,9 @@ func TestKnownProtocolNames(t *testing.T) { if !strings.Contains(result, "invocations") { t.Errorf("knownProtocolNames() = %q, want to contain 'invocations'", result) } + if !strings.Contains(result, "invocations_ws") { + t.Errorf("knownProtocolNames() = %q, want to contain 'invocations_ws'", result) + } if !strings.Contains(result, "activity") { t.Errorf("knownProtocolNames() = %q, want to contain 'activity'", result) } @@ -736,6 +747,7 @@ func TestPromptProtocols_Interactive(t *testing.T) { Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: true}, {Value: "invocations", Label: "invocations", Selected: true}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: false}, }, }, nil }, @@ -751,6 +763,7 @@ func TestPromptProtocols_Interactive(t *testing.T) { Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: true}, {Value: "invocations", Label: "invocations", Selected: false}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: false}, }, }, nil }, @@ -758,6 +771,25 @@ func TestPromptProtocols_Interactive(t *testing.T) { {Protocol: "responses", Version: "2.0.0"}, }, }, + { + name: "websocket protocol selected", + multiSelectFn: func( + _ context.Context, + _ *azdext.MultiSelectRequest, + _ ...grpc.CallOption, + ) (*azdext.MultiSelectResponse, error) { + return &azdext.MultiSelectResponse{ + Values: []*azdext.MultiSelectChoice{ + {Value: "responses", Label: "responses", Selected: false}, + {Value: "invocations", Label: "invocations", Selected: false}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: true}, + }, + }, nil + }, + wantProtocols: []agent_yaml.ProtocolVersionRecord{ + {Protocol: "invocations_ws", Version: "2.0.0"}, + }, + }, { name: "user cancellation", multiSelectFn: func(_ context.Context, _ *azdext.MultiSelectRequest, _ ...grpc.CallOption) (*azdext.MultiSelectResponse, error) { @@ -773,6 +805,7 @@ func TestPromptProtocols_Interactive(t *testing.T) { Values: []*azdext.MultiSelectChoice{ {Value: "responses", Label: "responses", Selected: false}, {Value: "invocations", Label: "invocations", Selected: false}, + {Value: "invocations_ws", Label: "invocations_ws", Selected: false}, }, }, nil }, @@ -819,6 +852,33 @@ func TestPromptProtocols_Interactive(t *testing.T) { } } +func TestPromptProtocols_ChoicesIncludeInvocationsWsWithoutChangingDefault(t *testing.T) { + t.Parallel() + + client := &fakePromptClient{multiSelectFn: func( + _ context.Context, + in *azdext.MultiSelectRequest, + _ ...grpc.CallOption, + ) (*azdext.MultiSelectResponse, error) { + choices := in.Options.Choices + require.GreaterOrEqual(t, len(choices), 3) + require.Equal(t, "responses", choices[0].Value) + require.True(t, choices[0].Selected) + require.Equal(t, "invocations", choices[1].Value) + require.False(t, choices[1].Selected) + require.Equal(t, "invocations_ws", choices[2].Value) + require.False(t, choices[2].Selected) + + return &azdext.MultiSelectResponse{Values: choices}, nil + }} + + got, err := promptProtocols(t.Context(), client, false, nil) + require.NoError(t, err) + require.Equal(t, []agent_yaml.ProtocolVersionRecord{ + {Protocol: "responses", Version: "2.0.0"}, + }, got) +} + func TestPromptDeployMode_FlagOverride(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index 2d865a57ee1..baa94021e41 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -316,7 +316,7 @@ func TestSynthesizeImageManifestFile(t *testing.T) { const image = "myacr.azurecr.io/agents/my-agent@sha256:" + "76a9463463acf11d4068e8468fb232a3de0709177b6b35de95de6a34b33fa686" - manifestPath, cleanup, err := synthesizeImageManifestFile(agentName, image) + manifestPath, cleanup, err := synthesizeImageManifestFile(agentName, image, nil) require.NoError(t, err) require.NotNil(t, cleanup) require.FileExists(t, manifestPath) @@ -343,6 +343,56 @@ func TestSynthesizeImageManifestFile(t *testing.T) { require.NoFileExists(t, manifestPath) } +func TestSynthesizeImageManifestFile_UsesFlagProtocols(t *testing.T) { + t.Parallel() + + const agentName = "my-agent" + const image = "myacr.azurecr.io/agents/my-agent:v1" + + manifestPath, cleanup, err := synthesizeImageManifestFile(agentName, image, []string{"invocations_ws"}) + require.NoError(t, err) + defer cleanup() + + content, err := os.ReadFile(manifestPath) + require.NoError(t, err) + template, err := agent_yaml.ExtractAgentDefinition(content) + require.NoError(t, err) + + containerAgent, ok := template.(agent_yaml.ContainerAgent) + require.True(t, ok, "synthesized template should be a ContainerAgent, got %T", template) + require.Len(t, containerAgent.Protocols, 1) + require.Equal(t, "invocations_ws", containerAgent.Protocols[0].Protocol) + require.Equal(t, "2.0.0", containerAgent.Protocols[0].Version) +} + +func TestSynthesizeImageManifestFile_RejectsUnknownProtocol(t *testing.T) { + t.Parallel() + + manifestPath, cleanup, err := synthesizeImageManifestFile( + "my-agent", + "myacr.azurecr.io/agents/my-agent:v1", + []string{"unknown"}, + ) + require.Error(t, err) + require.Empty(t, manifestPath) + cleanup() + require.Contains(t, err.Error(), "unknown protocol") +} + +func TestSynthesizeImageManifestFile_RejectsActivityProtocol(t *testing.T) { + t.Parallel() + + manifestPath, cleanup, err := synthesizeImageManifestFile( + "my-agent", + "myacr.azurecr.io/agents/my-agent:v1", + []string{"activity"}, + ) + require.Error(t, err) + require.Empty(t, manifestPath) + cleanup() + require.Contains(t, err.Error(), "--protocol activity is not supported with --image") +} + func TestAddToProjectPreBuiltImageWritesServiceImage(t *testing.T) { const image = "myacr.azurecr.io/agents/my-agent:v1" server := &recordingProjectServer{} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go index c9b35d0eceb..1db1a0beca0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_test.go @@ -1604,6 +1604,43 @@ func TestCreateHostedAgentAPIRequest_WithRaiConfig(t *testing.T) { } } +func TestCreateAgentAPIRequest_CodeDeploy_InvocationsWsUsesProtocolVersions(t *testing.T) { + t.Parallel() + agent := ContainerAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindHosted, + Name: "ws-agent", + }, + Protocols: []ProtocolVersionRecord{ + {Protocol: "invocations_ws", Version: "2.0.0"}, + }, + CodeConfiguration: &CodeConfiguration{ + Runtime: "python_3_12", + EntryPoint: "main.py", + }, + } + + req, err := CreateAgentAPIRequestFromDefinition(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + codeDef, ok := req.Definition.(agent_api.HostedAgentDefinition) + if !ok { + t.Fatalf("expected HostedAgentDefinition, got %T", req.Definition) + } + + if len(codeDef.ProtocolVersions) != 1 { + t.Fatalf("expected 1 protocol version, got %d", len(codeDef.ProtocolVersions)) + } + if string(codeDef.ProtocolVersions[0].Protocol) != "invocations_ws" { + t.Errorf("protocol = %q, want %q", codeDef.ProtocolVersions[0].Protocol, "invocations_ws") + } + if codeDef.ProtocolVersions[0].Version != "2.0.0" { + t.Errorf("version = %q, want %q", codeDef.ProtocolVersions[0].Version, "2.0.0") + } +} + func TestCreateAgentAPIRequest_CodeDeploy_WithRaiConfig(t *testing.T) { t.Parallel() const raiPolicyID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/" + diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index a6ac2d23f0d..52a4e681eb7 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -110,7 +110,7 @@ "type": "object", "description": "A protocol the agent implements, with its version.", "properties": { - "protocol": { "type": "string", "description": "Protocol name (e.g., 'responses', 'invocations', 'a2a')." }, + "protocol": { "type": "string", "description": "Protocol name (e.g., 'responses', 'invocations', 'invocations_ws', 'a2a')." }, "version": { "type": "string", "description": "Protocol version." } }, "required": ["protocol"],