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
1 change: 1 addition & 0 deletions cli/azd/extensions/azure.ai.agents/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ words:
- underscoped
- unparseable
- Vnext
- VOICELIVE
- webp
# Doctor / next-step terms
- nextstep
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"switzerlandnorth",
"uksouth",
"westus",
"westus2",
"westus3"
]
}
77 changes: 69 additions & 8 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,91 @@ 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 {
p := knownProtocols[0] // default: responses
return []agent_yaml.ProtocolVersionRecord{{Protocol: p.Name, Version: p.Version}}, 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",
)
}
protocols, err := resolveKnownProtocols(flagProtocols)
if err != nil {
return nil, err
}
records := make([]agent_yaml.ProtocolVersionRecord, 0, len(protocols))
for _, p := range protocols {
records = append(records, agent_yaml.ProtocolVersionRecord{Protocol: p.Name, Version: p.Version})
}
return records, nil
}

func resolveKnownProtocols(flagProtocols []string) ([]protocolInfo, error) {
versionOf := make(map[string]string, len(knownProtocols))
for _, p := range knownProtocols {
versionOf[p.Name] = p.Version
}

seen := make(map[string]bool, len(flagProtocols))
protocols := make([]protocolInfo, 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",
)
}
protocols = append(protocols, protocolInfo{Name: name, Version: version})
}

return protocols, 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) {
// top-level image field. flagProtocols specifies the protocol names to include in the
// manifest (e.g., "responses", "invocations", "invocations_ws"); if empty, "responses"
// is used as the default.
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 @@ -952,7 +1013,6 @@ func runInitFromManifest(
if err != nil {
return err
}

// Create credential with whatever tenant is available (may be empty → default tenant)
credential, err := azidentity.NewAzureDeveloperCLICredential(
&azidentity.AzureDeveloperCLICredentialOptions{
Expand Down Expand Up @@ -1143,7 +1203,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 +1570,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 @@ -279,7 +279,6 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context)
}
a.azureContext = azureContext
}

// TODO: Prompt user for agent kind
agentKind := agent_yaml.AgentKindHosted

Expand Down Expand Up @@ -906,6 +905,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 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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const (
// ProtocolResponses is the value of `agent.yaml#protocol` for plain
// text /responses agents.
ProtocolResponses = "responses"
// ProtocolInvocationsWS is the value of `agent.yaml#protocol` for
// bidirectional WebSocket /invocations_ws agents.
ProtocolInvocationsWS = "invocations_ws"

// placeholderPayload is the single-quoted literal the resolver
// emits as the body argument when no concrete payload is known —
Expand Down Expand Up @@ -770,6 +773,9 @@ func appendInvokeLocalSecondary(
if len(state.Services) == 1 {
svc = &state.Services[0]
}
if svc != nil && svc.Protocol == ProtocolInvocationsWS {
return out, priority
}
invokeArg, readmeHint := resolveInvokeArg(svc, "", readmeExists, priority)
if readmeHint != nil {
out = append(out, *readmeHint)
Expand Down
Loading