From 7fb0877eccc3f6093fc7af238f7c9a3f6e1dccdf Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:24:04 +0800 Subject: [PATCH 01/19] feat(agents): support declarative prompt-voice agents (managed model) Add azd support for a new 'prompt-voice' agent kind that creates a managed speech-to-speech (voice) agent on Azure AI Foundry. - yaml: new prompt-voice kind + VoiceAgent authoring struct/parsing - map: translate authoring kind prompt-voice -> data-plane kind voice, defaulting the audio pipeline (PCM16@24k, server_vad, whisper-1, DragonHD default voice) and v1 implicit managed model_type - agent_api: VoiceAgentDefinition wire structs + CreateVoiceAgent with Foundry-Features: VoiceAgents=V1Preview preview header - project: voice-aware agent_definition read/write + isolated deployVoiceAgent deploy path (container path unchanged) - init: --kind/--voice flags, voice manifest synthesis, prompt option Scope: prompt-voice + managed model only. BYOM, hosted-voice, tools, avatar, and cascaded models are follow-ups. Draft: needs further end-to-end session testing and optimization. --- .../azure.ai.agents/internal/cmd/init.go | 242 +++++++++++++++++- .../cmd/init_from_templates_helpers.go | 47 +++- .../internal/pkg/agents/agent_api/models.go | 78 ++++++ .../pkg/agents/agent_api/operations.go | 67 +++++ .../internal/pkg/agents/agent_yaml/map.go | 92 ++++++- .../internal/pkg/agents/agent_yaml/parse.go | 24 ++ .../internal/pkg/agents/agent_yaml/yaml.go | 46 ++++ .../internal/project/agent_definition.go | 104 ++++++++ .../internal/project/service_target_agent.go | 96 +++++++ .../internal/synthesis/synthesizer.go | 3 +- 10 files changed, 786 insertions(+), 13 deletions(-) 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 884ca71f428..2389ff5266d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -71,6 +71,17 @@ type initFlags struct { // connection prompts. Requires --agent-name when no --manifest is given. Incompatible // with --deploy-mode code. image string + // kind selects the agent kind to initialize non-interactively, bypassing the + // interactive init-mode/template prompts. Currently the only accepted value is + // "prompt-voice", which synthesizes a declarative (managed) voice agent + // manifest and routes it through the manifest flow (no code/image, no + // template/language selection, no ACR). An empty value keeps the existing + // inference-from-inputs behavior. Additive: existing kinds remain inferred. + kind string + // voice optionally overrides the output voice name for a prompt-voice agent + // (e.g. "alloy" for an OpenAI realtime voice or an Azure Neural voice name). + // Ignored for non-voice kinds. + voice string // force, when true, lets headless callers (--no-prompt) pre-consent to // overwrite prompts that would otherwise return a structured error. It // mirrors the `--force` convention used by `azd down`, `azd env remove`, @@ -635,6 +646,15 @@ func updateAgentDefinition( } update(&t.AgentDefinition) return t, nil + case agent_yaml.VoiceAgent: + update(&t.AgentDefinition) + return t, nil + case *agent_yaml.VoiceAgent: + if t == nil { + return nil, fmt.Errorf("agent template is nil") + } + update(&t.AgentDefinition) + return t, nil default: return nil, fmt.Errorf("unsupported agent template type %T", template) } @@ -750,6 +770,64 @@ func synthesizeImageManifestFile(agentName, image string, flagProtocols []string return manifestPath, cleanup, nil } +// defaultVoiceModel is the speech-to-speech model used for a prompt-voice agent +// when --model is not supplied. +const defaultVoiceModel = "gpt-realtime" + +// kindFlagPromptVoice is the accepted --kind value for a declarative voice agent. +const kindFlagPromptVoice = "prompt-voice" + +// synthesizeVoiceManifestFile writes a temporary declarative (managed) voice +// agent manifest (kind: prompt-voice) to a temp dir and returns its path plus a +// cleanup func. Like synthesizeImageManifestFile, it lets `--kind prompt-voice` +// (and the interactive voice option) route through the existing manifest flow, +// skipping template/language selection and code scaffolding. A voice agent has +// no image, Dockerfile, or source, so none of those are emitted. model_type is +// written explicitly as "managed" from day one. +func synthesizeVoiceManifestFile(agentName, model, voice string) (string, func(), error) { + noop := func() {} + + if strings.TrimSpace(model) == "" { + model = defaultVoiceModel + } + + tmpDir, err := os.MkdirTemp("", "azd-agent-voice-") + if err != nil { + return "", noop, fmt.Errorf("creating temp directory for synthesized manifest: %w", err) + } + cleanup := func() { _ = os.RemoveAll(tmpDir) } + + template := map[string]any{ + "kind": string(agent_yaml.AgentKindPromptVoice), + "name": agentName, + "description": "Declarative (managed) voice speech-to-speech agent", + "model_type": string(agent_yaml.VoiceModelTypeManaged), + "model": map[string]any{"id": model}, + } + if v := strings.TrimSpace(voice); v != "" { + template["voice"] = v + } + + doc := map[string]any{ + "name": agentName, + "template": template, + } + + content, err := yaml.Marshal(doc) + if err != nil { + cleanup() + return "", noop, fmt.Errorf("marshaling synthesized manifest: %w", err) + } + + manifestPath := filepath.Join(tmpDir, "agent.yaml") + if err := os.WriteFile(manifestPath, content, osutil.PermissionFile); err != nil { + cleanup() + return "", noop, fmt.Errorf("writing synthesized manifest: %w", err) + } + + return manifestPath, cleanup, nil +} + func nextAgentNameSuggestion(agentName string) string { const maxAgentNameLength = 63 const defaultAgentName = "agent" @@ -1205,7 +1283,43 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, userProvidedManifest = true } - // Auto-detect an existing agent manifest in the target directory + // Prompt-voice fast path: when --kind prompt-voice is set without a + // manifest, there is no source to scaffold and no template/language to + // choose. Synthesize a declarative (managed) voice manifest and route it + // through the manifest flow (which skips the init-mode / template / + // language prompts and code scaffolding). Mirrors the --image fast path. + if flags.kind != "" && flags.manifestPointer == "" { + if !strings.EqualFold(flags.kind, kindFlagPromptVoice) { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unsupported --kind value %q", flags.kind), + fmt.Sprintf("the only supported --kind value is %q", kindFlagPromptVoice), + ) + } + if flags.image != "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--kind prompt-voice cannot be combined with --image", + "a voice agent is managed and has no container image; drop --image", + ) + } + if flags.agentName == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--kind prompt-voice requires --agent-name when no --manifest is provided", + "pass --agent-name (or provide --manifest with the agent definition)", + ) + } + manifestPath, cleanup, err := synthesizeVoiceManifestFile( + flags.agentName, flags.model, flags.voice, + ) + if err != nil { + return err + } + defer cleanup() + flags.manifestPointer = manifestPath + userProvidedManifest = true + } // when no --manifest flag was provided. // // manifestDetectedButDeclined: gates the definition-reuse scan below so @@ -1498,6 +1612,44 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } + case initModeVoice: + // User chose to create a declarative (managed) voice agent. + // Resolve the agent name, synthesize a prompt-voice manifest, + // and route it through the manifest flow — the same path as + // `azd ai agent init --kind prompt-voice`. + resolvedName, err := resolveInitAgentName(ctx, azdClient, flags, "voice-agent") + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + + manifestPath, cleanup, err := synthesizeVoiceManifestFile( + resolvedName, flags.model, flags.voice, + ) + if err != nil { + return err + } + defer cleanup() + flags.manifestPointer = manifestPath + + folderName := sanitizeAgentName(resolvedName) + _, statErr := os.Stat(folderName) + newlyCreated := errors.Is(statErr, fs.ErrNotExist) + var folderDisplay string + if newlyCreated && !existingProject { + folderDisplay = filepath.ToSlash(folderName) + } + if err := runInitFromManifest( + ctx, flags, azdClient, httpClient, folderName, folderDisplay, true, + ); err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + default: // initModeFromCode - use existing code in current directory action := &InitFromCodeAction{ @@ -1567,6 +1719,15 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Dockerfile generation, and ACR setup, and requires --agent-name. "+ "Incompatible with --deploy-mode code.") + cmd.Flags().StringVar(&flags.kind, "kind", "", + "Agent kind to initialize non-interactively. Currently supports 'prompt-voice' to create a "+ + "declarative (managed) voice agent, skipping template/language selection and code scaffolding. "+ + "Use --model to name the speech-to-speech model and --voice to set the output voice.") + + cmd.Flags().StringVar(&flags.voice, "voice", "", + "Output voice name for a --kind prompt-voice agent (e.g. 'alloy' for an OpenAI realtime voice, "+ + "or an Azure Neural voice name). Ignored for other kinds.") + cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite an input manifest that already lives inside the generated src tree without prompting. "+ "Required together with --no-prompt when init would otherwise need confirmation.") @@ -2799,6 +2960,13 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa return fmt.Errorf("failed to unmarshal JSON to AgentDefinition: %w", err) } + // Voice agents (kind: prompt-voice) carry no container/image/code config and + // take an entirely different service-entry shape. Handle them in an isolated + // branch and return early so the container path below is unaffected. + if agentDef.Kind == agent_yaml.AgentKindPromptVoice { + return a.addVoiceAgentToProject(ctx, targetDir, agentManifest) + } + var agentConfig = project.ServiceTargetAgentConfig{} resourceDetails := []project.Resource{} @@ -2992,6 +3160,78 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa return nil } +// addVoiceAgentToProject writes a prompt-voice (declarative, managed) agent as an +// azure.ai.agent service entry. Voice agents carry no container/image/code +// config, so this path skips startup-command detection, Docker settings, and +// pre-built image handling entirely. The agent definition is embedded inline +// using the voice-specific writer; sibling Foundry resource services (project) +// are still emitted so provision wires the endpoint. +func (a *InitAction) addVoiceAgentToProject( + ctx context.Context, targetDir string, agentManifest *agent_yaml.AgentManifest, +) error { + if targetDir == "." { + if cwd, err := os.Getwd(); err == nil && a.projectConfig != nil && a.projectConfig.Path != "" { + if relPath, err := filepath.Rel(a.projectConfig.Path, cwd); err == nil && relPath != "." { + targetDir = filepath.ToSlash(relPath) + } + } + } + + // Rebuild the full VoiceAgent from the manifest template so it can be + // embedded inline on the service entry. + templateYAML, err := yaml.Marshal(agentManifest.Template) + if err != nil { + return fmt.Errorf("marshaling voice agent definition: %w", err) + } + var voiceDef agent_yaml.VoiceAgent + if err := yaml.Unmarshal(templateYAML, &voiceDef); err != nil { + return fmt.Errorf("parsing voice agent definition: %w", err) + } + + agentConfig := project.ServiceTargetAgentConfig{} + agentProps, err := project.VoiceAgentDefinitionToServiceProperties(voiceDef, &agentConfig) + if err != nil { + return err + } + + serviceConfig := &azdext.ServiceConfig{ + Name: a.serviceNameOverride, + RelativePath: targetDir, + Host: AiAgentHost, + AdditionalProperties: agentProps, + } + + req := &azdext.AddServiceRequest{Service: serviceConfig} + if _, err := a.azdClient.Project().AddService(ctx, req); err != nil { + return fmt.Errorf("adding voice agent service to project: %w", err) + } + + // Emit the sibling Foundry project service so provision reuses/creates the + // project. Voice v1 uses a managed model with no tool connections or + // toolboxes, so no deployment/connection/toolbox siblings are emitted. + if err := emitResourceServices( + ctx, a.azdClient, a.serviceNameOverride, + projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), + a.selectedFoundryProject.Endpoint(), + nil, nil, nil, + ); err != nil { + return err + } + + fmt.Printf( + "\nAdded your voice agent as a service entry named '%s' under the file azure.yaml.\n", + a.serviceNameOverride, + ) + + var stateOpts []nextstep.Option + if a.createdFolderDisplay != "" { + stateOpts = append(stateOpts, nextstep.WithCreatedFolder(a.createdFolderDisplay)) + } + state, _ := nextstep.AssembleState(ctx, a.azdClient, stateOpts...) + _ = printAllNextIfTerminal(os.Stdout, nextstep.ResolveAfterInit(state, readmeExistsForProject(ctx, a.azdClient))) + return nil +} + //nolint:gosec // env var key name, not a credential const resourceTokenSaltKey = "AZD_RESOURCE_TOKEN_SALT" diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index ce7712b4f1f..d2d26a768d9 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -96,28 +96,55 @@ func (t *AgentTemplate) EffectiveType() string { const ( initModeFromCode = "from_code" initModeTemplate = "template" + // initModeVoice is chosen when the user wants to create a declarative + // (managed) voice agent. It maps to the same synthesized-manifest fast path + // as `azd ai agent init --kind prompt-voice`. + initModeVoice = "prompt_voice" ) -// promptInitMode asks the user whether to use existing code or start from a template. -// If the current directory is empty, automatically returns initModeTemplate. -// In no-prompt mode with existing local files, defaults to using the current directory. -// Returns initModeFromCode or initModeTemplate. +// voiceInitChoice is the interactive menu entry for creating a prompt voice agent. +// It is appended to the init-mode choices in both the empty-dir and existing-code +// cases so the option is always offered interactively. +var voiceInitChoice = &azdext.SelectChoice{ + Label: "Create a prompt voice agent", + Value: initModeVoice, +} + +// promptInitMode asks the user whether to use existing code, start from a +// template, or create a prompt voice agent. +// If the current directory is empty, the "use existing code" option is omitted +// (there is no code to use) but the template / voice options are still offered +// interactively. +// In no-prompt mode the directory contents decide: empty -> template, otherwise +// use the current directory. Voice is only selectable interactively (or via +// --kind prompt-voice in no-prompt mode). +// Returns initModeFromCode, initModeTemplate, or initModeVoice. func promptInitMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool) (string, error) { empty, err := dirIsEmpty(".") if err != nil { return "", fmt.Errorf("checking current directory: %w", err) } - if empty { - return initModeTemplate, nil - } if noPrompt { + if empty { + return initModeTemplate, nil + } return initModeFromCode, nil } - choices := []*azdext.SelectChoice{ - {Label: "Use the code in the current directory", Value: initModeFromCode}, - {Label: "Start new from a template", Value: initModeTemplate}, + var choices []*azdext.SelectChoice + if empty { + // No local code to adopt; offer template + voice. + choices = []*azdext.SelectChoice{ + {Label: "Start new from a template", Value: initModeTemplate}, + voiceInitChoice, + } + } else { + choices = []*azdext.SelectChoice{ + {Label: "Use the code in the current directory", Value: initModeFromCode}, + {Label: "Start new from a template", Value: initModeTemplate}, + voiceInitChoice, + } } defaultIndex := int32(0) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index ed00129e0a5..5815906550d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -68,6 +68,12 @@ type AgentKind string const ( AgentKindHosted AgentKind = "hosted" AgentKindWorkflow AgentKind = "workflow" + // AgentKindVoice is the data-plane (service) kind for a declarative voice + // (speech-to-speech) agent. Note: this is the wire value posted to the + // /voice_agents collection. The azd manifest authoring kind is + // "prompt-voice" (agent_yaml.AgentKindPromptVoice), which the map layer + // translates to this value. + AgentKindVoice AgentKind = "voice" ) // AgentEventType represents the types of events that can be handled @@ -282,6 +288,78 @@ func (d *HostedAgentDefinition) UnmarshalJSON(data []byte) error { return nil } +// VoiceModelType selects the model-inference mode for a voice agent. +// - "managed": Voice Live-hosted — the service runs the model on its own infra. +// - "self_deployed": BYOM — the service calls the customer's own Foundry deployment. +// +// In v1 azd only emits "managed"; "self_deployed" is defined for forward-compat. +type VoiceModelType string + +const ( + VoiceModelTypeManaged VoiceModelType = "managed" + VoiceModelTypeSelfDeployed VoiceModelType = "self_deployed" +) + +// VoiceAudioFormat describes a PCM audio stream format (e.g. audio/pcm @ 24 kHz). +type VoiceAudioFormat struct { + Type string `json:"type"` + Rate int `json:"rate"` +} + +// VoiceTurnDetection configures server-side voice-activity detection so the +// agent auto-responds when the caller stops speaking. +type VoiceTurnDetection struct { + Type string `json:"type"` + Threshold *float64 `json:"threshold,omitempty"` + PrefixPaddingMs *int `json:"prefix_padding_ms,omitempty"` + SilenceDurationMs *int `json:"silence_duration_ms,omitempty"` +} + +// VoiceTranscription enables user-speech transcription events on the input stream. +type VoiceTranscription struct { + Model string `json:"model,omitempty"` +} + +// VoiceInputConfig is the input (caller -> agent) audio configuration. +type VoiceInputConfig struct { + Format *VoiceAudioFormat `json:"format,omitempty"` + TurnDetection *VoiceTurnDetection `json:"turn_detection,omitempty"` + Transcription *VoiceTranscription `json:"transcription,omitempty"` +} + +// VoiceConfig selects the output voice. Type is "openai" for realtime voices +// (single lowercase word, e.g. "alloy") or "azure_standard" for Azure Neural +// voices (e.g. "en-US-Ava:DragonHDLatestNeural"). +type VoiceConfig struct { + Type string `json:"type"` + Name string `json:"name"` +} + +// VoiceOutputConfig is the output (agent -> caller) audio configuration. +type VoiceOutputConfig struct { + Format *VoiceAudioFormat `json:"format,omitempty"` + Voice *VoiceConfig `json:"voice,omitempty"` +} + +// VoiceAudioConfig bundles the input and output audio configuration. +type VoiceAudioConfig struct { + Input *VoiceInputConfig `json:"input,omitempty"` + Output *VoiceOutputConfig `json:"output,omitempty"` +} + +// VoiceAgentDefinition is the data-plane definition body POSTed to the +// /voice_agents collection for a declarative (managed) voice agent. Its Kind +// is always AgentKindVoice ("voice"). +type VoiceAgentDefinition struct { + AgentDefinition + ModelType VoiceModelType `json:"model_type"` + Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` + Audio *VoiceAudioConfig `json:"audio,omitempty"` + OutputModalities []string `json:"output_modalities,omitempty"` + Store *bool `json:"store,omitempty"` +} + // CreateAgentVersionRequest represents a request to create an agent version type CreateAgentVersionRequest struct { Description *string `json:"description,omitempty"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index 2274bb9e33b..f7d6e7062f0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -163,6 +163,73 @@ func (c *AgentClient) CreateAgent(ctx context.Context, request *CreateAgentReque return &agent, nil } +// voiceAgentsPreviewFeature is the opt-in token required in the Foundry-Features +// header while voice agents remain a preview capability. +const voiceAgentsPreviewFeature = "VoiceAgents=V1Preview" + +// CreateVoiceAgent creates a new declarative (managed) voice agent. +// +// Voice agents live in a separate data-plane collection (/voice_agents), distinct +// from the /agents collection used by hosted/workflow agents. The request +// Definition must be a *VoiceAgentDefinition (service kind "voice"). +// +// overriddenHost, when non-empty, is sent as the x-ms-overridden-host header. +// This routes the request directly to the regional Hyena data-plane host, +// bypassing the public Foundry APIM (whose voice route may not yet be rolled +// out). Pass "" to use the default endpoint routing. +func (c *AgentClient) CreateVoiceAgent( + ctx context.Context, + request *CreateAgentRequest, + apiVersion string, + overriddenHost string, +) (*AgentObject, error) { + url := fmt.Sprintf("%s/voice_agents?api-version=%s", c.endpoint, apiVersion) + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Voice agents are a preview feature; the service rejects the request with + // 403 preview_feature_required unless this opt-in header is present. + req.Raw().Header.Set("Foundry-Features", voiceAgentsPreviewFeature) + + if overriddenHost != "" { + req.Raw().Header.Set("x-ms-overridden-host", overriddenHost) + } + + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var agent AgentObject + if err := json.Unmarshal(body, &agent); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &agent, nil +} + // UpdateAgent updates an existing agent func (c *AgentClient) UpdateAgent(ctx context.Context, agentName string, request *UpdateAgentRequest, apiVersion string) (*AgentObject, error) { url := fmt.Sprintf("%s/agents/%s?api-version=%s", c.endpoint, agentName, apiVersion) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index e493f96f7c3..61f48a284a5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -133,8 +133,11 @@ func CreateAgentAPIRequestFromDefinition(agentTemplate any, options ...AgentBuil case AgentKindHosted: hostedDef := agentTemplate.(ContainerAgent) return CreateHostedAgentAPIRequest(hostedDef, buildConfig) + case AgentKindPromptVoice: + voiceDef := agentTemplate.(VoiceAgent) + return CreateVoiceAgentAPIRequest(voiceDef) default: - return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: hosted", agentDef.Kind) + return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: hosted, prompt-voice", agentDef.Kind) } } @@ -453,6 +456,93 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB hostedAgent.AgentEndpoint, hostedAgent.AgentCard) } +// Default audio-pipeline values for a voice agent. Authors don't specify the +// audio block in v1; these mirror the Voice Live sample (PCM16 @ 24 kHz, server +// VAD turn detection, input transcription enabled). +const ( + defaultVoiceAudioType = "audio/pcm" + defaultVoiceAudioRate = 24000 + defaultVoiceTurnDetectionType = "server_vad" + defaultVoiceInstructions = "You are a helpful voice assistant. Respond naturally and concisely." + // defaultVoiceInputTranscriptionModel enables user-speech transcription events. + defaultVoiceInputTranscriptionModel = "whisper-1" + // defaultVoiceName is a DragonHD (HD) Azure Neural voice used when the author + // omits a voice. + defaultVoiceName = "en-US-Ava:DragonHDLatestNeural" +) + +// isOpenAIVoice reports whether a voice name denotes an OpenAI realtime voice +// (single lowercase word, e.g. "alloy") vs an Azure Neural voice (contains "-", +// e.g. "en-US-Ava:DragonHDLatestNeural"). +func isOpenAIVoice(name string) bool { + return !strings.Contains(name, "-") +} + +// buildVoiceConfig chooses the OpenAI vs Azure voice type by name shape. +func buildVoiceConfig(name string) *agent_api.VoiceConfig { + if isOpenAIVoice(name) { + return &agent_api.VoiceConfig{Type: "openai", Name: name} + } + return &agent_api.VoiceConfig{Type: "azure_standard", Name: name} +} + +// CreateVoiceAgentAPIRequest builds a CreateAgentRequest for a declarative +// (managed) voice agent. It translates the authoring kind "prompt-voice" into +// the data-plane service kind "voice" and defaults the audio pipeline. +func CreateVoiceAgentAPIRequest(voiceAgent VoiceAgent) (*agent_api.CreateAgentRequest, error) { + if voiceAgent.Model == nil || voiceAgent.Model.Id == "" { + return nil, fmt.Errorf("model.id is required for a prompt-voice agent") + } + + // v1 only supports managed inference; default and enforce it. + modelType := agent_api.VoiceModelTypeManaged + if voiceAgent.ModelType != "" && voiceAgent.ModelType != VoiceModelTypeManaged { + return nil, fmt.Errorf( + "model_type '%s' is not supported; only '%s' is available", + voiceAgent.ModelType, VoiceModelTypeManaged) + } + + instructions := defaultVoiceInstructions + if voiceAgent.Instructions != nil && *voiceAgent.Instructions != "" { + instructions = *voiceAgent.Instructions + } + + voiceName := defaultVoiceName + if voiceAgent.Voice != nil && *voiceAgent.Voice != "" { + voiceName = *voiceAgent.Voice + } + + audioFormat := &agent_api.VoiceAudioFormat{ + Type: defaultVoiceAudioType, + Rate: defaultVoiceAudioRate, + } + + voiceDef := agent_api.VoiceAgentDefinition{ + AgentDefinition: agent_api.AgentDefinition{ + // Translate authoring kind prompt-voice -> service kind voice. + Kind: agent_api.AgentKindVoice, + }, + ModelType: modelType, + Model: voiceAgent.Model.Id, + Instructions: instructions, + Audio: &agent_api.VoiceAudioConfig{ + Input: &agent_api.VoiceInputConfig{ + Format: audioFormat, + TurnDetection: &agent_api.VoiceTurnDetection{Type: defaultVoiceTurnDetectionType}, + Transcription: &agent_api.VoiceTranscription{Model: defaultVoiceInputTranscriptionModel}, + }, + Output: &agent_api.VoiceOutputConfig{ + Format: audioFormat, + Voice: buildVoiceConfig(voiceName), + }, + }, + OutputModalities: []string{"audio"}, + Store: voiceAgent.Store, + } + + return createAgentAPIRequest(voiceAgent.AgentDefinition, voiceDef, nil, nil) +} + // createAgentAPIRequest is a helper function to create the final request with common fields. // The optional agentEndpoint and agentCard parameters are mapped to the corresponding // request-level fields when non-nil. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index 9d3cf3ab439..be8399d1785 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -115,6 +115,14 @@ func ExtractAgentDefinition(manifestYamlContent []byte) (any, error) { return nil, fmt.Errorf("failed to unmarshal to ContainerAgent: %w", err) } + agent.AgentDefinition = agentDef + return agent, nil + case AgentKindPromptVoice: + var agent VoiceAgent + if err := yaml.Unmarshal(templateBytes, &agent); err != nil { + return nil, fmt.Errorf("failed to unmarshal to VoiceAgent: %w", err) + } + agent.AgentDefinition = agentDef return agent, nil } @@ -419,6 +427,22 @@ func ValidateAgentDefinition(templateBytes []byte) error { } else { errors = append(errors, fmt.Sprintf("failed to unmarshal to Workflow: %v", err)) } + case AgentKindPromptVoice: + var agent VoiceAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if agent.Model == nil || agent.Model.Id == "" { + errors = append(errors, "template.model.id is required for a prompt-voice agent") + } + // v1 only supports managed inference. An explicit self_deployed + // (BYOM) value is rejected until BYOM ships. + if agent.ModelType != "" && agent.ModelType != VoiceModelTypeManaged { + errors = append(errors, fmt.Sprintf( + "template.model_type '%s' is not supported; only '%s' is available", + agent.ModelType, VoiceModelTypeManaged)) + } + } else { + errors = append(errors, fmt.Sprintf("failed to unmarshal to VoiceAgent: %v", err)) + } } } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 53871c73336..d4251ec1ef0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -16,6 +16,22 @@ type AgentKind string const ( AgentKindHosted AgentKind = "hosted" AgentKindWorkflow AgentKind = "workflow" + // AgentKindPromptVoice is the authoring (agent.yaml) kind for a declarative + // voice (speech-to-speech) agent. It is intentionally distinct from the + // data-plane service kind "voice": the map layer translates prompt-voice -> + // voice when building the create request. Reserving "prompt-voice" keeps a + // clean boundary against a future hosted (code) voice agent. + AgentKindPromptVoice AgentKind = "prompt-voice" +) + +// VoiceModelType selects the model-inference mode for a voice agent. +// v1 only supports (and implicitly defaults to) managed; self_deployed (BYOM) +// is reserved for a future release. +type VoiceModelType string + +const ( + VoiceModelTypeManaged VoiceModelType = "managed" + VoiceModelTypeSelfDeployed VoiceModelType = "self_deployed" ) // IsValidAgentKind checks if the provided AgentKind is valid @@ -28,6 +44,7 @@ func ValidAgentKinds() []AgentKind { return []AgentKind{ AgentKindHosted, AgentKindWorkflow, + AgentKindPromptVoice, } } @@ -176,6 +193,35 @@ type Workflow struct { Trigger *map[string]any `json:"trigger,omitempty" yaml:"trigger,omitempty"` } +// VoiceAgent is a declarative (managed) voice speech-to-speech agent authored in +// agent.yaml with kind "prompt-voice". Unlike a ContainerAgent it has no image, +// Dockerfile, or code — Foundry's Voice Live service hosts the model and audio +// pipeline. The map layer translates this into a data-plane VoiceAgentDefinition +// whose service kind is "voice". +// +// v1 keeps authoring lightweight: only the model and (optionally) a voice name, +// instructions, and store flag are author-facing. ModelType is implicitly +// "managed" and BYOM is intentionally not surfaced. The audio pipeline (PCM16 @ +// 24 kHz, server VAD turn detection, input transcription) is defaulted by the +// map layer so authors don't have to specify it. +type VoiceAgent struct { + AgentDefinition `json:",inline" yaml:",inline"` + // ModelType selects managed vs self_deployed (BYOM). Optional; defaults to + // managed. v1 only emits/accepts managed. + ModelType VoiceModelType `json:"modelType,omitempty" yaml:"model_type,omitempty"` + // Model names the speech-to-speech model (e.g. "gpt-realtime"). Reuses the + // shared Model struct; only Id is required for voice. + Model *Model `json:"model,omitempty" yaml:"model,omitempty"` + // Instructions is the system prompt for the voice assistant. + Instructions *string `json:"instructions,omitempty" yaml:"instructions,omitempty"` + // Voice is the output voice name (e.g. "en-US-Ava:DragonHDLatestNeural" for + // an Azure Neural voice, or "alloy" for an OpenAI realtime voice). + Voice *string `json:"voice,omitempty" yaml:"voice,omitempty"` + // Store toggles server-side logging (transcript + per-turn audio). Optional; + // the service defaults to false when omitted. + Store *bool `json:"store,omitempty" yaml:"store,omitempty"` +} + // ContainerResources represents the resource allocation for a containerized agent. type ContainerResources struct { Cpu string `json:"cpu" yaml:"cpu"` diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 921c7236ff7..dd264944a12 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -89,6 +89,39 @@ type AgentDefinitionInline struct { AgentCard *agent_yaml.AgentCard `json:"agentCard,omitempty"` CodeConfiguration *agent_yaml.CodeConfiguration `json:"codeConfiguration,omitempty"` Policies []agent_yaml.Policy `json:"policies,omitempty"` + + // Voice-agent fields (kind: prompt-voice). All omitempty so container/ + // workflow entries are byte-for-byte unchanged. + ModelType agent_yaml.VoiceModelType `json:"modelType,omitempty"` + Model *agent_yaml.Model `json:"model,omitempty"` + Instructions *string `json:"instructions,omitempty"` + Voice *string `json:"voice,omitempty"` + Store *bool `json:"store,omitempty"` +} + +// voiceAgentDefinitionToInline projects a VoiceAgent into the inline definition +// written to azure.yaml. Voice agents carry no container/image/code config. +func voiceAgentDefinitionToInline(va agent_yaml.VoiceAgent) AgentDefinitionInline { + return AgentDefinitionInline{ + AgentDefinition: va.AgentDefinition, + ModelType: va.ModelType, + Model: va.Model, + Instructions: va.Instructions, + Voice: va.Voice, + Store: va.Store, + } +} + +// toVoiceAgent rebuilds an agent_yaml.VoiceAgent from the inline definition. +func (d AgentDefinitionInline) toVoiceAgent() agent_yaml.VoiceAgent { + return agent_yaml.VoiceAgent{ + AgentDefinition: d.AgentDefinition, + ModelType: d.ModelType, + Model: d.Model, + Instructions: d.Instructions, + Voice: d.Voice, + Store: d.Store, + } } // agentDefinitionToInline splits a ContainerAgent into the inline definition, @@ -773,3 +806,74 @@ func AgentDefinitionToServiceProperties( return defStruct, nil } + +// VoiceAgentDefinitionToServiceProperties marshals a VoiceAgent (kind: +// prompt-voice) into the inline service-level properties written to azure.yaml. +// Voice agents carry no container/image/code config, so — unlike the container +// writer — there is no `container` block to merge. The optional extra config is +// still merged so provision-time settings (env, etc.) round-trip. +func VoiceAgentDefinitionToServiceProperties( + va agent_yaml.VoiceAgent, + extra *ServiceTargetAgentConfig, +) (*structpb.Struct, error) { + inline := voiceAgentDefinitionToInline(va) + + defStruct, err := MarshalStruct(&inline) + if err != nil { + return nil, fmt.Errorf("marshaling voice agent definition: %w", err) + } + + if extra != nil { + cfgStruct, err := MarshalStruct(extra) + if err != nil { + return nil, fmt.Errorf("marshaling voice agent service config: %w", err) + } + maps.Copy(defStruct.Fields, cfgStruct.GetFields()) + } + + return defStruct, nil +} + +// VoiceAgentFromResolvedService resolves a prompt-voice agent definition from a +// service entry's inline (preferred) or legacy config properties. It returns the +// parsed VoiceAgent and whether a prompt-voice definition was found. Non-voice +// (or absent) definitions return found=false with no error so callers can fall +// through to the container path unchanged. +func VoiceAgentFromResolvedService( + svc *azdext.ServiceConfig, + projectRoot string, +) (agent_yaml.VoiceAgent, bool, error) { + candidates := []*structpb.Struct{ + svc.GetAdditionalProperties(), + svc.GetConfig(), + } + for _, props := range candidates { + if props == nil || len(props.GetFields()) == 0 { + continue + } + resolved, err := resolveServiceProps(props, svc.GetName(), projectRoot) + if err != nil { + return agent_yaml.VoiceAgent{}, false, err + } + if !structHasKind(resolved) { + continue + } + if resolved.GetFields()["kind"].GetStringValue() != + string(agent_yaml.AgentKindPromptVoice) { + // A definition is present but it is not a voice agent. + return agent_yaml.VoiceAgent{}, false, nil + } + + var inline AgentDefinitionInline + if err := UnmarshalStruct(resolved, &inline); err != nil { + return agent_yaml.VoiceAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("voice agent service config is not valid: %s", err), + "re-run `azd ai agent init` to regenerate the agent service entry", + ) + } + return inline.toVoiceAgent(), true, nil + } + + return agent_yaml.VoiceAgent{}, false, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index c2e410aa174..32f1027e287 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -1071,6 +1071,17 @@ func (p *AgentServiceTargetProvider) Deploy( return nil, err } + // Voice agents (kind: prompt-voice) use a fundamentally different data-plane + // contract than hosted/workflow agents: a synchronous POST to /voice_agents + // that returns an AgentObject directly, with no version/polling model. Peek + // the definition first and dispatch to an isolated method so the container + // deploy path below stays byte-for-byte unchanged. + if va, isVoice, vErr := VoiceAgentFromResolvedService(serviceConfig, p.projectPath); vErr != nil { + return nil, vErr + } else if isVoice { + return p.deployVoiceAgent(ctx, serviceConfig, va, azdEnv, progress) + } + agentDef, isContainerAgent, err := p.loadContainerAgentDefinition() if err != nil { return nil, err @@ -1713,6 +1724,91 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( }, nil } +// voiceOverriddenHostEnvKey optionally routes the /voice_agents call directly to +// a regional data-plane host (bypassing the public Foundry APIM, whose voice +// route may not yet be rolled out). When unset, default endpoint routing is used. +// +//nolint:gosec // env var key name, not a credential +const voiceOverriddenHostEnvKey = "AZURE_VOICE_OVERRIDDEN_HOST" + +// deployVoiceAgent deploys a declarative (managed) voice agent (kind: +// prompt-voice) to the Foundry service. Unlike hosted agents, voice agents are +// created synchronously via a single POST to /voice_agents that returns the +// created AgentObject directly — there is no container build, no agent-version +// object, and no active-state polling. This method is intentionally isolated +// from the container deploy path so the two contracts never entangle. +func (p *AgentServiceTargetProvider) deployVoiceAgent( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + va agent_yaml.VoiceAgent, + azdEnv map[string]string, + progress azdext.ProgressReporter, +) (*azdext.ServiceDeployResult, error) { + progress("Deploying voice agent") + + request, err := agent_yaml.CreateVoiceAgentAPIRequest(va) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("invalid voice agent definition: %s", err), + "fix the agent definition in azure.yaml and re-run `azd deploy`", + ) + } + + projectEndpoint := azdEnv["FOUNDRY_PROJECT_ENDPOINT"] + if projectEndpoint == "" { + return nil, exterrors.Dependency( + exterrors.CodeMissingAiProjectEndpoint, + "cannot deploy voice agent: the Foundry project endpoint is not set", + "run 'azd provision' or connect to an existing project via "+ + "'azd ai agent init --project-id '", + ) + } + + agentClient := agent_api.NewAgentClient(projectEndpoint, p.credential) + + progress("Creating voice agent") + agentObject, err := agentClient.CreateVoiceAgent( + ctx, request, agent_api.AgentEndpointAPIVersion, azdEnv[voiceOverriddenHostEnvKey], + ) + if err != nil { + return nil, exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) + } + + fmt.Fprintf(os.Stderr, "Voice agent '%s' created successfully!\n", agentObject.Name) + + // Persist the agent name/endpoint so `azd ai agent run`/`list` can find it. + serviceKey := p.getServiceKey(serviceConfig.Name) + baseEndpoint := fmt.Sprintf( + "%s/voice_agents/%s", strings.TrimRight(projectEndpoint, "/"), agentObject.Name, + ) + for key, value := range map[string]string{ + fmt.Sprintf("AGENT_%s_NAME", serviceKey): agentObject.Name, + fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey): baseEndpoint, + } { + if _, setErr := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: p.env.Name, + Key: key, + Value: value, + }); setErr != nil { + return nil, fmt.Errorf("registering voice agent environment variable %s: %w", key, setErr) + } + } + + artifacts := []*azdext.Artifact{{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Location: baseEndpoint, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, + Metadata: map[string]string{ + "agentName": agentObject.Name, + "label": "Voice agent endpoint", + "clickable": "false", + }, + }} + + return &azdext.ServiceDeployResult{Artifacts: artifacts}, nil +} + // packageCodeDeploy creates a ZIP archive of the agent source code, writes it to a temp file, // and computes its SHA-256. Returns the temp file path and SHA-256 hex string. func (p *AgentServiceTargetProvider) packageCodeDeploy(ctx context.Context, serviceConfig *azdext.ServiceConfig) (string, string, error) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index 8c4e7d7baea..4e67bffa03a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -618,7 +618,8 @@ func agentNeedsAcr(a agentBlock) bool { return false } // "hosted" is the only container kind; an empty kind defaults to hosted for - // back-compat. Other explicit kinds (prompt, workflow) do not build. + // back-compat. Other explicit kinds (prompt, prompt-voice, workflow) do not + // build a container image. // NOTE: if a future non-container kind can omit kind:, replace this // default-to-hosted with an explicit allowlist so it does not trigger ACR. kind := strings.TrimSpace(a.Kind) From 29b8724ec7e08db3afe190935d6524e2b44f68b5 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:15:57 +0800 Subject: [PATCH 02/19] test(agents): add prompt-voice unit tests, schema, and cspell entry - map_voice_test.go: cover CreateVoiceAgentAPIRequest defaults/overrides, managed enforcement, BYOM rejection, missing-model error, and the isOpenAIVoice/buildVoiceConfig voice-type selection - parse_voice_test.go: cover prompt-voice manifest parsing and ValidateAgentDefinition (ok / missing model.id / self_deployed rejected) - azure.ai.agent.json: add prompt-voice to the kind enum and document the voice service properties (modelType/model/instructions/voice/store) - cspell.yaml: allow BYOM --- .../extensions/azure.ai.agents/cspell.yaml | 2 + .../pkg/agents/agent_yaml/map_voice_test.go | 204 ++++++++++++++++++ .../pkg/agents/agent_yaml/parse_voice_test.go | 94 ++++++++ .../schemas/azure.ai.agent.json | 30 ++- 4 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_voice_test.go diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index fae2f20d958..35572bd144d 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -8,6 +8,8 @@ words: - harsheet # Terms - Reprompt + # Voice (prompt-voice) agents + - BYOM # Azure region names - australiaeast - brazilsouth diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go new file mode 100644 index 00000000000..fe6622b7327 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" +) + +// --------------------------------------------------------------------------- +// isOpenAIVoice / buildVoiceConfig +// --------------------------------------------------------------------------- + +func TestIsOpenAIVoice(t *testing.T) { + t.Parallel() + cases := map[string]bool{ + "alloy": true, // single lowercase word -> OpenAI + "verse": true, + "en-US-Ava:DragonHDLatestNeural": false, // contains "-" -> Azure Neural + "en-US-JennyNeural": false, + } + for name, want := range cases { + if got := isOpenAIVoice(name); got != want { + t.Errorf("isOpenAIVoice(%q) = %v, want %v", name, got, want) + } + } +} + +func TestBuildVoiceConfig_OpenAI(t *testing.T) { + t.Parallel() + cfg := buildVoiceConfig("alloy") + if cfg.Type != "openai" { + t.Errorf("Type = %q, want openai", cfg.Type) + } + if cfg.Name != "alloy" { + t.Errorf("Name = %q, want alloy", cfg.Name) + } +} + +func TestBuildVoiceConfig_Azure(t *testing.T) { + t.Parallel() + cfg := buildVoiceConfig("en-US-Ava:DragonHDLatestNeural") + if cfg.Type != "azure_standard" { + t.Errorf("Type = %q, want azure_standard", cfg.Type) + } + if cfg.Name != "en-US-Ava:DragonHDLatestNeural" { + t.Errorf("Name = %q", cfg.Name) + } +} + +// --------------------------------------------------------------------------- +// CreateVoiceAgentAPIRequest +// --------------------------------------------------------------------------- + +// TestCreateVoiceAgentAPIRequest_Defaults verifies that a minimal prompt-voice +// agent (only model.id) is translated to the data-plane "voice" kind with the +// full default audio pipeline and implicit managed model_type. +func TestCreateVoiceAgentAPIRequest_Defaults(t *testing.T) { + t.Parallel() + agent := VoiceAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPromptVoice, + Name: "my-voice-agent", + }, + Model: &Model{Id: "gpt-realtime"}, + } + + req, err := CreateVoiceAgentAPIRequest(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Name != "my-voice-agent" { + t.Errorf("Name = %q", req.Name) + } + + def, ok := req.Definition.(agent_api.VoiceAgentDefinition) + if !ok { + t.Fatalf("expected VoiceAgentDefinition, got %T", req.Definition) + } + + // Authoring kind prompt-voice is translated to service kind voice. + if def.Kind != agent_api.AgentKindVoice { + t.Errorf("Kind = %q, want %q", def.Kind, agent_api.AgentKindVoice) + } + // v1 is implicitly managed. + if def.ModelType != agent_api.VoiceModelTypeManaged { + t.Errorf("ModelType = %q, want managed", def.ModelType) + } + if def.Model != "gpt-realtime" { + t.Errorf("Model = %q", def.Model) + } + if def.Instructions != defaultVoiceInstructions { + t.Errorf("Instructions = %q, want default", def.Instructions) + } + if len(def.OutputModalities) != 1 || def.OutputModalities[0] != "audio" { + t.Errorf("OutputModalities = %v, want [audio]", def.OutputModalities) + } + + // Audio pipeline defaults. + if def.Audio == nil || def.Audio.Input == nil || def.Audio.Output == nil { + t.Fatalf("Audio pipeline not populated: %+v", def.Audio) + } + in := def.Audio.Input + if in.Format == nil || in.Format.Type != defaultVoiceAudioType || in.Format.Rate != defaultVoiceAudioRate { + t.Errorf("input format = %+v", in.Format) + } + if in.TurnDetection == nil || in.TurnDetection.Type != defaultVoiceTurnDetectionType { + t.Errorf("turn detection = %+v", in.TurnDetection) + } + if in.Transcription == nil || in.Transcription.Model != defaultVoiceInputTranscriptionModel { + t.Errorf("transcription = %+v", in.Transcription) + } + out := def.Audio.Output + if out.Format == nil || out.Format.Type != defaultVoiceAudioType || out.Format.Rate != defaultVoiceAudioRate { + t.Errorf("output format = %+v", out.Format) + } + // Default voice is the DragonHD Azure Neural voice. + if out.Voice == nil || out.Voice.Type != "azure_standard" || out.Voice.Name != defaultVoiceName { + t.Errorf("output voice = %+v, want azure_standard/%s", out.Voice, defaultVoiceName) + } + // Store defaults to nil (service defaults to false). + if def.Store != nil { + t.Errorf("Store = %v, want nil", def.Store) + } +} + +// TestCreateVoiceAgentAPIRequest_Overrides verifies author-facing overrides +// (instructions, voice, store) flow through. +func TestCreateVoiceAgentAPIRequest_Overrides(t *testing.T) { + t.Parallel() + instructions := "You are a terse concierge." + voice := "alloy" + store := true + agent := VoiceAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "concierge"}, + Model: &Model{Id: "gpt-realtime"}, + Instructions: &instructions, + Voice: &voice, + Store: &store, + } + + req, err := CreateVoiceAgentAPIRequest(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + def := req.Definition.(agent_api.VoiceAgentDefinition) + if def.Instructions != instructions { + t.Errorf("Instructions = %q", def.Instructions) + } + // "alloy" is an OpenAI realtime voice. + if def.Audio.Output.Voice.Type != "openai" || def.Audio.Output.Voice.Name != "alloy" { + t.Errorf("voice = %+v, want openai/alloy", def.Audio.Output.Voice) + } + if def.Store == nil || !*def.Store { + t.Errorf("Store = %v, want true", def.Store) + } +} + +// TestCreateVoiceAgentAPIRequest_ExplicitManaged verifies that explicitly +// setting model_type: managed is accepted (idempotent with the default). +func TestCreateVoiceAgentAPIRequest_ExplicitManaged(t *testing.T) { + t.Parallel() + agent := VoiceAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "v"}, + Model: &Model{Id: "gpt-realtime"}, + ModelType: VoiceModelTypeManaged, + } + req, err := CreateVoiceAgentAPIRequest(agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Definition.(agent_api.VoiceAgentDefinition).ModelType != agent_api.VoiceModelTypeManaged { + t.Errorf("ModelType not managed") + } +} + +// TestCreateVoiceAgentAPIRequest_MissingModel verifies model.id is required. +func TestCreateVoiceAgentAPIRequest_MissingModel(t *testing.T) { + t.Parallel() + for _, agent := range []VoiceAgent{ + {AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "v"}}, + {AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "v"}, Model: &Model{}}, + } { + if _, err := CreateVoiceAgentAPIRequest(agent); err == nil { + t.Errorf("expected error for missing model.id, agent=%+v", agent) + } + } +} + +// TestCreateVoiceAgentAPIRequest_SelfDeployedRejected verifies BYOM is rejected +// in v1 (only managed is available). +func TestCreateVoiceAgentAPIRequest_SelfDeployedRejected(t *testing.T) { + t.Parallel() + agent := VoiceAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "v"}, + Model: &Model{Id: "gpt-realtime"}, + ModelType: VoiceModelTypeSelfDeployed, + } + if _, err := CreateVoiceAgentAPIRequest(agent); err == nil { + t.Error("expected error for self_deployed (BYOM) model_type") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_voice_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_voice_test.go new file mode 100644 index 00000000000..15293e5e76d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_voice_test.go @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "strings" + "testing" +) + +// TestExtractAgentDefinition_PromptVoice verifies a prompt-voice manifest parses +// into a VoiceAgent with its author-facing fields populated. +func TestExtractAgentDefinition_PromptVoice(t *testing.T) { + yamlContent := []byte(` +name: voice-agent +template: + kind: prompt-voice + name: voice-agent + model: + id: gpt-realtime + instructions: You are a friendly voice assistant. + voice: en-US-Ava:DragonHDLatestNeural + store: true +`) + + agent, err := ExtractAgentDefinition(yamlContent) + if err != nil { + t.Fatalf("ExtractAgentDefinition failed: %v", err) + } + + voiceAgent, ok := agent.(VoiceAgent) + if !ok { + t.Fatalf("Expected VoiceAgent, got %T", agent) + } + if voiceAgent.Kind != AgentKindPromptVoice { + t.Errorf("Kind = %q, want prompt-voice", voiceAgent.Kind) + } + if voiceAgent.Model == nil || voiceAgent.Model.Id != "gpt-realtime" { + t.Errorf("Model = %+v", voiceAgent.Model) + } + if voiceAgent.Instructions == nil || *voiceAgent.Instructions != "You are a friendly voice assistant." { + t.Errorf("Instructions = %v", voiceAgent.Instructions) + } + if voiceAgent.Voice == nil || *voiceAgent.Voice != "en-US-Ava:DragonHDLatestNeural" { + t.Errorf("Voice = %v", voiceAgent.Voice) + } + if voiceAgent.Store == nil || !*voiceAgent.Store { + t.Errorf("Store = %v, want true", voiceAgent.Store) + } +} + +// TestValidateAgentDefinition_PromptVoice_OK validates a minimal well-formed +// prompt-voice manifest. +func TestValidateAgentDefinition_PromptVoice_OK(t *testing.T) { + // ValidateAgentDefinition operates on the template body directly. + yamlContent := []byte(` +kind: prompt-voice +name: voice-agent +model: + id: gpt-realtime +`) + if err := ValidateAgentDefinition(yamlContent); err != nil { + t.Fatalf("expected valid, got error: %v", err) + } +} + +// TestValidateAgentDefinition_PromptVoice_MissingModel rejects a manifest with +// no model.id. +func TestValidateAgentDefinition_PromptVoice_MissingModel(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice-agent +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "model.id is required") { + t.Fatalf("expected model.id required error, got: %v", err) + } +} + +// TestValidateAgentDefinition_PromptVoice_BYOMRejected rejects self_deployed +// (BYOM) model_type in v1. +func TestValidateAgentDefinition_PromptVoice_BYOMRejected(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice-agent +model: + id: gpt-realtime +model_type: self_deployed +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("expected self_deployed rejection, got: %v", err) + } +} 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 1e362c72ed7..037ed498662 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 @@ -50,8 +50,34 @@ }, "kind": { "type": "string", - "description": "The agent kind. Currently only 'hosted' is supported.", - "enum": ["hosted"] + "description": "The agent kind. 'hosted' for a containerized/code agent; 'prompt-voice' for a declarative (managed) speech-to-speech voice agent.", + "enum": ["hosted", "prompt-voice"] + }, + "modelType": { + "type": "string", + "description": "Voice agent (kind: prompt-voice) model-inference mode. Only 'managed' (Voice Live-hosted) is supported in v1.", + "enum": ["managed"] + }, + "model": { + "type": "object", + "description": "Voice agent (kind: prompt-voice) speech-to-speech model (e.g. id: gpt-realtime).", + "properties": { + "id": { "type": "string", "description": "Model name (e.g. 'gpt-realtime')." } + }, + "required": ["id"], + "additionalProperties": true + }, + "instructions": { + "type": "string", + "description": "Voice agent (kind: prompt-voice) system prompt for the assistant." + }, + "voice": { + "type": "string", + "description": "Voice agent (kind: prompt-voice) output voice name (e.g. 'en-US-Ava:DragonHDLatestNeural' for an Azure Neural voice, or 'alloy' for an OpenAI realtime voice)." + }, + "store": { + "type": "boolean", + "description": "Voice agent (kind: prompt-voice) server-side logging toggle (transcript + per-turn audio). Defaults to false when omitted." }, "name": { "type": "string", From 82e094adc26b18e71fc82ef9b8485fd10eb79806 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:22:37 +0800 Subject: [PATCH 03/19] test(projects): sync synthesis parity copy for prompt-voice comment The azure.ai.projects synthesis copy must stay byte-identical to the azure.ai.agents copy (TestAgentsSynthesisCopyMatches). Mirror the prompt-voice comment update made in the agents synthesizer. --- .../azure.ai.projects/internal/synthesis/synthesizer.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go index 8c4e7d7baea..4e67bffa03a 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -618,7 +618,8 @@ func agentNeedsAcr(a agentBlock) bool { return false } // "hosted" is the only container kind; an empty kind defaults to hosted for - // back-compat. Other explicit kinds (prompt, workflow) do not build. + // back-compat. Other explicit kinds (prompt, prompt-voice, workflow) do not + // build a container image. // NOTE: if a future non-container kind can omit kind:, replace this // default-to-hosted with an explicit allowlist so it does not trigger ACR. kind := strings.TrimSpace(a.Kind) From fd2d7012265b6e9a01e14e97d89c2cb06e1de300 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:11:26 +0800 Subject: [PATCH 04/19] fix(agents): address prompt-voice review feedback Resolve four correctness gaps in the declarative prompt-voice flow surfaced in review, keeping every existing hosted/container/workflow path unchanged: - init: validate --kind (and its --image incompatibility) before either the image or prompt-voice synthesis fast path, so `--kind prompt-voice --image` is rejected instead of silently creating a hosted image agent. - init: skipACR now also covers prompt-voice (managed, no container), while a new isHostedAgent decision drives hosted-region filtering. selectFoundryProject gains a distinct filterHostedRegions parameter so a voice agent skips ACR without being constrained to hosted-agent regions. - deploy: resolve an explicit AGENT_DEFINITION_PATH override before the voice/container dispatch (resolveVoiceAgentForDeploy), so an override wins for voice just as it does for the container path. - deploy contract: make Endpoints() and next-step isDeployed voice-aware. Voice agents record only NAME + base ENDPOINT (no agent-version / per-protocol endpoints), so both consumers now treat the base endpoint as the deployment marker instead of reporting a created voice agent as undeployed. Adds unit tests for the skipACR/isHostedAgent split, the override-precedence dispatch, and the voice deployed-marker fallback. --- .../azure.ai.agents/internal/cmd/init.go | 82 +++++++++++++--- .../cmd/init_foundry_project_setup.go | 6 +- .../cmd/init_foundry_resources_helpers.go | 12 ++- .../internal/cmd/init_from_code.go | 6 +- .../azure.ai.agents/internal/cmd/init_test.go | 43 ++++++++ .../internal/cmd/nextstep/state.go | 22 ++++- .../internal/cmd/nextstep/state_test.go | 41 ++++++++ .../internal/project/agent_definition.go | 69 ++++++++++++- .../internal/project/service_target_agent.go | 22 ++++- .../project/voice_deploy_dispatch_test.go | 98 +++++++++++++++++++ 10 files changed, 372 insertions(+), 29 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/project/voice_deploy_dispatch_test.go 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 2389ff5266d..94fa93c3bbd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -118,6 +118,7 @@ type InitAction struct { deploymentDetails []project.Deployment containerSettings *project.ContainerSettings isCodeDeploy bool // true when user selects code deploy mode; skips ACR config + isVoiceAgent bool // true when the manifest kind is prompt-voice (managed, no container) httpClient *http.Client serviceNameOverride string // when set, addToProject uses this instead of the manifest name createdFolderDisplay string // pre-computed relative display path for the created folder @@ -139,7 +140,18 @@ type InitAction struct { // This happens when: // - Code deploy mode is selected (ZIP upload, no container build) // - Pre-built image is provided via --image flag (user manages their own registry) +// - The manifest is a prompt-voice agent (managed, no container image) func (a *InitAction) skipACR() bool { + return a.isCodeDeploy || a.flags.image != "" || a.isVoiceAgent +} + +// isHostedAgent reports whether the agent is deployed as an azd hosted agent +// (code deploy or a pre-built --image). Hosted agents must land in a Foundry +// project whose region supports hosted agents, so this gates the region filter +// in selectFoundryProject. It is deliberately distinct from skipACR: a +// prompt-voice agent also skips ACR, but is managed rather than hosted and must +// not be constrained to hosted-agent regions. +func (a *InitAction) isHostedAgent() bool { return a.isCodeDeploy || a.flags.image != "" } @@ -1254,6 +1266,28 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // when a template adds a subfolder to an existing project. existingProject := fileExists("azure.yaml") + // Validate --kind and its incompatible options before either synthesis + // branch. The image and prompt-voice fast paths both mutate + // flags.manifestPointer, so validating inside one branch is unreachable + // when the other runs first (e.g. --kind prompt-voice --image would + // otherwise silently create a hosted image agent). + if flags.kind != "" { + if !strings.EqualFold(flags.kind, kindFlagPromptVoice) { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unsupported --kind value %q", flags.kind), + fmt.Sprintf("the only supported --kind value is %q", kindFlagPromptVoice), + ) + } + if flags.image != "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--kind prompt-voice cannot be combined with --image", + "a voice agent is managed and has no container image; drop --image", + ) + } + } + // Bring-your-own-image fast path: when --image is set without a manifest, // there is no source to scaffold and no template/language to choose. // Synthesize a minimal hosted container manifest and route it through the @@ -1288,21 +1322,9 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // choose. Synthesize a declarative (managed) voice manifest and route it // through the manifest flow (which skips the init-mode / template / // language prompts and code scaffolding). Mirrors the --image fast path. + // --kind value and --image incompatibility are validated above, before + // either synthesis branch. if flags.kind != "" && flags.manifestPointer == "" { - if !strings.EqualFold(flags.kind, kindFlagPromptVoice) { - return exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("unsupported --kind value %q", flags.kind), - fmt.Sprintf("the only supported --kind value is %q", kindFlagPromptVoice), - ) - } - if flags.image != "" { - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "--kind prompt-voice cannot be combined with --image", - "a voice agent is managed and has no container image; drop --image", - ) - } if flags.agentName == "" { return exterrors.Validation( exterrors.CodeInvalidParameter, @@ -2163,6 +2185,24 @@ func manifestHasModelResources(manifest *agent_yaml.AgentManifest) bool { return false } +// agentManifestKind extracts the agent kind from a manifest's template. The kind +// lives inside the (untyped) Template payload rather than on AgentManifest, so we +// round-trip the template into an AgentDefinition to read it. Mirrors the +// extraction addToProject performs before dispatching on kind. +func agentManifestKind(manifest *agent_yaml.AgentManifest) (agent_yaml.AgentKind, error) { + templateBytes, err := json.Marshal(manifest.Template) + if err != nil { + return "", fmt.Errorf("failed to marshal agent template to JSON: %w", err) + } + + var agentDef agent_yaml.AgentDefinition + if err := json.Unmarshal(templateBytes, &agentDef); err != nil { + return "", fmt.Errorf("failed to unmarshal agent template to AgentDefinition: %w", err) + } + + return agentDef.Kind, nil +} + // configureModelChoice presents the "use existing / deploy new" model configuration choice // and establishes the necessary Azure context (subscription, location, project) before // ProcessModels is called. This defers subscription/location prompting until we know @@ -2170,6 +2210,14 @@ func manifestHasModelResources(manifest *agent_yaml.AgentManifest) bool { func (a *InitAction) configureModelChoice( ctx context.Context, agentManifest *agent_yaml.AgentManifest, ) (*agent_yaml.AgentManifest, error) { + // Record whether this manifest is a prompt-voice (managed) agent so ACR is + // skipped (skipACR) without treating it as a hosted agent for region + // filtering (isHostedAgent). Best-effort: a parse failure here leaves the + // default non-voice behavior unchanged. + if kind, err := agentManifestKind(agentManifest); err == nil { + a.isVoiceAgent = kind == agent_yaml.AgentKindPromptVoice + } + // When no --project-id flag was given, check whether the azd environment already // has a Foundry project configured from a previous init. If so, reuse it so the // user isn't prompted to select a project they already chose. @@ -2254,7 +2302,8 @@ func (a *InitAction) configureModelChoice( ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId, a.skipACR(), - true, // bicepless + a.isHostedAgent(), // filterHostedRegions + true, // bicepless ) if err != nil { return nil, err @@ -2333,7 +2382,8 @@ func (a *InitAction) configureModelChoice( ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, "", a.skipACR(), - true, // bicepless + a.isHostedAgent(), // filterHostedRegions + true, // bicepless ) if err != nil { return nil, err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go index 4501750fbce..63fcfe68104 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go @@ -71,7 +71,8 @@ func configureFoundryProject( ctx, azdClient, newCred, azureContext, envName, azureContext.Scope.SubscriptionId, projectResourceId, skipACR, - true, // bicepless + skipACR, // filterHostedRegions: this path is code/container only (non-voice) + true, // bicepless ) if err != nil { return nil, err @@ -144,7 +145,8 @@ func configureFoundryProject( ctx, azdClient, newCred, azureContext, envName, azureContext.Scope.SubscriptionId, "", skipACR, - true, // bicepless + skipACR, // filterHostedRegions: this path is code/container only (non-voice) + true, // bicepless ) if err != nil { return nil, err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go index 32085a6668f..4dbf273c034 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_resources_helpers.go @@ -1401,6 +1401,9 @@ func sortModelDeploymentCandidates(candidates []*azdext.AiModelDeployment, defau // "Create a new Foundry project" or no projects exist. // When a project is selected, configures all project-related environment variables. // skipACR skips ACR connection discovery (used for code deploy); +// filterHostedRegions restricts the project list to regions that support hosted +// agents (code deploy or --image). These are separate decisions: a prompt-voice +// (managed) agent skips ACR but is not hosted, so it must not be region-filtered. // bicepless skips both ACR and AppInsights prompts (see // configureFoundryProjectEnv). func selectFoundryProject( @@ -1412,6 +1415,7 @@ func selectFoundryProject( subscriptionId string, projectResourceId string, skipACR bool, + filterHostedRegions bool, bicepless bool, ) (*FoundryProjectInfo, error) { spinnerText := "Searching for Foundry projects in your subscription..." @@ -1450,9 +1454,11 @@ func selectFoundryProject( return nil, fmt.Errorf("failed to list Foundry projects: %w", err) } - // When ACR is skipped (code deploy, or a pre-built --image), the agent runs as a - // hosted agent, so restrict to regions that support hosted agents. - if skipACR { + // Hosted agents (code deploy or a pre-built --image) must run in a region that + // supports hosted agents, so restrict the project list to those regions. This + // is intentionally gated on filterHostedRegions rather than skipACR: a managed + // prompt-voice agent also skips ACR but is not hosted and must not be filtered. + if filterHostedRegions { supportedRegions, regErr := supportedRegionsForInit(ctx) if regErr != nil { // Propagate context cancellation/timeout — these are not recoverable fetch failures. 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 466c42a1317..c05fb2cc652 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 @@ -338,7 +338,8 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId, deployMode == "code", - true, // bicepless + deployMode == "code", // filterHostedRegions: code deploy targets hosted agents + true, // bicepless ) if err != nil { return nil, err @@ -405,7 +406,8 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name, a.azureContext.Scope.SubscriptionId, "", deployMode == "code", - true, // bicepless + deployMode == "code", // filterHostedRegions: code deploy targets hosted agents + true, // bicepless ) if err != nil { return nil, err 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 4d4741eb750..444a3bd39fa 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 @@ -269,6 +269,7 @@ func TestSkipACR(t *testing.T) { name string isCodeDeploy bool image string + isVoiceAgent bool want bool }{ { @@ -289,6 +290,13 @@ func TestSkipACR(t *testing.T) { image: "myacr.azurecr.io/agent:v1", want: true, }, + { + name: "voice agent skips ACR", + isCodeDeploy: false, + image: "", + isVoiceAgent: true, + want: true, + }, { name: "neither set does not skip ACR", isCodeDeploy: false, @@ -303,6 +311,7 @@ func TestSkipACR(t *testing.T) { action := &InitAction{ isCodeDeploy: tt.isCodeDeploy, + isVoiceAgent: tt.isVoiceAgent, flags: &initFlags{image: tt.image}, } @@ -311,6 +320,40 @@ func TestSkipACR(t *testing.T) { } } +// TestIsHostedAgent verifies that isHostedAgent is decoupled from skipACR: a +// voice agent skips ACR but is not a hosted agent, so it must not be treated as +// hosted for region filtering. +func TestIsHostedAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + isCodeDeploy bool + image string + isVoiceAgent bool + want bool + }{ + {name: "code deploy is hosted", isCodeDeploy: true, want: true}, + {name: "image is hosted", image: "myacr.azurecr.io/agent:v1", want: true}, + {name: "voice is not hosted", isVoiceAgent: true, want: false}, + {name: "plain container is not hosted", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + action := &InitAction{ + isCodeDeploy: tt.isCodeDeploy, + isVoiceAgent: tt.isVoiceAgent, + flags: &initFlags{image: tt.image}, + } + + require.Equal(t, tt.want, action.isHostedAgent()) + }) + } +} + func TestSynthesizeImageManifestFile(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 0f2739ff1a5..aea342a2f8a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -34,6 +34,12 @@ const ( // agent service. Filled with the upper-cased service key. agentVersionVarFormat = "AGENT_%s_VERSION" + // agentEndpointVarFormat is the base endpoint env-var written for every + // deployed agent. Voice agents (kind: prompt-voice) are created + // synchronously with no agent-version object, so this base endpoint is the + // only deployment marker they set — isDeployed falls back to it. + agentEndpointVarFormat = "AGENT_%s_ENDPOINT" + // projectEndpointVar is the env-var that carries the Foundry project // endpoint URL produced by `azd ai agent init`. projectEndpointVar = "FOUNDRY_PROJECT_ENDPOINT" @@ -752,7 +758,21 @@ func isDeployed( *errs = append(*errs, fmt.Errorf("read %s: %w", key, err)) return false } - return value != "" + if value != "" { + return true + } + + // Voice agents (kind: prompt-voice) deploy without an agent-version object, + // so they never set AGENT__VERSION. Fall back to the base endpoint + // marker, which every voice deploy writes, so a successfully created voice + // agent is not reported as undeployed. + endpointKey := fmt.Sprintf(agentEndpointVarFormat, serviceKey(serviceName)) + endpointValue, err := src.EnvValue(ctx, envName, endpointKey) + if err != nil { + *errs = append(*errs, fmt.Errorf("read %s: %w", endpointKey, err)) + return false + } + return endpointValue != "" } // serviceKey converts a service name into the env-var key fragment used by diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index e118f83a8bd..14c5f38a301 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -292,6 +292,47 @@ func TestServiceKey(t *testing.T) { } } +// TestIsDeployed_VoiceEndpointFallback verifies that a voice agent — which sets +// only AGENT__NAME and AGENT__ENDPOINT, never AGENT__VERSION — is +// still reported as deployed via the base endpoint marker, while an agent with +// neither version nor endpoint is reported undeployed. +func TestIsDeployed_VoiceEndpointFallback(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + values map[string]string + want bool + }{ + { + name: "version set: deployed (hosted agent)", + values: map[string]string{"env1/AGENT_VOICE_SVC_VERSION": "1"}, + want: true, + }, + { + name: "no version but base endpoint set: deployed (voice agent)", + values: map[string]string{"env1/AGENT_VOICE_SVC_ENDPOINT": "https://x/voice_agents/a"}, + want: true, + }, + { + name: "neither version nor endpoint: undeployed", + values: map[string]string{}, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + src := &fakeSource{values: tc.values} + var errs []error + got := isDeployed(context.Background(), src, "env1", "voice-svc", &errs) + assert.Equal(t, tc.want, got) + assert.Empty(t, errs) + }) + } +} + func TestOptionsApplyCleanly(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index dd264944a12..d1ca72d0231 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -768,7 +768,74 @@ func parseContainerAgentYAML(data []byte) (agent_yaml.ContainerAgent, bool, erro return agentDef, true, nil } -// AgentDefinitionToServiceProperties marshals a ContainerAgent into the inline +// voiceAgentFromDefinitionFile reads an agent definition file (an +// AGENT_DEFINITION_PATH override) and reports whether it declares a prompt-voice +// agent, returning the parsed VoiceAgent when it does. A non-voice (e.g. hosted) +// definition returns found=false with no error so the caller can fall through to +// the container path, mirroring VoiceAgentFromResolvedService's contract. This +// lets an explicit override drive the voice/container dispatch with the same +// precedence loadContainerAgentDefinition documents. +func voiceAgentFromDefinitionFile(path string) (agent_yaml.VoiceAgent, bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return agent_yaml.VoiceAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("failed to read agent manifest file: %s", err), + "verify the agent.yaml file exists and is readable", + ) + } + + var genericTemplate map[string]any + if err := yaml.Unmarshal(data, &genericTemplate); err != nil { + return agent_yaml.VoiceAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("YAML content is not valid: %s", err), + "verify the agent.yaml has valid YAML syntax", + ) + } + + if kind, _ := genericTemplate["kind"].(string); kind != string(agent_yaml.AgentKindPromptVoice) { + // Not a voice definition; let the container path handle the override. + return agent_yaml.VoiceAgent{}, false, nil + } + + if err := agent_yaml.ValidateAgentDefinition(data); err != nil { + return agent_yaml.VoiceAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent.yaml is not valid: %s", err), + "fix the agent.yaml file according to the schema", + ) + } + + var va agent_yaml.VoiceAgent + if err := yaml.Unmarshal(data, &va); err != nil { + return agent_yaml.VoiceAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("YAML content is not valid for a voice agent: %s", err), + "fix the agent.yaml to match the prompt-voice schema", + ) + } + + return va, true, nil +} + +// resolveVoiceAgentForDeploy determines whether the service should deploy a +// prompt-voice agent, honoring the AGENT_DEFINITION_PATH override precedence: an +// explicit override file wins over the service entry (matching +// loadContainerAgentDefinition). When agentDefinitionPath is empty the resolved +// service entry is inspected instead. A non-voice result returns found=false so +// the caller falls through to the container deploy path unchanged. +func resolveVoiceAgentForDeploy( + agentDefinitionPath string, + svc *azdext.ServiceConfig, + projectRoot string, +) (agent_yaml.VoiceAgent, bool, error) { + if agentDefinitionPath != "" { + return voiceAgentFromDefinitionFile(agentDefinitionPath) + } + return VoiceAgentFromResolvedService(svc, projectRoot) +} + // service-level properties (and the `container` CPU/memory config) used by the // unified azure.ai.agent service entry. The returned struct is merged into the // service entry's AdditionalProperties at init time. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 32f1027e287..c62aafc0e47 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -428,6 +428,16 @@ func (p *AgentServiceTargetProvider) Endpoints( serviceKey := p.getServiceKey(serviceConfig.Name) agentNameKey := fmt.Sprintf("AGENT_%s_NAME", serviceKey) agentVersionKey := fmt.Sprintf("AGENT_%s_VERSION", serviceKey) + agentEndpointKey := fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey) + + // Voice agents (kind: prompt-voice) are created synchronously with no + // agent-version object and no per-protocol endpoints; they record only NAME + // and a base ENDPOINT. Recognize that shape via the base endpoint marker so a + // successfully deployed voice agent reports its endpoint instead of failing + // the version/per-protocol checks below (which only apply to hosted agents). + if azdEnv[agentVersionKey] == "" && azdEnv[agentEndpointKey] != "" { + return []string{azdEnv[agentEndpointKey]}, nil + } if azdEnv[agentNameKey] == "" || azdEnv[agentVersionKey] == "" { return nil, exterrors.Dependency( @@ -1073,10 +1083,14 @@ func (p *AgentServiceTargetProvider) Deploy( // Voice agents (kind: prompt-voice) use a fundamentally different data-plane // contract than hosted/workflow agents: a synchronous POST to /voice_agents - // that returns an AgentObject directly, with no version/polling model. Peek - // the definition first and dispatch to an isolated method so the container - // deploy path below stays byte-for-byte unchanged. - if va, isVoice, vErr := VoiceAgentFromResolvedService(serviceConfig, p.projectPath); vErr != nil { + // that returns an AgentObject directly, with no version/polling model. Resolve + // the definition first — honoring the AGENT_DEFINITION_PATH override precedence + // so an override drives this dispatch just as it does the container path — and + // route voice to an isolated method so the container deploy path below stays + // byte-for-byte unchanged. + if va, isVoice, vErr := resolveVoiceAgentForDeploy( + p.agentDefinitionPath, serviceConfig, p.projectPath, + ); vErr != nil { return nil, vErr } else if isVoice { return p.deployVoiceAgent(ctx, serviceConfig, va, azdEnv, progress) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/voice_deploy_dispatch_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/voice_deploy_dispatch_test.go new file mode 100644 index 00000000000..1e419fefbe3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/voice_deploy_dispatch_test.go @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const voiceOverrideYAML = `kind: prompt-voice +name: override-voice +model_type: managed +model: + id: gpt-realtime +voice: alloy +` + +const hostedOverrideYAML = `kind: hosted +name: override-hosted +image: myregistry.azurecr.io/agent:v1 +` + +// writeTempAgentFile writes content to a temp agent.yaml and returns its path. +func writeTempAgentFile(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +// TestVoiceAgentFromDefinitionFile verifies that a prompt-voice override file is +// parsed as a voice agent, while a hosted override is not (found=false) so the +// caller falls through to the container path. +func TestVoiceAgentFromDefinitionFile(t *testing.T) { + t.Parallel() + + t.Run("voice override is recognized", func(t *testing.T) { + t.Parallel() + path := writeTempAgentFile(t, voiceOverrideYAML) + + va, isVoice, err := voiceAgentFromDefinitionFile(path) + require.NoError(t, err) + assert.True(t, isVoice) + assert.Equal(t, "override-voice", va.Name) + require.NotNil(t, va.Model) + assert.Equal(t, "gpt-realtime", va.Model.Id) + require.NotNil(t, va.Voice) + assert.Equal(t, "alloy", *va.Voice) + }) + + t.Run("hosted override falls through to container path", func(t *testing.T) { + t.Parallel() + path := writeTempAgentFile(t, hostedOverrideYAML) + + _, isVoice, err := voiceAgentFromDefinitionFile(path) + require.NoError(t, err) + assert.False(t, isVoice) + }) + + t.Run("missing file errors", func(t *testing.T) { + t.Parallel() + _, isVoice, err := voiceAgentFromDefinitionFile(filepath.Join(t.TempDir(), "nope.yaml")) + assert.Error(t, err) + assert.False(t, isVoice) + }) +} + +// TestResolveVoiceAgentForDeploy_OverridePrecedence verifies that an explicit +// AGENT_DEFINITION_PATH override wins over the resolved service entry, so a voice +// override drives the voice dispatch even when the service entry is absent. +func TestResolveVoiceAgentForDeploy_OverridePrecedence(t *testing.T) { + t.Parallel() + + t.Run("voice override wins", func(t *testing.T) { + t.Parallel() + path := writeTempAgentFile(t, voiceOverrideYAML) + + va, isVoice, err := resolveVoiceAgentForDeploy(path, nil, "") + require.NoError(t, err) + assert.True(t, isVoice) + assert.Equal(t, "override-voice", va.Name) + }) + + t.Run("hosted override routes to container path", func(t *testing.T) { + t.Parallel() + path := writeTempAgentFile(t, hostedOverrideYAML) + + _, isVoice, err := resolveVoiceAgentForDeploy(path, nil, "") + require.NoError(t, err) + assert.False(t, isVoice) + }) +} From 438466c7687a53ce45988f8bab98a55f97cbdc7f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:42:54 +0800 Subject: [PATCH 05/19] fix(agents): address prompt-voice review round 2 - Endpoints()/isDeployed(): gate voice base-endpoint fallback on the service's actual prompt-voice kind instead of the env-var shape, so a partially-failed hosted deploy still surfaces CodeMissingAgentEnvVars - add nextstep isVoiceService helper (mirrors project kind gate; the two stay in separate packages to avoid a project->nextstep import cycle) - CreateVoiceAgent: document create-only redeploy semantics - isOpenAIVoice: classify via explicit OpenAI voice set + Azure Neural locale-prefix pattern instead of a bare '-' check - init.go: drop orphaned comment fragment - tests: cover hosted lingering-endpoint gate and voice name classification --- .../azure.ai.agents/internal/cmd/init.go | 2 - .../internal/cmd/nextstep/state.go | 61 ++++++++++++++++++- .../internal/cmd/nextstep/state_test.go | 25 +++++--- .../pkg/agents/agent_api/operations.go | 9 +++ .../internal/pkg/agents/agent_yaml/map.go | 35 ++++++++++- .../pkg/agents/agent_yaml/map_voice_test.go | 6 +- .../internal/project/service_target_agent.go | 12 ++-- 7 files changed, 130 insertions(+), 20 deletions(-) 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 94fa93c3bbd..ba946efe3e6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1342,8 +1342,6 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, flags.manifestPointer = manifestPath userProvidedManifest = true } - // when no --manifest flag was provided. - // // manifestDetectedButDeclined: gates the definition-reuse scan below so // a declined manifest is not re-discovered and mis-classified. manifestDetectedButDeclined := false diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index aea342a2f8a..19c934eefab 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -452,7 +452,7 @@ func collectServices( RelativePath: svc.RelativePath, Protocol: protocol, MultiProtocol: multiProtocol, - IsDeployed: isDeployed(ctx, src, envName, svc.Name, errs), + IsDeployed: isDeployed(ctx, src, envName, svc.Name, isVoiceService(project.Path, svc), errs), }) } @@ -519,6 +519,53 @@ func structHasKind(s *structpb.Struct) bool { return ok && strings.TrimSpace(v.GetStringValue()) != "" } +// isVoiceService reports whether the service declares kind: prompt-voice, +// preferring the inline/legacy config carried on the service entry and falling +// back to the on-disk agent.yaml. It is the nextstep-local mirror of the kind +// gate used by project.VoiceAgentFromResolvedService; the two live in separate +// packages because project imports nextstep, so a literally shared helper would +// create an import cycle. +func isVoiceService(projectPath string, svc *azdext.ServiceConfig) bool { + if kind := serviceConfigKind(svc); kind != "" { + return kind == string(agent_yaml.AgentKindPromptVoice) + } + return fileServiceKind(projectPath, svc) == string(agent_yaml.AgentKindPromptVoice) +} + +// serviceConfigKind returns the declared kind carried inline (or in the legacy +// config block) on the service entry, or "" when none is present. +func serviceConfigKind(svc *azdext.ServiceConfig) string { + props := nextStepServiceConfigProps(svc) + if len(props) == 0 { + return "" + } + kind, _ := props["kind"].(string) + return strings.TrimSpace(kind) +} + +// fileServiceKind returns the declared kind from the service's on-disk +// agent.yaml, or "" when the manifest is missing or unreadable. +func fileServiceKind(projectPath string, svc *azdext.ServiceConfig) string { + if projectPath == "" || svc == nil { + return "" + } + manifestPath, err := paths.JoinAllowRoot(projectPath, svc.RelativePath, "agent.yaml") + if err != nil { + return "" + } + data, err := os.ReadFile(manifestPath) //nolint:gosec // path is validated under the project root + if err != nil { + return "" + } + var def struct { + Kind string `yaml:"kind"` + } + if err := yaml.Unmarshal(data, &def); err != nil { + return "" + } + return strings.TrimSpace(def.Kind) +} + func loadServiceProtocolFromFile(projectPath, relativePath string) (string, bool) { if projectPath == "" { return "", false @@ -747,6 +794,7 @@ func isDeployed( ctx context.Context, src Source, envName, serviceName string, + isVoice bool, errs *[]error, ) bool { if envName == "" || serviceName == "" { @@ -765,7 +813,16 @@ func isDeployed( // Voice agents (kind: prompt-voice) deploy without an agent-version object, // so they never set AGENT__VERSION. Fall back to the base endpoint // marker, which every voice deploy writes, so a successfully created voice - // agent is not reported as undeployed. + // agent is not reported as undeployed. Gate this on the service's actual + // declared kind: a hosted agent whose deploy partially failed can also + // present an empty VERSION with a lingering ENDPOINT, and must stay reported + // as not-deployed. This mirrors the kind gate in + // AgentServiceTargetProvider.Endpoints (project package); the two live in + // separate packages because project imports nextstep, so a literally shared + // helper would create an import cycle. + if !isVoice { + return false + } endpointKey := fmt.Sprintf(agentEndpointVarFormat, serviceKey(serviceName)) endpointValue, err := src.EnvValue(ctx, envName, endpointKey) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index 14c5f38a301..5a2dd721d86 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -300,9 +300,10 @@ func TestIsDeployed_VoiceEndpointFallback(t *testing.T) { t.Parallel() tests := []struct { - name string - values map[string]string - want bool + name string + values map[string]string + isVoice bool + want bool }{ { name: "version set: deployed (hosted agent)", @@ -310,9 +311,19 @@ func TestIsDeployed_VoiceEndpointFallback(t *testing.T) { want: true, }, { - name: "no version but base endpoint set: deployed (voice agent)", - values: map[string]string{"env1/AGENT_VOICE_SVC_ENDPOINT": "https://x/voice_agents/a"}, - want: true, + name: "no version but base endpoint set: deployed (voice agent)", + values: map[string]string{"env1/AGENT_VOICE_SVC_ENDPOINT": "https://x/voice_agents/a"}, + isVoice: true, + want: true, + }, + { + name: "hosted agent with lingering endpoint but no version: undeployed", + // A partially-failed hosted deploy can present an empty VERSION with a + // stale ENDPOINT. The endpoint fallback must not fire for non-voice + // services, so it stays reported as not-deployed. + values: map[string]string{"env1/AGENT_VOICE_SVC_ENDPOINT": "https://x/agents/a"}, + isVoice: false, + want: false, }, { name: "neither version nor endpoint: undeployed", @@ -326,7 +337,7 @@ func TestIsDeployed_VoiceEndpointFallback(t *testing.T) { t.Parallel() src := &fakeSource{values: tc.values} var errs []error - got := isDeployed(context.Background(), src, "env1", "voice-svc", &errs) + got := isDeployed(context.Background(), src, "env1", "voice-svc", tc.isVoice, &errs) assert.Equal(t, tc.want, got) assert.Empty(t, errs) }) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index f7d6e7062f0..c94ebc69294 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -177,6 +177,15 @@ const voiceAgentsPreviewFeature = "VoiceAgents=V1Preview" // This routes the request directly to the regional Hyena data-plane host, // bypassing the public Foundry APIM (whose voice route may not yet be rolled // out). Pass "" to use the default endpoint routing. +// +// Redeploy semantics: the voice data-plane exposes create-only POST /voice_agents +// with no version/upsert model (unlike hosted agents, which mint a new +// agent-version per deploy). A second `azd deploy` of the same voice service +// therefore re-POSTs with the same name and the service rejects it with a +// non-success status, which this method surfaces as a deploy error rather than +// silently overwriting the existing agent. Idempotent redeploy/update is tracked +// as a follow-up (see the PR "Follow-ups" section); until the service adds an +// update route, redeploy requires deleting the existing voice agent first. func (c *AgentClient) CreateVoiceAgent( ctx context.Context, request *CreateAgentRequest, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index 61f48a284a5..a9383190b9c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -7,6 +7,7 @@ import ( "fmt" "maps" "math" + "regexp" "strings" "azureaiagent/internal/pkg/agents/agent_api" @@ -471,11 +472,39 @@ const ( defaultVoiceName = "en-US-Ava:DragonHDLatestNeural" ) +// knownOpenAIVoices is the set of OpenAI realtime voice names accepted by the +// data-plane voice service. OpenAI voices are single lowercase tokens; Azure +// Neural voices are locale-prefixed (see azureNeuralVoicePattern). Keep this in +// sync with the service's supported voice list. +var knownOpenAIVoices = map[string]struct{}{ + "alloy": {}, + "ash": {}, + "ballad": {}, + "coral": {}, + "echo": {}, + "sage": {}, + "shimmer": {}, + "verse": {}, +} + +// azureNeuralVoicePattern matches the locale prefix that every Azure Neural +// voice name carries, e.g. "en-US-Ava:DragonHDLatestNeural" or +// "ja-JP-NanamiNeural". The service contract guarantees this -- +// shape for Azure voices, which is what distinguishes them from the flat +// lowercase OpenAI voice tokens. +var azureNeuralVoicePattern = regexp.MustCompile(`^[a-z]{2,3}-[A-Z]{2,3}-`) + // isOpenAIVoice reports whether a voice name denotes an OpenAI realtime voice -// (single lowercase word, e.g. "alloy") vs an Azure Neural voice (contains "-", -// e.g. "en-US-Ava:DragonHDLatestNeural"). +// (e.g. "alloy") vs an Azure Neural voice (e.g. "en-US-Ava:DragonHDLatestNeural"). +// It first matches the explicit known-OpenAI set, then falls back to structure: +// anything lacking the Azure Neural locale prefix is treated as OpenAI. This is +// deliberately stricter than a plain "contains '-'" check so that a partly +// specified or future name is classified by its actual shape. func isOpenAIVoice(name string) bool { - return !strings.Contains(name, "-") + if _, ok := knownOpenAIVoices[strings.ToLower(strings.TrimSpace(name))]; ok { + return true + } + return !azureNeuralVoicePattern.MatchString(name) } // buildVoiceConfig chooses the OpenAI vs Azure voice type by name shape. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go index fe6622b7327..20fbdf725a0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go @@ -16,10 +16,12 @@ import ( func TestIsOpenAIVoice(t *testing.T) { t.Parallel() cases := map[string]bool{ - "alloy": true, // single lowercase word -> OpenAI + "alloy": true, // known OpenAI voice "verse": true, - "en-US-Ava:DragonHDLatestNeural": false, // contains "-" -> Azure Neural + "Shimmer": true, // known OpenAI voice, case-insensitive + "en-US-Ava:DragonHDLatestNeural": false, // Azure Neural locale prefix "en-US-JennyNeural": false, + "ja-JP-NanamiNeural": false, // non-en Azure locale prefix } for name, want := range cases { if got := isOpenAIVoice(name); got != want { diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index c62aafc0e47..036c769207a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -432,10 +432,14 @@ func (p *AgentServiceTargetProvider) Endpoints( // Voice agents (kind: prompt-voice) are created synchronously with no // agent-version object and no per-protocol endpoints; they record only NAME - // and a base ENDPOINT. Recognize that shape via the base endpoint marker so a - // successfully deployed voice agent reports its endpoint instead of failing - // the version/per-protocol checks below (which only apply to hosted agents). - if azdEnv[agentVersionKey] == "" && azdEnv[agentEndpointKey] != "" { + // and a base ENDPOINT. Gate the base-endpoint fallback on the service's + // actual declared kind rather than on the env-var shape: a hosted agent whose + // deploy partially failed (or whose vars were cleaned up) can also present an + // empty VERSION with a lingering ENDPOINT, and for that case we must still + // surface the actionable CodeMissingAgentEnvVars error below. + if _, isVoice, err := VoiceAgentFromResolvedService(serviceConfig, p.projectPath); err != nil { + return nil, err + } else if isVoice && azdEnv[agentEndpointKey] != "" { return []string{azdEnv[agentEndpointKey]}, nil } From a508d1a80605415b6dcf2be321d3eedd8c0a28e6 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:21:19 +0800 Subject: [PATCH 06/19] chore(agents): add Nanami to cspell dictionary Example Azure Neural voice name used in the isOpenAIVoice doc comment. --- cli/azd/extensions/azure.ai.agents/cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 35572bd144d..ec2db726d8d 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -10,6 +10,7 @@ words: - Reprompt # Voice (prompt-voice) agents - BYOM + - Nanami # Azure region names - australiaeast - brazilsouth From 9c5ca019f9ce1260cee509cca7d847b6b0090ffb Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:32:52 +0800 Subject: [PATCH 07/19] refactor(agents): unify prompt-voice kind resolution in a leaf package Jon flagged that Endpoints(), the deploy path, and nextstep.isDeployed resolved 'is this a voice service?' three different ways, so a legacy-shape voice agent (kind only in the on-disk agent.yaml, none on the service entry) could deploy and still be reported as missing env vars by Endpoints(). Introduce internal/pkg/agents/agentkind, a small leaf package with a single kind lookup that mirrors the deploy precedence (definition-file override -> inline/legacy service-entry kind with $ref resolution -> on-disk agent.yaml). Route all three call sites through it: - resolveVoiceAgentForDeploy delegates the voice/non-voice decision to agentkind, then parses from the matched source - Endpoints() gates on agentkind.IsPromptVoice; resolution errors are non-fatal (falls through to the hosted guard) so hosted services keep their prior behavior on a path that never resolved config before - nextstep.isVoiceService delegates to agentkind, dropping its local mirror Add agentkind unit tests (incl. the legacy manifest-fallback regression) and cspell entries for 'agentkind' and 'Nanami'. --- .../extensions/azure.ai.agents/cspell.yaml | 1 + .../internal/cmd/nextstep/state.go | 51 +------ .../pkg/agents/agentkind/agentkind.go | 127 ++++++++++++++++++ .../pkg/agents/agentkind/agentkind_test.go | 107 +++++++++++++++ .../internal/project/agent_definition.go | 12 ++ .../internal/project/service_target_agent.go | 20 ++- 6 files changed, 267 insertions(+), 51 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind_test.go diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index ec2db726d8d..5d2d9e0950c 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -90,6 +90,7 @@ words: # Doctor / next-step terms - nextstep - nextsteps + - agentkind - undeployed - unredacted - UNKN diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 19c934eefab..337ed60e6d8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -15,6 +15,7 @@ import ( "strings" "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/agents/agentkind" "azureaiagent/internal/pkg/envkey" "azureaiagent/internal/pkg/paths" @@ -519,51 +520,13 @@ func structHasKind(s *structpb.Struct) bool { return ok && strings.TrimSpace(v.GetStringValue()) != "" } -// isVoiceService reports whether the service declares kind: prompt-voice, -// preferring the inline/legacy config carried on the service entry and falling -// back to the on-disk agent.yaml. It is the nextstep-local mirror of the kind -// gate used by project.VoiceAgentFromResolvedService; the two live in separate -// packages because project imports nextstep, so a literally shared helper would -// create an import cycle. +// isVoiceService reports whether the service declares kind: prompt-voice. It +// delegates to the shared agentkind lookup so the next-step reader classifies a +// service identically to the deploy path and Endpoints. Kind resolution is +// best-effort for next-step hints, so any error is treated as not-voice. func isVoiceService(projectPath string, svc *azdext.ServiceConfig) bool { - if kind := serviceConfigKind(svc); kind != "" { - return kind == string(agent_yaml.AgentKindPromptVoice) - } - return fileServiceKind(projectPath, svc) == string(agent_yaml.AgentKindPromptVoice) -} - -// serviceConfigKind returns the declared kind carried inline (or in the legacy -// config block) on the service entry, or "" when none is present. -func serviceConfigKind(svc *azdext.ServiceConfig) string { - props := nextStepServiceConfigProps(svc) - if len(props) == 0 { - return "" - } - kind, _ := props["kind"].(string) - return strings.TrimSpace(kind) -} - -// fileServiceKind returns the declared kind from the service's on-disk -// agent.yaml, or "" when the manifest is missing or unreadable. -func fileServiceKind(projectPath string, svc *azdext.ServiceConfig) string { - if projectPath == "" || svc == nil { - return "" - } - manifestPath, err := paths.JoinAllowRoot(projectPath, svc.RelativePath, "agent.yaml") - if err != nil { - return "" - } - data, err := os.ReadFile(manifestPath) //nolint:gosec // path is validated under the project root - if err != nil { - return "" - } - var def struct { - Kind string `yaml:"kind"` - } - if err := yaml.Unmarshal(data, &def); err != nil { - return "" - } - return strings.TrimSpace(def.Kind) + isVoice, err := agentkind.IsPromptVoice(svc, projectPath, "") + return err == nil && isVoice } func loadServiceProtocolFromFile(projectPath, relativePath string) (string, bool) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind.go new file mode 100644 index 00000000000..3c2370cea85 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind.go @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package agentkind resolves an agent service's declared kind (hosted, +// workflow, prompt-voice, …) from the same sources, in the same precedence +// order, that the deploy path uses. It exists as a small leaf package so the +// deploy path (project), the endpoint/next-step readers (project, nextstep), +// and any future caller all answer "what kind is this service?" identically — +// without either the project or nextstep package importing the other (project +// imports nextstep, so a shared helper in either would create an import cycle). +package agentkind + +import ( + "os" + "strings" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/paths" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" + "go.yaml.in/yaml/v3" + "google.golang.org/protobuf/types/known/structpb" +) + +// Kind resolves the declared kind for an agent service. Precedence mirrors the +// deploy path (resolveVoiceAgentForDeploy): +// +// 1. overridePath — an explicit definition file (e.g. AGENT_DEFINITION_PATH). +// When set it wins outright, matching the deploy-time override precedence. +// 2. the inline (preferred) or legacy config kind carried on the service entry, +// resolving a `$ref` file reference when the kind is not inline. +// 3. the on-disk agent.yaml / agent.yml in the service directory (the legacy +// shape, where the service entry carries no kind at all). +// +// It returns "" when no source declares a kind. An error is returned only when a +// referenced file or manifest is present but malformed; callers that treat kind +// detection as best-effort (endpoint/next-step readers) may ignore it, while the +// deploy path propagates it. +func Kind(svc *azdext.ServiceConfig, projectRoot, overridePath string) (string, error) { + if overridePath != "" { + return fileKind(overridePath) + } + if kind, err := entryKind(svc, projectRoot); err != nil || kind != "" { + return kind, err + } + return serviceDirKind(svc, projectRoot) +} + +// IsPromptVoice reports whether the service resolves to kind: prompt-voice. +func IsPromptVoice(svc *azdext.ServiceConfig, projectRoot, overridePath string) (bool, error) { + kind, err := Kind(svc, projectRoot, overridePath) + if err != nil { + return false, err + } + return kind == string(agent_yaml.AgentKindPromptVoice), nil +} + +// entryKind returns the kind declared inline on the service entry (or in the +// legacy config block), resolving a `$ref` file reference when the kind is not +// carried directly. Returns "" when the entry declares no kind. +func entryKind(svc *azdext.ServiceConfig, projectRoot string) (string, error) { + if svc == nil { + return "", nil + } + for _, props := range []*structpb.Struct{svc.GetAdditionalProperties(), svc.GetConfig()} { + if props == nil || len(props.GetFields()) == 0 { + continue + } + values := props.AsMap() + if kind := kindFromMap(values); kind != "" { + return kind, nil + } + if _, hasRef := values["$ref"]; !hasRef { + continue + } + resolved, err := foundry.ResolveFileRefs(values, projectRoot) + if err != nil { + return "", err + } + if kind := kindFromMap(resolved); kind != "" { + return kind, nil + } + } + return "", nil +} + +// serviceDirKind returns the kind declared by the service directory's on-disk +// agent.yaml (then agent.yml), or "" when neither is present. +func serviceDirKind(svc *azdext.ServiceConfig, projectRoot string) (string, error) { + if svc == nil || projectRoot == "" { + return "", nil + } + relativePath := svc.GetRelativePath() + for _, name := range []string{"agent.yaml", "agent.yml"} { + manifestPath, err := paths.JoinAllowRoot(projectRoot, relativePath, name) + if err != nil { + continue + } + if _, err := os.Stat(manifestPath); err != nil { + continue + } + return fileKind(manifestPath) + } + return "", nil +} + +// fileKind reads the top-level `kind` scalar from a YAML manifest file. +func fileKind(path string) (string, error) { + data, err := os.ReadFile(path) //nolint:gosec // path is validated under the project root by callers + if err != nil { + return "", err + } + var def struct { + Kind string `yaml:"kind"` + } + if err := yaml.Unmarshal(data, &def); err != nil { + return "", err + } + return strings.TrimSpace(def.Kind), nil +} + +// kindFromMap reads a trimmed top-level `kind` string from a resolved props map. +func kindFromMap(values map[string]any) string { + kind, _ := values["kind"].(string) + return strings.TrimSpace(kind) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind_test.go new file mode 100644 index 00000000000..3e67ae160d8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind_test.go @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agentkind + +import ( + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func mustStruct(t *testing.T, m map[string]any) *structpb.Struct { + t.Helper() + s, err := structpb.NewStruct(m) + require.NoError(t, err) + return s +} + +func TestKind_InlineOnEntry(t *testing.T) { + t.Parallel() + svc := &azdext.ServiceConfig{ + Name: "voice", + AdditionalProperties: mustStruct(t, map[string]any{"kind": "prompt-voice"}), + } + isVoice, err := IsPromptVoice(svc, t.TempDir(), "") + require.NoError(t, err) + assert.True(t, isVoice) +} + +func TestKind_LegacyConfigOnEntry(t *testing.T) { + t.Parallel() + svc := &azdext.ServiceConfig{ + Name: "voice", + Config: mustStruct(t, map[string]any{"kind": "prompt-voice"}), + } + isVoice, err := IsPromptVoice(svc, t.TempDir(), "") + require.NoError(t, err) + assert.True(t, isVoice) +} + +// TestKind_ManifestFallback is the regression for the legacy shape: the service +// entry carries no kind, so the kind must be read from the on-disk agent.yaml. +// This is the case where the deploy path (which reads the manifest) and the +// endpoint/next-step readers previously disagreed. +func TestKind_ManifestFallback(t *testing.T) { + t.Parallel() + root := t.TempDir() + svcDir := filepath.Join(root, "svc") + require.NoError(t, os.MkdirAll(svcDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(svcDir, "agent.yaml"), + []byte("kind: prompt-voice\nname: concierge\n"), 0o600)) + + svc := &azdext.ServiceConfig{Name: "voice", RelativePath: "svc"} + isVoice, err := IsPromptVoice(svc, root, "") + require.NoError(t, err) + assert.True(t, isVoice, "kind must be resolved from the on-disk manifest") +} + +func TestKind_OverridePathWins(t *testing.T) { + t.Parallel() + root := t.TempDir() + override := filepath.Join(root, "custom-def.yaml") + require.NoError(t, os.WriteFile(override, []byte("kind: prompt-voice\n"), 0o600)) + + // Entry declares hosted, but the explicit override file declares voice and + // must win, matching the deploy-time AGENT_DEFINITION_PATH precedence. + svc := &azdext.ServiceConfig{ + Name: "voice", + AdditionalProperties: mustStruct(t, map[string]any{"kind": "hosted"}), + } + isVoice, err := IsPromptVoice(svc, root, override) + require.NoError(t, err) + assert.True(t, isVoice) +} + +func TestKind_HostedIsNotVoice(t *testing.T) { + t.Parallel() + root := t.TempDir() + svcDir := filepath.Join(root, "svc") + require.NoError(t, os.MkdirAll(svcDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(svcDir, "agent.yaml"), + []byte("kind: hosted\nname: worker\n"), 0o600)) + + svc := &azdext.ServiceConfig{ + Name: "worker", + RelativePath: "svc", + AdditionalProperties: mustStruct(t, map[string]any{"kind": "hosted"}), + } + isVoice, err := IsPromptVoice(svc, root, "") + require.NoError(t, err) + assert.False(t, isVoice) +} + +func TestKind_AbsentReturnsEmpty(t *testing.T) { + t.Parallel() + svc := &azdext.ServiceConfig{Name: "worker", RelativePath: "svc"} + kind, err := Kind(svc, t.TempDir(), "") + require.NoError(t, err) + assert.Empty(t, kind) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index d1ca72d0231..5562621555e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -11,6 +11,7 @@ import ( "azureaiagent/internal/exterrors" "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/agents/agentkind" "azureaiagent/internal/pkg/paths" "azureaiagent/internal/pkg/projectconfig" @@ -825,11 +826,22 @@ func voiceAgentFromDefinitionFile(path string) (agent_yaml.VoiceAgent, bool, err // loadContainerAgentDefinition). When agentDefinitionPath is empty the resolved // service entry is inspected instead. A non-voice result returns found=false so // the caller falls through to the container deploy path unchanged. +// +// The voice/non-voice decision is delegated to the shared agentkind lookup so +// deploy, Endpoints, and next-step all classify a service identically; this +// function then parses the definition from whichever source agentkind matched. func resolveVoiceAgentForDeploy( agentDefinitionPath string, svc *azdext.ServiceConfig, projectRoot string, ) (agent_yaml.VoiceAgent, bool, error) { + isVoice, err := agentkind.IsPromptVoice(svc, projectRoot, agentDefinitionPath) + if err != nil { + return agent_yaml.VoiceAgent{}, false, err + } + if !isVoice { + return agent_yaml.VoiceAgent{}, false, nil + } if agentDefinitionPath != "" { return voiceAgentFromDefinitionFile(agentDefinitionPath) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 036c769207a..57b4cac87fb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -29,6 +29,7 @@ import ( "azureaiagent/internal/pkg/agents" "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/agents/agentkind" "azureaiagent/internal/pkg/azure" "azureaiagent/internal/pkg/paths" "azureaiagent/internal/pkg/projectconfig" @@ -433,13 +434,18 @@ func (p *AgentServiceTargetProvider) Endpoints( // Voice agents (kind: prompt-voice) are created synchronously with no // agent-version object and no per-protocol endpoints; they record only NAME // and a base ENDPOINT. Gate the base-endpoint fallback on the service's - // actual declared kind rather than on the env-var shape: a hosted agent whose - // deploy partially failed (or whose vars were cleaned up) can also present an - // empty VERSION with a lingering ENDPOINT, and for that case we must still - // surface the actionable CodeMissingAgentEnvVars error below. - if _, isVoice, err := VoiceAgentFromResolvedService(serviceConfig, p.projectPath); err != nil { - return nil, err - } else if isVoice && azdEnv[agentEndpointKey] != "" { + // actual declared kind (resolved via the shared agentkind lookup, so this + // agrees with the deploy path and next-step reader) rather than on the + // env-var shape: a hosted agent whose deploy partially failed (or whose vars + // were cleaned up) can also present an empty VERSION with a lingering + // ENDPOINT, and for that case we must still surface the actionable + // CodeMissingAgentEnvVars error below. Kind resolution is best-effort here: + // an error (or non-voice result) simply falls through to the hosted guard, so + // hosted services keep their prior behavior on a path that never resolved + // config before. + if isVoice, err := agentkind.IsPromptVoice( + serviceConfig, p.projectPath, p.agentDefinitionPath, + ); err == nil && isVoice && azdEnv[agentEndpointKey] != "" { return []string{azdEnv[agentEndpointKey]}, nil } From 4c47fab718d33476ed3f1e34483e09806097b6b6 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:37:44 +0800 Subject: [PATCH 08/19] test(agents): tighten agentkind test dir perms to 0o750 (gosec G301) --- .../internal/pkg/agents/agentkind/agentkind_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind_test.go index 3e67ae160d8..a4d0e13bc29 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agentkind/agentkind_test.go @@ -51,7 +51,7 @@ func TestKind_ManifestFallback(t *testing.T) { t.Parallel() root := t.TempDir() svcDir := filepath.Join(root, "svc") - require.NoError(t, os.MkdirAll(svcDir, 0o755)) + require.NoError(t, os.MkdirAll(svcDir, 0o750)) require.NoError(t, os.WriteFile( filepath.Join(svcDir, "agent.yaml"), []byte("kind: prompt-voice\nname: concierge\n"), 0o600)) @@ -83,7 +83,7 @@ func TestKind_HostedIsNotVoice(t *testing.T) { t.Parallel() root := t.TempDir() svcDir := filepath.Join(root, "svc") - require.NoError(t, os.MkdirAll(svcDir, 0o755)) + require.NoError(t, os.MkdirAll(svcDir, 0o750)) require.NoError(t, os.WriteFile( filepath.Join(svcDir, "agent.yaml"), []byte("kind: hosted\nname: worker\n"), 0o600)) From 2bc2ce933a7eb7ababe47cf9a963ba196d7184a0 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:04:00 +0800 Subject: [PATCH 09/19] fix(agents): resolve project root in Endpoints for $ref/on-disk voice manifests Endpoints() ran ensureEnv but not ensureDeployContext, so p.projectPath was empty in a fresh CLI process. A prompt-voice service declared via a root $ref or an on-disk agent.yaml (legacy shape) then failed agentkind classification and fell through to the missing-VERSION error despite a successful deploy. Resolve the project root best-effort before kind detection, matching deploy/next-step. Inline-kind services are unaffected; hosted behavior is unchanged (added a regression test asserting the hosted missing-VERSION error still fires). --- .../internal/project/service_target_agent.go | 14 ++- .../project/service_target_agent_test.go | 115 ++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 57b4cac87fb..53b71ff80a0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -443,8 +443,20 @@ func (p *AgentServiceTargetProvider) Endpoints( // an error (or non-voice result) simply falls through to the hosted guard, so // hosted services keep their prior behavior on a path that never resolved // config before. + // Endpoints may run in a fresh CLI process (e.g. `azd show`) where + // ensureDeployContext has not populated p.projectPath. A voice manifest + // supplied via a root `$ref` or an on-disk agent.yaml can only be classified + // with the project root, so resolve it best-effort here; an inline `kind` + // does not need it. Failure to resolve leaves the root empty and simply falls + // through to the hosted guard below, so hosted behavior is unchanged. + projectRoot := p.projectPath + if projectRoot == "" { + if proj, perr := p.azdClient.Project().Get(ctx, nil); perr == nil { + projectRoot = proj.Project.Path + } + } if isVoice, err := agentkind.IsPromptVoice( - serviceConfig, p.projectPath, p.agentDefinitionPath, + serviceConfig, projectRoot, p.agentDefinitionPath, ); err == nil && isVoice && azdEnv[agentEndpointKey] != "" { return []string{azdEnv[agentEndpointKey]}, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 307571d6023..1f2bab6bb13 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -2243,3 +2243,118 @@ func TestValidatePythonBundledDeps_ErrorCodeCorrect(t *testing.T) { require.True(t, errors.As(err, &localErr)) require.Equal(t, exterrors.CodeBundledDepsNotFound, localErr.Code) } + +// endpointsTestEnvServer serves GetCurrent/GetValues for Endpoints() tests. +type endpointsTestEnvServer struct { + azdext.UnimplementedEnvironmentServiceServer + values map[string]string +} + +func (s *endpointsTestEnvServer) GetCurrent( + context.Context, *azdext.EmptyRequest, +) (*azdext.EnvironmentResponse, error) { + return &azdext.EnvironmentResponse{Environment: &azdext.Environment{Name: "test-env"}}, nil +} + +func (s *endpointsTestEnvServer) GetValues( + context.Context, *azdext.GetEnvironmentRequest, +) (*azdext.KeyValueListResponse, error) { + kvs := make([]*azdext.KeyValue, 0, len(s.values)) + for k, v := range s.values { + kvs = append(kvs, &azdext.KeyValue{Key: k, Value: v}) + } + return &azdext.KeyValueListResponse{KeyValues: kvs}, nil +} + +func newEndpointsTestClient( + t *testing.T, projectRoot string, envValues map[string]string, +) *azdext.AzdClient { + t.Helper() + + srv := grpc.NewServer() + azdext.RegisterProjectServiceServer(srv, &stubProjectServer{ + project: &azdext.ProjectConfig{Path: projectRoot}, + }) + azdext.RegisterEnvironmentServiceServer(srv, &endpointsTestEnvServer{values: envValues}) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(func() { + srv.Stop() + _ = lis.Close() + }) + + client, err := azdext.NewAzdClient(azdext.WithAddress(lis.Addr().String())) + require.NoError(t, err) + t.Cleanup(func() { client.Close() }) + return client +} + +// TestEndpoints_VoiceManifestOnDisk_ResolvesProjectRoot covers the fresh-process +// case where Endpoints runs without ensureDeployContext having populated +// p.projectPath. A legacy-shape prompt-voice service (kind only on disk, no +// inline kind) records NAME+ENDPOINT but no VERSION; Endpoints must resolve the +// project root itself so agentkind classifies it as voice and returns the base +// endpoint instead of the missing-VERSION error. +func TestEndpoints_VoiceManifestOnDisk_ResolvesProjectRoot(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + serviceDir := filepath.Join(projectRoot, "src", "voice") + require.NoError(t, os.MkdirAll(serviceDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(serviceDir, "agent.yaml"), + []byte("kind: prompt-voice\nname: my-voice\n"), + 0o600, + )) + + const endpoint = "https://proj.services.ai.azure.com/voice/my-voice" + client := newEndpointsTestClient(t, projectRoot, map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://proj.services.ai.azure.com", + "AGENT_VOICE_NAME": "my-voice", + "AGENT_VOICE_ENDPOINT": endpoint, + // deliberately no AGENT_VOICE_VERSION: voice agents have no version. + }) + + // Fresh process: projectPath/agentDefinitionPath are empty, exactly as they + // are before any ensureDeployContext call. + provider := &AgentServiceTargetProvider{azdClient: client} + + got, err := provider.Endpoints( + t.Context(), + &azdext.ServiceConfig{Name: "voice", RelativePath: "src/voice"}, + nil, + ) + require.NoError(t, err) + require.Equal(t, []string{endpoint}, got) +} + +// TestEndpoints_HostedMissingVersion_StillErrors guards that the project-root +// resolution added for voice does not change hosted behavior: a hosted service +// (no voice manifest) with a lingering ENDPOINT but no VERSION must still +// surface the actionable missing-env-vars error. +func TestEndpoints_HostedMissingVersion_StillErrors(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(projectRoot, "src", "hosted"), 0o750)) + + client := newEndpointsTestClient(t, projectRoot, map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://proj.services.ai.azure.com", + "AGENT_HOSTED_ENDPOINT": "https://proj.services.ai.azure.com/agents/hosted", + // no VERSION and no voice manifest -> must error, not fall through. + }) + + provider := &AgentServiceTargetProvider{azdClient: client} + + _, err := provider.Endpoints( + t.Context(), + &azdext.ServiceConfig{Name: "hosted", RelativePath: "src/hosted"}, + nil, + ) + require.Error(t, err) + var localErr *azdext.LocalError + require.True(t, errors.As(err, &localErr)) + require.Equal(t, exterrors.CodeMissingAgentEnvVars, localErr.Code) +} From 470ea64b20ad636dfd36ddbc8700957713c5db64 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:07:08 +0800 Subject: [PATCH 10/19] fix(agents): thread filterHostedRegions through configureFoundryProject configureFoundryProject hardcoded selectFoundryProject's filterHostedRegions to skipACR, so a prompt-voice manifest (skipACR=true, managed/no container) was restricted to hosted-agent regions even though voice agents are region-agnostic. The direct selectFoundryProject call sites already pass a.isHostedAgent() for this argument; thread the same value through configureFoundryProject and pass a.isHostedAgent() from the model-choice caller. The adopt path (container/code only, no voice) keeps its prior skipACR value, so existing behavior is unchanged. --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 1 + .../azure.ai.agents/internal/cmd/init_adopt.go | 1 + .../internal/cmd/init_foundry_project_setup.go | 9 +++++---- 3 files changed, 7 insertions(+), 4 deletions(-) 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 ba946efe3e6..33a27571422 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -2272,6 +2272,7 @@ func (a *InitAction) configureModelChoice( result, err := configureFoundryProject( ctx, a.azdClient, a.azureContext, a.environment.Name, a.flags.projectResourceId, a.flags.noPrompt, a.skipACR(), + a.isHostedAgent(), // filterHostedRegions: voice/managed agents are not region-restricted ) if err != nil { return nil, err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 858ba630703..45e3db0b417 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -909,6 +909,7 @@ func runInitFromAzureYaml( ctx, azdClient, azureContext, env.Name, flags.projectResourceId, flags.noPrompt, skipACR, + skipACR, // filterHostedRegions: adopt path is container/code only (no voice); preserve prior behavior ) if err != nil { if exterrors.IsCancellation(err) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go index 63fcfe68104..d3e87212db8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_foundry_project_setup.go @@ -40,6 +40,7 @@ func configureFoundryProject( projectResourceId string, noPrompt bool, skipACR bool, + filterHostedRegions bool, ) (*foundryProjectSetupResult, error) { result := &foundryProjectSetupResult{} @@ -71,8 +72,8 @@ func configureFoundryProject( ctx, azdClient, newCred, azureContext, envName, azureContext.Scope.SubscriptionId, projectResourceId, skipACR, - skipACR, // filterHostedRegions: this path is code/container only (non-voice) - true, // bicepless + filterHostedRegions, + true, // bicepless ) if err != nil { return nil, err @@ -145,8 +146,8 @@ func configureFoundryProject( ctx, azdClient, newCred, azureContext, envName, azureContext.Scope.SubscriptionId, "", skipACR, - skipACR, // filterHostedRegions: this path is code/container only (non-voice) - true, // bicepless + filterHostedRegions, + true, // bicepless ) if err != nil { return nil, err From cef22a2afdc13610d343d7697e49c9559c5edf9f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:27:09 +0800 Subject: [PATCH 11/19] fix(agents): honor AGENT_DEFINITION_PATH override in Endpoints Endpoints resolves the project root in a fresh process but still passed the empty cached p.agentDefinitionPath, so an explicit AGENT_DEFINITION_PATH voice override (which deploy honors via ensureDeployContext) was ignored during endpoint discovery, classifying the kind-less service entry and returning CodeMissingAgentEnvVars. Read the process override when the cached path is empty so classification matches deploy. Adds TestEndpoints_VoiceAgentDefinitionPathOverride. --- .../internal/project/service_target_agent.go | 17 ++++++--- .../project/service_target_agent_test.go | 37 ++++++++++++++++++- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 53b71ff80a0..fdb25cfeb21 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -444,19 +444,24 @@ func (p *AgentServiceTargetProvider) Endpoints( // hosted services keep their prior behavior on a path that never resolved // config before. // Endpoints may run in a fresh CLI process (e.g. `azd show`) where - // ensureDeployContext has not populated p.projectPath. A voice manifest - // supplied via a root `$ref` or an on-disk agent.yaml can only be classified - // with the project root, so resolve it best-effort here; an inline `kind` - // does not need it. Failure to resolve leaves the root empty and simply falls - // through to the hosted guard below, so hosted behavior is unchanged. + // ensureDeployContext has not populated p.projectPath or p.agentDefinitionPath. + // A voice manifest supplied via a root `$ref` or an on-disk agent.yaml can only + // be classified with the project root, and an explicit AGENT_DEFINITION_PATH + // override drives deploy, so honor both here to match the deploy classification. + // Both are resolved best-effort: any failure falls through to the hosted guard + // below, so hosted behavior is unchanged. projectRoot := p.projectPath if projectRoot == "" { if proj, perr := p.azdClient.Project().Get(ctx, nil); perr == nil { projectRoot = proj.Project.Path } } + agentDefinitionPath := p.agentDefinitionPath + if agentDefinitionPath == "" { + agentDefinitionPath = os.Getenv("AGENT_DEFINITION_PATH") + } if isVoice, err := agentkind.IsPromptVoice( - serviceConfig, projectRoot, p.agentDefinitionPath, + serviceConfig, projectRoot, agentDefinitionPath, ); err == nil && isVoice && azdEnv[agentEndpointKey] != "" { return []string{azdEnv[agentEndpointKey]}, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 1f2bab6bb13..02aa22b03f5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -2330,7 +2330,42 @@ func TestEndpoints_VoiceManifestOnDisk_ResolvesProjectRoot(t *testing.T) { require.Equal(t, []string{endpoint}, got) } -// TestEndpoints_HostedMissingVersion_StillErrors guards that the project-root +// TestEndpoints_VoiceAgentDefinitionPathOverride covers the fresh-process case +// where a voice manifest is supplied via the AGENT_DEFINITION_PATH override. +// Deploy follows the override and writes NAME+ENDPOINT but no VERSION; Endpoints +// runs without ensureDeployContext (so p.agentDefinitionPath is empty) and must +// read the process override to classify the service as voice, rather than +// classifying the (kind-less) service entry and returning missing-VERSION. +func TestEndpoints_VoiceAgentDefinitionPathOverride(t *testing.T) { + projectRoot := t.TempDir() + overridePath := filepath.Join(projectRoot, "custom-voice.yaml") + require.NoError(t, os.WriteFile( + overridePath, + []byte("kind: prompt-voice\nname: my-voice\n"), + 0o600, + )) + t.Setenv("AGENT_DEFINITION_PATH", overridePath) + + const endpoint = "https://proj.services.ai.azure.com/voice/my-voice" + client := newEndpointsTestClient(t, projectRoot, map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://proj.services.ai.azure.com", + "AGENT_VOICE_NAME": "my-voice", + "AGENT_VOICE_ENDPOINT": endpoint, + // no AGENT_VOICE_VERSION: voice agents have no version. + }) + + // Fresh process: the service entry carries no kind; only the override does. + provider := &AgentServiceTargetProvider{azdClient: client} + + got, err := provider.Endpoints( + t.Context(), + &azdext.ServiceConfig{Name: "voice", RelativePath: "src/voice"}, + nil, + ) + require.NoError(t, err) + require.Equal(t, []string{endpoint}, got) +} + // resolution added for voice does not change hosted behavior: a hosted service // (no voice manifest) with a lingering ENDPOINT but no VERSION must still // surface the actionable missing-env-vars error. From de4b8be8f87b950413b23a93c7ac9f18a0a9e22a Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:43:06 +0800 Subject: [PATCH 12/19] fix(agents): honor AGENT_DEFINITION_PATH in next-step, document voice --model next-step isVoiceService passed an empty override path, so a voice agent deployed via an explicit AGENT_DEFINITION_PATH override on a hosted/kind-less service entry was classified as not-voice and reported as undeployed. Read the process override so next-step classifies identically to deploy and Endpoints. Also extend the --model flag help to document the prompt-voice semantics (names the managed speech-to-speech model, defaults to gpt-realtime, deploys nothing). --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 4 +++- .../azure.ai.agents/internal/cmd/nextstep/state.go | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) 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 33a27571422..fff6b08f4f0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1705,8 +1705,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, fmt.Sprintf( "Name of the AI model to deploy. Defaults to '%s' during interactive model selection; "+ "required to deploy a new model with --no-prompt. If --model-deployment is also provided, "+ - "--model-deployment takes precedence", + "--model-deployment takes precedence. For --kind prompt-voice this instead names the "+ + "managed speech-to-speech model (no model is deployed) and defaults to '%s'", defaultAgentModel, + defaultVoiceModel, )) cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 337ed60e6d8..88a67a34086 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -522,10 +522,12 @@ func structHasKind(s *structpb.Struct) bool { // isVoiceService reports whether the service declares kind: prompt-voice. It // delegates to the shared agentkind lookup so the next-step reader classifies a -// service identically to the deploy path and Endpoints. Kind resolution is -// best-effort for next-step hints, so any error is treated as not-voice. +// service identically to the deploy path and Endpoints, including honoring an +// explicit AGENT_DEFINITION_PATH override (which deploy follows). Kind +// resolution is best-effort for next-step hints, so any error is treated as +// not-voice. func isVoiceService(projectPath string, svc *azdext.ServiceConfig) bool { - isVoice, err := agentkind.IsPromptVoice(svc, projectPath, "") + isVoice, err := agentkind.IsPromptVoice(svc, projectPath, os.Getenv("AGENT_DEFINITION_PATH")) return err == nil && isVoice } From cfe817a4f2e5407f66b307659359c568ce76a3e9 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:56:09 +0800 Subject: [PATCH 13/19] test(agents): add coverage for voice manifest, CreateVoiceAgent, Go 1.26 idioms Fills the test gaps flagged by the reviewer's suppressed suggestions: - init: TestSynthesizeVoiceManifestFile exercises the scaffolded prompt-voice manifest (default gpt-realtime, explicit model+voice, managed model_type, omitted voice) via the real agent_yaml parser. - agent_api: httptest coverage for CreateVoiceAgent asserting POST to the /voice_agents collection, api-version query, the VoiceAgents preview Foundry-Features header, x-ms-overridden-host routing, request body, and non-success error surfacing. - Modernize touched tests for Go 1.26: t.Context() over context.Background() and errors.AsType over errors.As. --- .../azure.ai.agents/internal/cmd/init_test.go | 49 ++++++++++++++ .../internal/cmd/nextstep/state_test.go | 2 +- .../pkg/agents/agent_api/operations_test.go | 64 +++++++++++++++++++ .../project/service_target_agent_test.go | 4 +- 4 files changed, 116 insertions(+), 3 deletions(-) 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 444a3bd39fa..3c3e621f97c 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 @@ -3831,3 +3831,52 @@ func TestRemoveContainerFiles(t *testing.T) { } }) } + +// TestSynthesizeVoiceManifestFile verifies the --kind prompt-voice scaffold path +// writes a valid managed voice manifest that round-trips through the real parser, +// covering the default model, the explicit model/voice overrides, and that no +// voice key is emitted when none is supplied. +func TestSynthesizeVoiceManifestFile(t *testing.T) { + t.Parallel() + + parse := func(t *testing.T, path string) agent_yaml.VoiceAgent { + t.Helper() + data, err := os.ReadFile(path) //nolint:gosec // path is produced by the function under test + require.NoError(t, err) + def, err := agent_yaml.ExtractAgentDefinition(data) + require.NoError(t, err) + va, ok := def.(agent_yaml.VoiceAgent) + require.True(t, ok, "expected VoiceAgent, got %T", def) + return va + } + + t.Run("defaults model when empty and omits voice", func(t *testing.T) { + t.Parallel() + path, cleanup, err := synthesizeVoiceManifestFile("my-voice", "", "") + require.NoError(t, err) + defer cleanup() + + va := parse(t, path) + require.Equal(t, agent_yaml.AgentKindPromptVoice, va.Kind) + require.Equal(t, agent_yaml.VoiceModelTypeManaged, va.ModelType) + require.NotNil(t, va.Model) + require.Equal(t, defaultVoiceModel, va.Model.Id) + require.Nil(t, va.Voice, "no voice key should be emitted when none is supplied") + }) + + t.Run("honors explicit model and voice", func(t *testing.T) { + t.Parallel() + path, cleanup, err := synthesizeVoiceManifestFile( + "my-voice", "gpt-realtime-preview", "en-US-Ava:DragonHDLatestNeural", + ) + require.NoError(t, err) + defer cleanup() + + va := parse(t, path) + require.Equal(t, agent_yaml.VoiceModelTypeManaged, va.ModelType) + require.NotNil(t, va.Model) + require.Equal(t, "gpt-realtime-preview", va.Model.Id) + require.NotNil(t, va.Voice) + require.Equal(t, "en-US-Ava:DragonHDLatestNeural", *va.Voice) + }) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index 5a2dd721d86..9837accc98a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -337,7 +337,7 @@ func TestIsDeployed_VoiceEndpointFallback(t *testing.T) { t.Parallel() src := &fakeSource{values: tc.values} var errs []error - got := isDeployed(context.Background(), src, "env1", "voice-svc", tc.isVoice, &errs) + got := isDeployed(t.Context(), src, "env1", "voice-svc", tc.isVoice, &errs) assert.Equal(t, tc.want, got) assert.Empty(t, errs) }) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index 8d111edaa94..6eb2effae43 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go @@ -918,3 +918,67 @@ func TestDownloadAgentCode_ReturnsErrorOnNon200(t *testing.T) { _, err := client.DownloadAgentCode(context.Background(), "no-such-agent", "v1", "") require.Error(t, err) } + +func TestCreateVoiceAgent_PostsToVoiceCollectionWithPreviewHeader(t *testing.T) { + body := `{"object":"agent","id":"va-1","name":"my-voice","versions":{"latest":{}}}` + client, transport := newCaptureClient(http.StatusOK, body) + + agent, err := client.CreateVoiceAgent( + t.Context(), + &CreateAgentRequest{Name: "my-voice"}, + AgentEndpointAPIVersion, + "", + ) + + require.NoError(t, err) + require.Equal(t, "my-voice", agent.Name) + + require.Len(t, transport.requests, 1) + req := transport.requests[0] + + require.Equal(t, http.MethodPost, req.Method) + require.Equal(t, "/api/projects/proj/voice_agents", req.URL.Path) + require.Equal(t, AgentEndpointAPIVersion, req.URL.Query().Get("api-version")) + require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features")) + // Default routing: no host override. + require.Empty(t, req.Header.Get("x-ms-overridden-host")) + + reqBody, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Contains(t, string(reqBody), `"name":"my-voice"`) +} + +func TestCreateVoiceAgent_SetsOverriddenHostHeader(t *testing.T) { + client, transport := newCaptureClient(http.StatusCreated, `{"name":"my-voice","versions":{"latest":{}}}`) + + _, err := client.CreateVoiceAgent( + t.Context(), + &CreateAgentRequest{Name: "my-voice"}, + AgentEndpointAPIVersion, + "regional.hyena.example.com", + ) + + require.NoError(t, err) + require.Len(t, transport.requests, 1) + require.Equal( + t, + "regional.hyena.example.com", + transport.requests[0].Header.Get("x-ms-overridden-host"), + ) +} + +func TestCreateVoiceAgent_ReturnsErrorOnNonSuccess(t *testing.T) { + client, _ := newCaptureClient( + http.StatusForbidden, + `{"error":{"code":"preview_feature_required","message":"voice agents preview"}}`, + ) + + _, err := client.CreateVoiceAgent( + t.Context(), + &CreateAgentRequest{Name: "my-voice"}, + AgentEndpointAPIVersion, + "", + ) + + require.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index 02aa22b03f5..bb296947792 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -2389,7 +2389,7 @@ func TestEndpoints_HostedMissingVersion_StillErrors(t *testing.T) { nil, ) require.Error(t, err) - var localErr *azdext.LocalError - require.True(t, errors.As(err, &localErr)) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) require.Equal(t, exterrors.CodeMissingAgentEnvVars, localErr.Code) } From 94fde924b597537d3556c5ea2d908feba3ca799e Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:11:06 +0800 Subject: [PATCH 14/19] fix(agents): require model for prompt-voice in service-target schema An inline `kind: prompt-voice` service could omit `model` and still pass schema validation, while the deploy path rejects the same config as missing `model.id`. Add a draft-07 if/then so schema/editor validation requires `model` when `kind` is `prompt-voice`, matching runtime. --- .../azure.ai.agents/schemas/azure.ai.agent.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 037ed498662..b8b769b5725 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 @@ -131,6 +131,20 @@ } }, "additionalProperties": true, + "allOf": [ + { + "$comment": "A prompt-voice agent must declare a speech-to-speech model; the deploy path rejects a voice service whose model.id is missing. Keep editor/schema validation aligned with that runtime requirement.", + "if": { + "properties": { + "kind": { "const": "prompt-voice" } + }, + "required": ["kind"] + }, + "then": { + "required": ["model"] + } + } + ], "definitions": { "ProtocolVersionRecord": { "type": "object", From 7f5667fe9c19c1e81cbd53c153c91549e366db9f Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:26:51 +0800 Subject: [PATCH 15/19] fix(agents): normalize OpenAI voice name casing on the wire isOpenAIVoice classifies names case-insensitively, but buildVoiceConfig emitted the original casing, so `--voice Shimmer` produced "Shimmer" while OpenAI wire IDs are lowercase. Trim and lowercase OpenAI voice names; Azure Neural names stay case-sensitive (whitespace-trimmed only). --- .../internal/pkg/agents/agent_yaml/map.go | 13 ++++++++++--- .../pkg/agents/agent_yaml/map_voice_test.go | 12 ++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index a9383190b9c..29c9e59f791 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -508,11 +508,18 @@ func isOpenAIVoice(name string) bool { } // buildVoiceConfig chooses the OpenAI vs Azure voice type by name shape. +// +// OpenAI realtime voice IDs are lowercase on the wire, and the classifier +// already matches them case-insensitively, so normalize to lowercase to keep +// e.g. "--voice Shimmer" from being emitted as "Shimmer". Azure Neural voice +// names are case-sensitive (e.g. "en-US-Ava:DragonHDLatestNeural"), so only the +// surrounding whitespace is trimmed for those. func buildVoiceConfig(name string) *agent_api.VoiceConfig { - if isOpenAIVoice(name) { - return &agent_api.VoiceConfig{Type: "openai", Name: name} + trimmed := strings.TrimSpace(name) + if isOpenAIVoice(trimmed) { + return &agent_api.VoiceConfig{Type: "openai", Name: strings.ToLower(trimmed)} } - return &agent_api.VoiceConfig{Type: "azure_standard", Name: name} + return &agent_api.VoiceConfig{Type: "azure_standard", Name: trimmed} } // CreateVoiceAgentAPIRequest builds a CreateAgentRequest for a declarative diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go index 20fbdf725a0..51340e7a577 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map_voice_test.go @@ -41,6 +41,18 @@ func TestBuildVoiceConfig_OpenAI(t *testing.T) { } } +func TestBuildVoiceConfig_OpenAINormalizesCasing(t *testing.T) { + t.Parallel() + // OpenAI wire IDs are lowercase; mixed-case/padded input must normalize. + cfg := buildVoiceConfig(" Shimmer ") + if cfg.Type != "openai" { + t.Errorf("Type = %q, want openai", cfg.Type) + } + if cfg.Name != "shimmer" { + t.Errorf("Name = %q, want shimmer", cfg.Name) + } +} + func TestBuildVoiceConfig_Azure(t *testing.T) { t.Parallel() cfg := buildVoiceConfig("en-US-Ava:DragonHDLatestNeural") From 4db597e4bbffcf80bb507bccc8778510e7645fc7 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:20:32 +0800 Subject: [PATCH 16/19] fix(agents): avoid double name prompt in interactive voice init The interactive 'Create a prompt voice agent' path resolved the agent name but did not pin flags.agentName, so the inner resolveInitAgentName call in runInitFromManifest prompted for the name a second time. Pin the resolved name after resolution, matching resolveAgentNameFromManifestPointer. --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 4 ++++ 1 file changed, 4 insertions(+) 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 fff6b08f4f0..e7888df676a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1644,6 +1644,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } return err } + // Pin the resolved name so the inner resolveInitAgentName call + // in runInitFromManifest short-circuits instead of prompting a + // second time. Mirrors resolveAgentNameFromManifestPointer. + flags.agentName = resolvedName manifestPath, cleanup, err := synthesizeVoiceManifestFile( resolvedName, flags.model, flags.voice, From bea4d9ccf0e6c37a14ad0a0e1b094188bfc734fd Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:44:03 +0800 Subject: [PATCH 17/19] fix(agents): reject --kind prompt-voice combined with --manifest Previously --kind prompt-voice was silently ignored when --manifest was also supplied, so a hosted manifest would create a hosted service despite the user explicitly selecting the voice kind. Reject the combination early, matching the existing --kind/--image validation. --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 8 ++++++++ 1 file changed, 8 insertions(+) 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 add2aa00fd5..22c4a0a911d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1286,6 +1286,14 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "a voice agent is managed and has no container image; drop --image", ) } + if flags.manifestPointer != "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--kind prompt-voice cannot be combined with --manifest", + "a voice agent is synthesized from --agent-name/--model/--voice; "+ + "drop --manifest, or omit --kind to adopt the manifest as-is", + ) + } } // Bring-your-own-image fast path: when --image is set without a manifest, From 7b30f47ec8e2aab3e65244e7435a14c47ff0cb19 Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:33:20 +0800 Subject: [PATCH 18/19] fix(agents): append prompt-voice agent to existing azd project When 'azd ai agent init' for a prompt-voice (managed) agent runs inside an existing azd project, add it as a new azure.ai.agent service to the current azure.yaml (src/ layout), matching hosted and other agents, instead of scaffolding a separate nested / project. Applies to both the interactive voice menu path and the '--kind prompt-voice' fast path. A brand-new (empty) init still creates the / project folder. --- .../azure.ai.agents/internal/cmd/init.go | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) 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 22c4a0a911d..13c548bcd80 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1507,15 +1507,23 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } - if !manifestInCwd { + if manifestInCwd { + if flags.src == "" { + flags.src = "." + } + } else if strings.EqualFold(flags.kind, kindFlagPromptVoice) && existingProject { + // A prompt-voice agent carries no source code, so inside an + // existing project it is appended to the current azure.yaml + // (targetDir stays ".") like other agents, rather than + // scaffolded into a nested / project. Matches the + // interactive voice branch. + } else { _, statErr := os.Stat(folderName) newlyCreated := errors.Is(statErr, fs.ErrNotExist) targetDir = folderName if newlyCreated && !existingProject { folderDisplay = filepath.ToSlash(folderName) } - } else if flags.src == "" { - flags.src = "." } } @@ -1666,15 +1674,24 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, defer cleanup() flags.manifestPointer = manifestPath - folderName := sanitizeAgentName(resolvedName) - _, statErr := os.Stat(folderName) - newlyCreated := errors.Is(statErr, fs.ErrNotExist) + // When run inside an existing azd project, append the voice + // agent as a new service to the current azure.yaml + // (targetDir="."), matching hosted and other agents, instead + // of scaffolding a nested / project. Only a brand-new + // (empty) init creates the / project folder. + targetDir := "." var folderDisplay string - if newlyCreated && !existingProject { - folderDisplay = filepath.ToSlash(folderName) + if !existingProject { + folderName := sanitizeAgentName(resolvedName) + _, statErr := os.Stat(folderName) + newlyCreated := errors.Is(statErr, fs.ErrNotExist) + targetDir = folderName + if newlyCreated { + folderDisplay = filepath.ToSlash(folderName) + } } if err := runInitFromManifest( - ctx, flags, azdClient, httpClient, folderName, folderDisplay, true, + ctx, flags, azdClient, httpClient, targetDir, folderDisplay, true, ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") From 03f8a6cfd847e5a19ea997411c9e7a63e6c5172a Mon Sep 17 00:00:00 2001 From: Jian Wu <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:41:05 +0800 Subject: [PATCH 19/19] fix(agents): trim prompt-voice --agent-name hint Since --kind prompt-voice combined with --manifest is now rejected, drop the '(or provide --manifest ...)' remediation that would walk the user into a dead end. Addresses PR review feedback. --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 13c548bcd80..368da189fa6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1336,8 +1336,8 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if flags.agentName == "" { return exterrors.Validation( exterrors.CodeInvalidParameter, - "--kind prompt-voice requires --agent-name when no --manifest is provided", - "pass --agent-name (or provide --manifest with the agent definition)", + "--kind prompt-voice requires --agent-name", + "pass --agent-name ", ) } manifestPath, cleanup, err := synthesizeVoiceManifestFile(