Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"switzerlandnorth",
"uksouth",
"westus",
"westus2",
"westus3"
]
}
66 changes: 60 additions & 6 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
osExec "os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -657,31 +658,83 @@ 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
// scaffolding, since a pre-built image needs none of those. The returned cleanup removes
// 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 {
return "", noop, fmt.Errorf("creating temp directory for synthesized manifest: %w", err)
}
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,
},
}

Expand Down Expand Up @@ -1143,7 +1196,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
"pass --agent-name <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
}
Expand Down Expand Up @@ -1510,7 +1563,8 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
"Directory to download the agent definition to (defaults to 'src/<agent-id>')")

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.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand All @@ -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.
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
},
Expand All @@ -751,13 +763,33 @@ 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
},
wantProtocols: []agent_yaml.ProtocolVersionRecord{
{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) {
Expand All @@ -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
},
Expand Down Expand Up @@ -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()

Expand Down
52 changes: 51 additions & 1 deletion cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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{}
Expand Down
Loading