diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index 5aea6241cb7..51c3f68f535 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -238,6 +238,65 @@ Details: > the other inline agent properties such as `codeConfiguration` and > `environmentVariables`. +## Prompt voice telephony bindings + +Prompt voice agents can declare Foundry-side telephony bindings in `azure.yaml`. +This lets `azd deploy` bind an existing phone-provider route to the deployed +agent. Telephony is only supported for `kind: prompt-voice` services. + +```yaml +services: + support-voice: + host: azure.ai.agent + kind: prompt-voice + name: support-voice + model: + id: gpt-realtime + telephony: + bindings: + - provider: twilio + identifier: "+14255550123" + connection: telephony-twilio + - provider: acs + identifier: "28:orgid:00000000-0000-0000-0000-000000000001" + connection: telephony-acs +``` + +Prerequisites: + +- The phone provider account/resource and phone number already exist. +- The Foundry project connection named by `connection` already exists. +- Provider-side callbacks, such as Twilio webhooks or ACS Event Subscriptions, + are configured by the user/admin. + +Supported providers and identifiers: + +- `twilio`: use a Twilio phone number in E.164 format, such as `+14255550123`. +- `acs`: use `28:orgid:` for Teams Phone Extensibility Resource Accounts + or `4:+` for ACS-purchased numbers. azd maps `acs` to the service + provider value `azure-communication-service`. + +Bindings are create-only in this preview. If a remote binding exists and matches +the YAML, deploy continues. If the remote binding has different configuration, +azd fails with a remediation message instead of silently keeping stale routing. + +Cleanup: delete telephony bindings before deleting test agents. The service may +leave bindings behind when an agent is deleted, so do not rely on agent deletion +as binding cleanup. + +Delete a binding with the agent-scoped telephony API before deleting the agent: + +```bash +curl -X DELETE \ + -H "Authorization: Bearer $TOKEN" \ + -H "Foundry-Features: VoiceAgents=V1Preview" \ + "$PROJECT_ENDPOINT/agents/$AGENT_NAME/telephony/$BINDING_ID?api-version=2025-11-15-preview" +``` + +The binding ID is the service provider plus identifier, for example +`twilio:%2B14255550123` for `+14255550123`, or +`azure-communication-service:28:orgid:` for ACS/TPE. + ### Moderating invocations-protocol traffic For agents that expose the `invocations` protocol, the RAI policy alone is not @@ -304,6 +363,78 @@ keys throughout this block (`invocations_moderation`, `response_mode`, `input_paths`, `stream_selectors`, `event_type`, and so on). The **values** (`non_streaming`, `streaming`, `both`, `json`, `text`) are the same in both. +### Hosted voice wrapper (preview) + +A hosted voice wrapper keeps Voice Live responsible for VAD, speech-to-text, +and text-to-speech while routing conversation logic to a hosted agent in the +same Foundry project. Hosted Voice samples use the same sample `azure.yaml` +flow as other current Hosted Agent and `invocations_ws` samples: + +```powershell +azd ai agent init -m .\path\to\azure.yaml +``` + +The local path can be replaced with its public GitHub URL after the sample is +published. + +When the sample project is already present with its `azure.yaml`, run +`azd ai agent init` from the project directory to reuse the existing azd +configuration before provisioning and deployment. + +The sample `azure.yaml` contains both services and references the target by its +service name: + +```yaml +services: + ai-project: + host: azure.ai.project + + voice-target: + host: azure.ai.agent + project: ./src/voice-target + language: csharp + kind: hosted + name: voice-target + uses: + - ai-project + protocols: + - protocol: invocations_ws + version: 1.0.0 + metadata: + voiceLiveCompatible: "true" + bridgeProtocolVersion: "1.0" + container: + resources: + cpu: "1" + memory: 2Gi + codeConfiguration: + runtime: dotnet_10 + entryPoint: VoiceHostedAgent.dll + dependencyResolution: bundled + + voice-target-voice: + host: azure.ai.agent + kind: voice + name: voice-target-voice + uses: + - ai-project + - voice-target + modelType: hosted_agent + targetAgent: + service: voice-target + version: deployed + store: false +``` + +The `uses` edge deploys the target before the wrapper. `version: deployed` +pins the wrapper to the target version produced by the current azd environment. +Hosted voice wrappers use the unified Voice API. + +The target must be active, declare `invocations_ws/1.0.0`, and include +`voiceLiveCompatible=true` and `bridgeProtocolVersion=1.0` metadata. Model, +instructions, tools, and other conversation controls belong to the target; +the wrapper owns audio, voice, store, avatar, and greeting configuration. + ## Session idle timeout A hosted agent's runtime session sandbox is suspended by Foundry after a period diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 830f5522d73..6c3ac7af871 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -11,6 +11,7 @@ words: # Voice (prompt-voice) agents - BYOM - Nanami + - orgid - pcma - pcmu - webrtc diff --git a/cli/azd/extensions/azure.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml index f9df1a7cfaf..c26a678c523 100644 --- a/cli/azd/extensions/azure.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -2,7 +2,7 @@ id: azure.ai.agents namespace: ai.agent displayName: Foundry agents (Beta) -description: Ship agents with Microsoft Foundry from your terminal. (Beta) +description: Ship hosted and voice agents with Microsoft Foundry from your terminal. (Beta) usage: azd ai agent [options] # NOTE: Make sure version.txt is in sync with this version. version: 1.0.0-beta.14 @@ -29,5 +29,8 @@ providers: description: Deploys agents to the Foundry Agent Service examples: - name: init - description: Initialize a new AI agent project. + description: Initialize a new hosted or voice agent project. usage: azd ai agent init + - name: init-prompt-voice + description: Initialize a new prompt voice agent. + usage: azd ai agent init --kind prompt-voice --agent-name my-voice-agent diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index adae5fd2f6a..a0d3d26eb37 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -27,13 +27,13 @@ const agentEndpointHint = "run `azd ai agent show` to see the agent endpoint URL // // [1] project name (URL-escaped), // [2] agent name (URL-escaped), -// [3] protocol tail ("invocations", "a2a", or "openai/responses"). +// [3] protocol tail ("invocations", "a2a", "openai/responses", or "voice"). // // The "openai/v1/responses" tail is also accepted and rebuilt to the canonical // query-parameter form when invoked. var agentEndpointPathRegex = regexp.MustCompile( `^/api/projects/([^/]+)/agents/([^/]+)/endpoint/protocols/` + - `(invocations|a2a|openai/v1/responses|openai/responses)/?$`, + `(invocations|a2a|openai/v1/responses|openai/responses|voice)/?$`, ) // parsedAgentEndpoint describes a deployed agent invocation endpoint. @@ -52,6 +52,7 @@ type parsedAgentEndpoint struct { // // https://.services.ai.azure.com/api/projects//agents//endpoint/protocols/invocations[?api-version=…] // https://.services.ai.azure.com/api/projects//agents//endpoint/protocols/openai/responses?api-version=v1 +// wss://.services.ai.azure.com/api/projects//agents//endpoint/protocols/voice?api-version=v1 // // The host must be a `*.services.ai.azure.com` Foundry host. The path must include the // protocol-specific suffix; the protocol is derived from the URL. @@ -73,10 +74,10 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { ) } - if !strings.EqualFold(u.Scheme, "https") { + if !strings.EqualFold(u.Scheme, "https") && !strings.EqualFold(u.Scheme, "wss") { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, - "--agent-endpoint must use https", + "--agent-endpoint must use https or wss", agentEndpointHint, ) } @@ -139,6 +140,22 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { protocol = agent_api.AgentProtocolA2A case "openai/responses", "openai/v1/responses": protocol = agent_api.AgentProtocolResponses + case "voice": + protocol = agent_api.AgentProtocolVoice + } + if protocol == agent_api.AgentProtocolVoice && !strings.EqualFold(u.Scheme, "wss") { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint voice URLs must use wss", + agentEndpointHint, + ) + } + if protocol != agent_api.AgentProtocolVoice && !strings.EqualFold(u.Scheme, "https") { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint HTTP protocol URLs must use https", + agentEndpointHint, + ) } // Reject an explicit but empty api-version query parameter; the default fallback would diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go index 6779fa2407b..b6fcb473a09 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go @@ -74,6 +74,14 @@ func TestParseAgentEndpoint(t *testing.T) { wantAgent: "hello", wantProto: agent_api.AgentProtocolA2A, }, + { + name: "voice websocket with api-version", + raw: "wss://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/voice?api-version=v1", + wantProj: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgent: "hello", + wantProto: agent_api.AgentProtocolVoice, + wantAPIVer: "v1", + }, { name: "empty url", raw: "", @@ -84,7 +92,19 @@ func TestParseAgentEndpoint(t *testing.T) { name: "http scheme rejected", raw: "http://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations", wantErr: true, - errContains: "https", + errContains: "https or wss", + }, + { + name: "wss rejected for HTTP protocol endpoint", + raw: "wss://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/invocations", + wantErr: true, + errContains: "must use https", + }, + { + name: "https rejected for voice protocol endpoint", + raw: "https://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/voice", + wantErr: true, + errContains: "must use wss", }, { name: "non-foundry host rejected", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go index db7f1b6a239..7a4f984fc23 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go @@ -37,8 +37,8 @@ func newDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "delete [name]", - Short: "Delete a hosted agent.", - Long: `Delete a hosted agent and all of its versions. + Short: "Delete a hosted or voice agent.", + Long: `Delete a hosted or voice agent and all of its versions. If --version is specified, only that version is deleted (the agent itself remains). @@ -253,6 +253,8 @@ func (a *DeleteAction) cleanupEnvVars( fmt.Sprintf("AGENT_%s_NAME", serviceKey), fmt.Sprintf("AGENT_%s_VERSION", serviceKey), fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey), + fmt.Sprintf("AGENT_%s_TARGET_NAME", serviceKey), + fmt.Sprintf("AGENT_%s_TARGET_VERSION", serviceKey), envkey.AgentProjectEndpoint(serviceName), } for _, protocol := range project.DisplayableProtocolEnvSuffixes() { @@ -292,6 +294,8 @@ func (a *DeleteAction) clearDeletedVersionMarker( keys := []string{ versionKey, fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey), + fmt.Sprintf("AGENT_%s_TARGET_NAME", serviceKey), + fmt.Sprintf("AGENT_%s_TARGET_VERSION", serviceKey), } for _, protocol := range project.DisplayableProtocolEnvSuffixes() { keys = append(keys, fmt.Sprintf("AGENT_%s_%s_ENDPOINT", serviceKey, protocol.Suffix)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete_test.go index 67efb8a50c2..8702243ccdd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete_test.go @@ -33,6 +33,8 @@ func TestDeleteMarkerCleanup(t *testing.T) { "AGENT_MY_AGENT_NAME", "AGENT_MY_AGENT_VERSION", "AGENT_MY_AGENT_ENDPOINT", + "AGENT_MY_AGENT_TARGET_NAME", + "AGENT_MY_AGENT_TARGET_VERSION", "AGENT_MY_AGENT_PROJECT_ENDPOINT", "AGENT_MY_AGENT_RESPONSES_ENDPOINT", "AGENT_MY_AGENT_INVOCATIONS_ENDPOINT", @@ -82,6 +84,8 @@ func TestDeleteMarkerCleanup(t *testing.T) { for _, key := range []string{ "AGENT_MY_AGENT_VERSION", "AGENT_MY_AGENT_ENDPOINT", + "AGENT_MY_AGENT_TARGET_NAME", + "AGENT_MY_AGENT_TARGET_VERSION", "AGENT_MY_AGENT_RESPONSES_ENDPOINT", "AGENT_MY_AGENT_INVOCATIONS_ENDPOINT", "AGENT_MY_AGENT_INVOCATIONS_WS_ENDPOINT", diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go index 6d611f2c4ba..9a773b07b6c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go @@ -67,12 +67,13 @@ func newAgentDeployCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &agentDeployFlags{} cmd := &cobra.Command{ Use: "deploy [path]", - Short: "Deploy an agent directly from agent.yaml.", - Long: `Deploy an agent definition to the configured Foundry project. + Short: "Deploy a hosted or voice agent directly from agent.yaml.", + Long: `Deploy a hosted or voice agent definition to the configured Foundry project. The path defaults to ./agent.yaml. A hosted agent uploads source code from the definition directory unless --code specifies another path. If toolbox.yaml is -present next to agent.yaml, it is deployed first through the toolbox extension.`, +present next to agent.yaml, it is deployed first through the toolbox extension. +Prompt voice and hosted voice definitions deploy through the unified Voice API.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path := "agent.yaml" 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 b12d74b9189..1b7bf4790ea 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -86,9 +86,7 @@ type initFlags struct { // 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 hidden/private - // prompt-voice automation. Public interactive flows use the default and let - // users edit azure.yaml for customization. + // voice optionally overrides the output voice name for prompt-voice agents. voice string // force, when true, lets headless callers (--no-prompt) pre-consent to // overwrite prompts that would otherwise return a structured error. It @@ -380,7 +378,6 @@ func resolveAgentNameFromManifestPointer( flags.agentName = validated return validated, nil } - peeked := peekManifestName(ctx, manifestPointer, httpClient) if peeked == "" { // Defer to the inner flow which has access to the fully-loaded manifest. @@ -1211,6 +1208,8 @@ func agentDefiningFlagsSet(flags *initFlags, srcBlocksReuse bool) bool { flags.acrConnection != "" || flags.image != "" || flags.registryConnection != "" || + flags.kind != "" || + flags.voice != "" || srcBlocksReuse || len(flags.protocols) > 0 } @@ -1235,8 +1234,8 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "init [] [-m ] [--src ]", - Short: fmt.Sprintf("Initialize a new AI agent project. %s", color.YellowString("(Preview)")), - Long: `Initialize a new AI agent project. + Short: fmt.Sprintf("Initialize a new hosted or voice agent project. %s", color.YellowString("(Preview)")), + Long: `Initialize a new hosted or voice agent project. When -m points at a sample's unified azure.yaml (a project manifest that declares a service with host: azure.ai.agent), that azure.yaml is adopted as @@ -1244,6 +1243,12 @@ the project manifest and its referenced files are placed at the project root. When -m points at an agent manifest instead, the project's azure.yaml is generated from it. +Use --kind prompt-voice to initialize a managed prompt voice agent without +source code or container scaffolding. Prompt voice agents support model, audio, +voice, tool, greeting, avatar, handoff, and telephony settings in azure.yaml. +Hosted voice wrappers use kind: voice in azure.yaml and are typically initialized +from a sample or existing project manifest. + The agent name written to agent.yaml is the Foundry agent identity. Foundry agents are unique by name within a project, so deploying with an existing name creates a new version of that existing agent instead of a separate agent. @@ -1266,6 +1271,13 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, # Initialize from local agent code azd ai agent init --src ./src/my-agent --agent-name my-unique-agent + # Initialize a managed prompt voice agent + azd ai agent init --kind prompt-voice --agent-name support-voice + + # Initialize a prompt voice agent with an explicit realtime model and voice + azd ai agent init --kind prompt-voice --agent-name support-voice \ + --model gpt-realtime --voice en-US-Ava:DragonHDLatestNeural + # Non-interactive code deploy (CI/CD) azd ai agent init --no-prompt --project-id "" \ --deploy-mode code --runtime python_3_13 --entry-point app.py @@ -1386,21 +1398,14 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, fmt.Sprintf("the only supported --kind value is %q", kindFlagPromptVoice), ) } - if !promptVoicePreviewEnabled() { - return exterrors.Validation( - exterrors.CodeInvalidParameter, - "prompt voice agent init is private preview", - fmt.Sprintf("set %s=true to enable prompt voice init", promptVoicePreviewEnvVar), - ) - } - if flags.image != "" { + if strings.EqualFold(flags.kind, kindFlagPromptVoice) && 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.manifestPointer != "" { + if strings.EqualFold(flags.kind, kindFlagPromptVoice) && flags.manifestPointer != "" { return exterrors.Validation( exterrors.CodeInvalidParameter, "--kind prompt-voice cannot be combined with --manifest", @@ -1446,7 +1451,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // 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) && flags.manifestPointer == "" { if flags.agentName == "" { return exterrors.Validation( exterrors.CodeInvalidParameter, @@ -1994,14 +1999,11 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Requires a pre-built image and is incompatible with code deploy.") 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.") + "Agent kind to initialize. Supports 'prompt-voice' to create a managed declarative Voice Agent "+ + "without code scaffolding.") cmd.Flags().StringVar(&flags.voice, "voice", "", - "Output voice name for private prompt-voice automation. Hidden until public preview.") - _ = cmd.Flags().MarkHidden("kind") - _ = cmd.Flags().MarkHidden("voice") + "Output voice name for prompt-voice agents (for example, en-US-Ava:DragonHDLatestNeural).") cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite existing agent definitions or an input manifest inside the generated src tree without prompting. "+ @@ -2073,7 +2075,6 @@ func (a *InitAction) Run(ctx context.Context) error { if err != nil { return fmt.Errorf("downloading agent.yaml: %w", err) } - // Prompt for deploy mode (code vs container) for hosted agents. // Code deploy is supported for Python and .NET projects. if hostedAgent, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { @@ -2096,10 +2097,27 @@ func (a *InitAction) Run(ctx context.Context) error { if a.isCodeDeploy { // Prompt for code configuration and update the manifest - codeConfig, err := promptCodeConfig(ctx, a.azdClient, targetDir, a.flags.noPrompt, codeDeployOptions{ + codeOptions := codeDeployOptions{ runtime: a.flags.runtime, entryPoint: a.flags.entryPoint, depResolution: a.flags.depResolution, + } + if hostedAgent, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok && + hostedAgent.CodeConfiguration != nil { + if codeOptions.runtime == "" { + codeOptions.runtime = hostedAgent.CodeConfiguration.Runtime + } + if codeOptions.entryPoint == "" { + codeOptions.entryPoint = hostedAgent.CodeConfiguration.EntryPoint + } + if codeOptions.depResolution == "" && hostedAgent.CodeConfiguration.DependencyResolution != nil { + codeOptions.depResolution = *hostedAgent.CodeConfiguration.DependencyResolution + } + } + codeConfig, err := promptCodeConfig(ctx, a.azdClient, targetDir, a.flags.noPrompt, codeDeployOptions{ + runtime: codeOptions.runtime, + entryPoint: codeOptions.entryPoint, + depResolution: codeOptions.depResolution, }, a.userProvidedManifest) if err != nil { return fmt.Errorf("prompting for code configuration: %w", err) @@ -2554,7 +2572,7 @@ func (a *InitAction) configureModelChoice( // 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 + a.isVoiceAgent = agent_yaml.IsVoiceAgentKind(kind) } // When no --project-id flag was given, check whether the azd environment already @@ -3373,7 +3391,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa // 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 { + if agent_yaml.IsVoiceAgentKind(agentDef.Kind) { return a.addVoiceAgentToProject(ctx, targetDir, agentManifest) } @@ -4734,8 +4752,15 @@ func (a *InitAction) validateCodeDeployFlags() error { ); err != nil { return err } + noPrompt := a.flags.noPrompt + if a.flags.manifestPointer != "" { + // A standard manifest can provide runtime, entry point, and dependency + // resolution. Validate values now and enforce completeness after the + // manifest has been loaded and merged with explicit CLI overrides. + noPrompt = false + } return validateCodeDeployInput( - a.flags.noPrompt, a.flags.deployMode, a.flags.runtime, a.flags.entryPoint, a.flags.depResolution) + noPrompt, a.flags.deployMode, a.flags.runtime, a.flags.entryPoint, a.flags.depResolution) } // validateImageFlag checks that --image is valid when provided. 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 08d67e872fe..ff368bd99dd 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 @@ -20,6 +20,7 @@ import ( "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agentkind" "azureaiagent/internal/pkg/paths" "azureaiagent/internal/project" @@ -1619,6 +1620,10 @@ func applyDeployModeToAdoptedProjectWithSources( projectNeedsACR := false var configuredSourceContainers []string for _, agent := range agentServices { + kind := adoptedAgentKind(agent.svc, resp.GetProject().GetPath()) + if kind != "" && kind != "hosted" { + continue + } hadDockerConfig := adoptedServiceHasDocker(agent.svc) serviceNeedsACR, err := applyDeployModeToService( ctx, @@ -1639,6 +1644,14 @@ func applyDeployModeToAdoptedProjectWithSources( return projectNeedsACR, configuredSourceContainers, nil } +func adoptedAgentKind(svc *azdext.ServiceConfig, projectRoot string) string { + kind, err := agentkind.Kind(svc, projectRoot, "") + if err != nil { + return "" + } + return kind +} + func finalizeAdoptedSourceContainerNetwork( ctx context.Context, azdClient *azdext.AzdClient, 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 d3d56bce9cd..7317bfc130f 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 @@ -333,7 +333,6 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) } } - // Prompt user for supported protocols protocols, err := promptProtocols(ctx, a.azdClient.Prompt(), a.flags.noPrompt, a.flags.protocols) if err != nil { return nil, err @@ -830,6 +829,7 @@ func (a *InitFromCodeAction) addToProject( isCodeDeploy bool, ) error { agentName := definition.Name + agentServiceName := strings.ReplaceAll(agentName, " ", "") // If targetDir is ".", resolve the actual relative path from the project root to cwd. // This ensures azure.yaml gets the correct "project:" value when init is run from a subdirectory. if targetDir == "." { @@ -883,13 +883,11 @@ func (a *InitFromCodeAction) addToProject( strings.HasPrefix(definition.CodeConfiguration.Runtime, "dotnet_") { language = "csharp" } - serviceImage := "" if !isCodeDeploy { serviceImage = strings.TrimSpace(definition.Image) } - agentServiceName := strings.ReplaceAll(agentName, " ", "") serviceConfig := &azdext.ServiceConfig{ Name: agentServiceName, RelativePath: targetDir, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go index c2f32ff7943..447c7316ea7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_reuse.go @@ -156,7 +156,7 @@ func loadAgentDefinitionFile(path string) (*agent_yaml.ContainerAgent, error) { "fix the manifest schema and retry", ) } - if kind, _ := top["kind"].(string); kind == string(agent_yaml.AgentKindPromptVoice) { + if kind, _ := top["kind"].(string); agent_yaml.IsVoiceAgentKind(agent_yaml.AgentKind(kind)) { return nil, fmt.Errorf( "prompt-voice agent definitions cannot be reused through the current-code path; " + "run 'azd ai agent init' and choose 'Create a prompt voice agent', or use " + 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 305ce1ba9b5..ab06e730df5 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 @@ -26,13 +26,6 @@ import ( const agentTemplatesURL = "https://aka.ms/foundry-agents-samples" -const promptVoicePreviewEnvVar = "AZD_AI_AGENT_ENABLE_PROMPT_VOICE" - -func promptVoicePreviewEnabled() bool { - value := strings.TrimSpace(os.Getenv(promptVoicePreviewEnvVar)) - return strings.EqualFold(value, "1") || strings.EqualFold(value, "true") || strings.EqualFold(value, "yes") -} - // Template type constants const ( // TemplateTypeAgent is a template that points to an agent.yaml manifest file. @@ -110,8 +103,6 @@ const ( ) // voiceInitChoice is the interactive menu entry for creating a prompt voice agent. -// It is appended to the init-mode choices only when prompt voice private preview -// is explicitly enabled. var voiceInitChoice = &azdext.SelectChoice{ Label: "Create a prompt voice agent", Value: initModeVoice, @@ -120,8 +111,7 @@ var voiceInitChoice = &azdext.SelectChoice{ // 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). The voice option is private preview and only shown -// when promptVoicePreviewEnabled returns true. +// (there is no code to use). // 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). @@ -138,11 +128,6 @@ func promptInitMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt b } return initModeFromCode, nil } - voicePreviewEnabled := promptVoicePreviewEnabled() - if empty && !voicePreviewEnabled { - return initModeTemplate, nil - } - var choices []*azdext.SelectChoice if empty { // No local code to adopt; offer template + voice. @@ -155,9 +140,7 @@ func promptInitMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt b {Label: "Start new from a template", Value: initModeTemplate}, } } - if voicePreviewEnabled { - choices = append(choices, voiceInitChoice) - } + choices = append(choices, voiceInitChoice) defaultIndex := int32(0) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go index 117ec3e8f35..ce6c25ea2cd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go @@ -250,10 +250,9 @@ func TestPromptInitMode_NoPromptEmptyDirUsesTemplate(t *testing.T) { require.Equal(t, initModeTemplate, mode) } -func TestPromptInitMode_HidesVoiceChoiceByDefault(t *testing.T) { +func TestPromptInitMode_ShowsVoiceChoiceByDefault(t *testing.T) { dir := t.TempDir() t.Chdir(dir) - t.Setenv(promptVoicePreviewEnvVar, "") prompts := &helpersPromptServer{selectIndex: 0} azdClient := newHelpersTestAzdClient(t, &helpersProjectServer{}, prompts) @@ -262,13 +261,14 @@ func TestPromptInitMode_HidesVoiceChoiceByDefault(t *testing.T) { require.NoError(t, err) require.Equal(t, initModeTemplate, mode) - require.Nil(t, prompts.lastSelect) + require.NotNil(t, prompts.lastSelect) + require.Len(t, prompts.lastSelect.Options.Choices, 2) + require.Equal(t, "Create a prompt voice agent", prompts.lastSelect.Options.Choices[1].Label) } -func TestPromptInitMode_ShowsVoiceChoiceWhenPreviewEnabled(t *testing.T) { +func TestPromptInitMode_SelectsVoiceChoice(t *testing.T) { dir := t.TempDir() t.Chdir(dir) - t.Setenv(promptVoicePreviewEnvVar, "true") prompts := &helpersPromptServer{selectIndex: 1} azdClient := newHelpersTestAzdClient(t, &helpersProjectServer{}, prompts) 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 e1315cf7cdb..8717cc59e73 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 @@ -66,6 +66,20 @@ func TestInitCommand_AcrConnectionFlag(t *testing.T) { require.Empty(t, flag.DefValue) } +func TestInitCommand_VoiceFlagsArePublic(t *testing.T) { + cmd := newInitCommand(nil) + + kindFlag := cmd.Flags().Lookup("kind") + require.NotNil(t, kindFlag) + require.False(t, kindFlag.Hidden) + require.Contains(t, kindFlag.Usage, "prompt-voice") + + voiceFlag := cmd.Flags().Lookup("voice") + require.NotNil(t, voiceFlag) + require.False(t, voiceFlag.Hidden) + require.Contains(t, voiceFlag.Usage, "prompt-voice") +} + // TestHasFoundryProviderDeclared covers the predicate ensureProject // uses to suppress the "missing infra/" warning. func TestHasFoundryProviderDeclared(t *testing.T) { @@ -3409,6 +3423,13 @@ func TestCodeDeployFlagValidation(t *testing.T) { flags: initFlags{noPrompt: false, deployMode: "code"}, wantErr: false, }, + { + name: "no-prompt manifest can provide code configuration", + flags: initFlags{ + noPrompt: true, deployMode: "code", manifestPointer: "agent.manifest.yaml", + }, + wantErr: false, + }, { name: "invalid deploy-mode value fails", flags: initFlags{noPrompt: true, deployMode: "invalid"}, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 48c55259f7c..2684adca828 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -80,14 +80,18 @@ func newInvokeCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "invoke [name] [message]", - Short: "Send a message to your agent.", - Long: `Send a message to your agent. + Short: "Send a message to your hosted agent.", + Long: `Send a message to your hosted agent. By default the agent is invoked remotely on Foundry. When a single argument is provided it is treated as the message and the agent name is auto-detected from azure.yaml. With two arguments the first is the agent name and the second is the message. +For voice agents, use the voice WebSocket endpoint shown by 'azd show' or +'azd ai agent show' with a Voice Live client. Text invoke is for HTTP-based +hosted agent protocols such as responses, invocations, and a2a. + Use --input-file/-f to send the contents of a file as the request body instead of a positional message argument. This is useful for structured or large payloads with the invocations protocol, or for sending a complete @@ -237,6 +241,13 @@ be combined with --timeout.`, if flags.protocol != "" { p := agent_api.AgentProtocol(flags.protocol) + if p == agent_api.AgentProtocolVoice { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "voice endpoints require a Voice Live WebSocket client and cannot be invoked with this command", + "use the voice WebSocket endpoint shown by 'azd show' or 'azd ai agent show' with a Voice Live client", + ) + } if !p.IsInvocable() { return exterrors.Validation( exterrors.CodeInvalidParameter, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go index 2142407af31..60bfc9a8f65 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go @@ -803,6 +803,12 @@ func TestProtocolFlagValidation(t *testing.T) { wantErr: true, errSub: "--client-header is not supported with the a2a protocol", }, + { + name: "rejects voice protocol", + args: []string{"--protocol", "voice", "hello"}, + wantErr: true, + errSub: "Voice Live WebSocket client", + }, } for _, tt := range tests { @@ -884,6 +890,16 @@ func TestAgentEndpointFlagValidation(t *testing.T) { wantErr: true, errSub: "Foundry host", }, + { + name: "rejects voice websocket endpoint", + args: []string{ + "--agent-endpoint", + "wss://acct.services.ai.azure.com/api/projects/proj/agents/hello/endpoint/protocols/voice?api-version=v1", + "hi", + }, + wantErr: true, + errSub: "Voice Live WebSocket client", + }, } for _, tt := range tests { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index b9cae680f2f..e94b55307a4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -24,6 +24,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/output" + "google.golang.org/protobuf/proto" ) // configureExtensionHost wires the service target and event handlers on the @@ -503,11 +504,13 @@ func resolveAgentServiceConfigWithProjectOverrides( svc *azdext.ServiceConfig, projectRoot string, ) (*azdext.ServiceConfig, error) { - resolvedSvc := *svc - if err := project.ResolveServiceConfigInPlace(&resolvedSvc, projectRoot); err != nil { + // Resolve project-relative fields on an isolated protobuf copy so listen + // does not mutate the shared project service configuration. + resolvedSvc := proto.Clone(svc).(*azdext.ServiceConfig) + if err := project.ResolveServiceConfigInPlace(resolvedSvc, projectRoot); err != nil { return nil, err } - return &resolvedSvc, nil + return resolvedSvc, nil } func warnLegacySimpleTeamsArtifacts(proj *azdext.ProjectConfig, svc *azdext.ServiceConfig) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index 0dd0d2b23f7..6e34fc76453 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -15,7 +15,7 @@ func NewRootCommand() *cobra.Command { rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ Name: "agent", Use: "agent [options]", - Short: fmt.Sprintf("Ship agents with Microsoft Foundry from your terminal. %s", color.YellowString("(Preview)")), + Short: fmt.Sprintf("Ship hosted and voice agents with Microsoft Foundry from your terminal. %s", color.YellowString("(Preview)")), }) rootCmd.SilenceUsage = true rootCmd.SilenceErrors = true diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root_test.go new file mode 100644 index 00000000000..d819169c395 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root_test.go @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "slices" + "testing" +) + +func TestRootCommand_PublicPreviewCommandsVisible(t *testing.T) { + cmd := NewRootCommand() + + var visible []string + for _, sub := range cmd.Commands() { + if !sub.Hidden { + visible = append(visible, sub.Name()) + } + } + + for _, name := range []string{ + "add", + "code", + "delete", + "deploy", + "doctor", + "endpoint", + "eval", + "files", + "init", + "invoke", + "monitor", + "optimize", + "pack", + "publish", + "run", + "sample", + "sessions", + "show", + } { + if !slices.Contains(visible, name) { + t.Fatalf("expected visible root subcommand %q in %v", name, visible) + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index 06dcdef254d..fd3689fee76 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -46,8 +46,8 @@ func newShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "show [name]", - Short: "Show the status of a hosted agent.", - Long: `Show the status of a hosted agent. + Short: "Show the status of a hosted or voice agent.", + Long: `Show the status of a hosted or voice agent. The agent name and version are resolved automatically from the azure.yaml service configuration and the current azd environment. Optionally specify the service name diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index ccf4f8054ee..92e6f6cdfa8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -27,6 +27,7 @@ const ( CodeInvalidAgentRequest = "invalid_agent_request" CodeInvalidAgentName = "invalid_agent_name" CodeInvalidAgentVersion = "invalid_agent_version" + CodeTelephonyBindingDrift = "telephony_binding_drift" CodeInvalidSessionId = "invalid_session_id" CodeInvalidParameter = "invalid_parameter" CodeUnsupportedHost = "unsupported_host" @@ -194,29 +195,31 @@ const ( // Operation names for [ServiceFromAzure] errors. // These are prefixed to the Azure error code (e.g., "create_agent.NotFound"). const ( - OpGetFoundryProject = "get_foundry_project" - OpContainerBuild = "container_build" - OpContainerPackage = "container_package" - OpContainerPublish = "container_publish" - OpCreateAgent = "create_agent" - OpGetAgent = "get_agent" - OpUpdateAgent = "update_agent" - OpGetActivityBot = "get_activity_bot" - OpEnsureActivityBot = "ensure_activity_bot" - OpEnsureTeamsChannel = "ensure_teams_channel" - OpDeleteAgent = "delete_agent" - OpStartContainer = "start_container" - OpGetContainerOperation = "get_container_operation" - OpCreateSession = "create_session" - OpGetSession = "get_session" - OpDeleteSession = "delete_session" - OpStopSession = "stop_session" - OpListSessions = "list_sessions" - OpCreateToolboxVersion = "create_toolbox_version" - OpGetToolbox = "get_toolbox" - OpProvisionMemoryStore = "provision_memory_store" - OpPackTeamsApp = "pack_teams_app" - OpPublishTeamsApp = "publish_teams_app" + OpGetFoundryProject = "get_foundry_project" + OpContainerBuild = "container_build" + OpContainerPackage = "container_package" + OpContainerPublish = "container_publish" + OpCreateAgent = "create_agent" + OpCreateTelephonyBinding = "create_telephony_binding" + OpGetAgent = "get_agent" + OpGetTelephonyBinding = "get_telephony_binding" + OpUpdateAgent = "update_agent" + OpGetActivityBot = "get_activity_bot" + OpEnsureActivityBot = "ensure_activity_bot" + OpEnsureTeamsChannel = "ensure_teams_channel" + OpDeleteAgent = "delete_agent" + OpStartContainer = "start_container" + OpGetContainerOperation = "get_container_operation" + OpCreateSession = "create_session" + OpGetSession = "get_session" + OpDeleteSession = "delete_session" + OpStopSession = "stop_session" + OpListSessions = "list_sessions" + OpCreateToolboxVersion = "create_toolbox_version" + OpGetToolbox = "get_toolbox" + OpProvisionMemoryStore = "provision_memory_store" + OpPackTeamsApp = "pack_teams_app" + OpPublishTeamsApp = "publish_teams_app" ) const OpReadBackgroundResponseState = "read_background_response_state" 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 2dc851a94e6..132dee0bfe1 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 @@ -25,6 +25,7 @@ const ( AgentProtocolInvocationsWS AgentProtocol = "invocations_ws" AgentProtocolResponses AgentProtocol = "responses" AgentProtocolA2A AgentProtocol = "a2a" + AgentProtocolVoice AgentProtocol = "voice" ) // IsActivityProtocolName reports whether the given definition-level protocol name @@ -363,8 +364,17 @@ type VoiceModelType string const ( VoiceModelTypeManaged VoiceModelType = "managed" VoiceModelTypeSelfDeployed VoiceModelType = "self_deployed" + VoiceModelTypeHostedAgent VoiceModelType = "hosted_agent" ) +// VoiceTargetAgentReference is the data-plane target_agent object on a managed +// voice wrapper. Name identifies the hosted agent that receives Voice Bridge +// turns; Version optionally pins the wrapper to one immutable hosted version. +type VoiceTargetAgentReference struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + // VoiceAudioFormat describes a PCM audio stream format (e.g. audio/pcm @ 24 kHz). type VoiceAudioFormat struct { Type string `json:"type"` @@ -446,21 +456,42 @@ type VoiceAudioConfig struct { // prompt 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"` - StructuredInputs map[string]any `json:"structured_inputs,omitempty"` - Audio *VoiceAudioConfig `json:"audio,omitempty"` - OutputModalities []string `json:"output_modalities,omitempty"` - Store *bool `json:"store,omitempty"` - Tools []map[string]any `json:"tools,omitempty"` - Avatar map[string]any `json:"avatar,omitempty"` - Greeting map[string]any `json:"greeting,omitempty"` - Handoff map[string]any `json:"handoff,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` - MaxOutputTokens any `json:"max_output_tokens,omitempty"` - Include []string `json:"include,omitempty"` + ModelType VoiceModelType `json:"model_type"` + Model string `json:"model,omitempty"` + TargetAgent *VoiceTargetAgentReference `json:"target_agent,omitempty"` + Instructions string `json:"instructions,omitempty"` + StructuredInputs map[string]any `json:"structured_inputs,omitempty"` + Audio *VoiceAudioConfig `json:"audio,omitempty"` + OutputModalities []string `json:"output_modalities,omitempty"` + Store *bool `json:"store,omitempty"` + Tools []map[string]any `json:"tools,omitempty"` + Avatar map[string]any `json:"avatar,omitempty"` + Greeting map[string]any `json:"greeting,omitempty"` + Handoff map[string]any `json:"handoff,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + MaxOutputTokens any `json:"max_output_tokens,omitempty"` + Include []string `json:"include,omitempty"` +} + +// TelephonyBindingRequest creates an agent telephony binding. +type TelephonyBindingRequest struct { + Provider string `json:"provider"` + Identifier string `json:"identifier"` + ConnectionName string `json:"connection_name,omitempty"` + TransferTargets []map[string]any `json:"transfer_targets,omitempty"` +} + +// TelephonyBinding describes a Foundry-side phone number binding. +type TelephonyBinding struct { + ID string `json:"id,omitempty"` + Provider string `json:"provider,omitempty"` + Identifier string `json:"identifier,omitempty"` + Status string `json:"status,omitempty"` + Connection string `json:"connection,omitempty"` + ConnectionName string `json:"connection_name,omitempty"` + TransferTargets []map[string]any `json:"transfer_targets,omitempty"` + ETag string `json:"etag,omitempty"` } // CreateAgentVersionRequest represents a request to create an agent version 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 3b4ae765e10..23cf53977d2 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 @@ -14,6 +14,7 @@ import ( "net/textproto" "net/url" "strconv" + "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -196,6 +197,9 @@ func (c *AgentClient) CreateAgent(ctx context.Context, request *CreateAgentReque // header while voice agents remain a preview capability. const voiceAgentsPreviewFeature = "VoiceAgents=V1Preview" +// TelephonyBindingAPIVersion is the preview API version for voice telephony bindings. +const TelephonyBindingAPIVersion = "2025-11-15-preview" + func (c *AgentClient) doVoiceJSONAgentRequest( ctx context.Context, method string, @@ -313,6 +317,96 @@ func (c *AgentClient) UpdateVoiceAgent( return c.doVoiceJSONAgentRequest(ctx, http.MethodPost, url, request, overriddenHost) } +// GetTelephonyBinding retrieves one telephony binding for an agent. +func (c *AgentClient) GetTelephonyBinding( + ctx context.Context, + agentName string, + bindingID string, + apiVersion string, + overriddenHost string, +) (*TelephonyBinding, error) { + url := fmt.Sprintf( + "%s/agents/%s/telephony/%s?api-version=%s", + c.endpoint, + url.PathEscape(agentName), + escapeTelephonyPathSegment(bindingID), + apiVersion, + ) + req, err := runtime.NewRequest(ctx, http.MethodGet, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", voiceAgentsPreviewFeature) + if overriddenHost != "" { + req.Raw().Header.Set("x-ms-overridden-host", overriddenHost) + } + + 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) { + 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 binding TelephonyBinding + if err := json.Unmarshal(body, &binding); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &binding, nil +} + +func escapeTelephonyPathSegment(value string) string { + return strings.ReplaceAll(url.PathEscape(value), "+", "%2B") +} + +// CreateTelephonyBinding creates a telephony binding for an agent. +func (c *AgentClient) CreateTelephonyBinding( + ctx context.Context, + agentName string, + request *TelephonyBindingRequest, + apiVersion string, + overriddenHost string, +) (*TelephonyBinding, error) { + url := fmt.Sprintf("%s/agents/%s/telephony?api-version=%s", c.endpoint, url.PathEscape(agentName), 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) + } + 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 binding TelephonyBinding + if err := json.Unmarshal(body, &binding); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &binding, 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_api/operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations_test.go index 829f7b538fd..aebfb45140e 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 @@ -1096,3 +1096,52 @@ func TestUpdateVoiceAgent_PostsToNamedAgentWithPreviewHeader(t *testing.T) { require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features")) require.Equal(t, "regional.hyena.example.com", req.Header.Get("x-ms-overridden-host")) } + +func TestGetTelephonyBinding_GetsAgentScopedBinding(t *testing.T) { + body := `{"id":"twilio:%2B14255550123","provider":"twilio","identifier":"+14255550123"}` + client, transport := newCaptureClient(http.StatusOK, body) + + binding, err := client.GetTelephonyBinding( + t.Context(), "my-voice", "twilio:+14255550123", TelephonyBindingAPIVersion, "regional.hyena.example.com", + ) + + require.NoError(t, err) + require.Equal(t, "twilio", binding.Provider) + require.Len(t, transport.requests, 1) + req := transport.requests[0] + require.Equal(t, http.MethodGet, req.Method) + require.Equal(t, "/api/projects/proj/agents/my-voice/telephony/twilio:%2B14255550123", req.URL.EscapedPath()) + require.Equal(t, TelephonyBindingAPIVersion, req.URL.Query().Get("api-version")) + require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features")) + require.Equal(t, "regional.hyena.example.com", req.Header.Get("x-ms-overridden-host")) +} + +func TestCreateTelephonyBinding_PostsAgentScopedBinding(t *testing.T) { + body := `{"id":"twilio:+14255550123","provider":"twilio","identifier":"+14255550123"}` + client, transport := newCaptureClient(http.StatusCreated, body) + + _, err := client.CreateTelephonyBinding( + t.Context(), + "my-voice", + &TelephonyBindingRequest{ + Provider: "twilio", + Identifier: "+14255550123", + ConnectionName: "telephony-twilio", + }, + TelephonyBindingAPIVersion, + "regional.hyena.example.com", + ) + + require.NoError(t, err) + require.Len(t, transport.requests, 1) + req := transport.requests[0] + require.Equal(t, http.MethodPost, req.Method) + require.Equal(t, "/api/projects/proj/agents/my-voice/telephony", req.URL.Path) + require.Equal(t, TelephonyBindingAPIVersion, req.URL.Query().Get("api-version")) + require.Equal(t, voiceAgentsPreviewFeature, req.Header.Get("Foundry-Features")) + require.Equal(t, "regional.hyena.example.com", req.Header.Get("x-ms-overridden-host")) + reqBody, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Contains(t, string(reqBody), `"connection_name":"telephony-twilio"`) + require.NotContains(t, string(reqBody), `"agent_ref"`) +} 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 d4fcde06e40..682324ebab4 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 @@ -164,11 +164,11 @@ func CreateAgentAPIRequestFromDefinition(agentTemplate any, options ...AgentBuil case AgentKindHosted: hostedDef := agentTemplate.(ContainerAgent) return CreateHostedAgentAPIRequest(hostedDef, buildConfig) - case AgentKindPromptVoice: + case AgentKindPromptVoice, AgentKindVoice: voiceDef := agentTemplate.(VoiceAgent) return CreateVoiceAgentAPIRequest(voiceDef) default: - return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: hosted, prompt-voice", agentDef.Kind) + return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: hosted, prompt-voice, voice", agentDef.Kind) } } @@ -734,33 +734,62 @@ func mapVoiceStructuredInputs(inputs map[string]any) map[string]any { // 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) { - return createVoiceAgentAPIRequest(voiceAgent) + return createVoiceAgentAPIRequest(voiceAgent, nil) } -func createVoiceAgentAPIRequest(voiceAgent VoiceAgent) (*agent_api.CreateAgentRequest, error) { - modelID := "" - if voiceAgent.Model != nil { - modelID = strings.TrimSpace(voiceAgent.Model.Id) - } - if modelID == "" { - return nil, fmt.Errorf("model.id is required for a prompt-voice agent") - } +// CreateHostedVoiceAgentAPIRequest builds a hosted-agent voice wrapper using +// the deployed target resolved by the project layer. +func CreateHostedVoiceAgentAPIRequest( + voiceAgent VoiceAgent, + target agent_api.VoiceTargetAgentReference, +) (*agent_api.CreateAgentRequest, error) { + return createVoiceAgentAPIRequest(voiceAgent, &target) +} +func createVoiceAgentAPIRequest( + voiceAgent VoiceAgent, + target *agent_api.VoiceTargetAgentReference, +) (*agent_api.CreateAgentRequest, error) { modelType := agent_api.VoiceModelTypeManaged if voiceAgent.ModelType != "" { modelType = agent_api.VoiceModelType(voiceAgent.ModelType) } - if modelType != agent_api.VoiceModelTypeManaged && modelType != agent_api.VoiceModelTypeSelfDeployed { + hostedAgent := modelType == agent_api.VoiceModelTypeHostedAgent + if hostedAgent { + if target == nil || strings.TrimSpace(target.Name) == "" || strings.TrimSpace(target.Version) == "" { + return nil, fmt.Errorf("resolved target agent name and version are required when model_type is 'hosted_agent'") + } + if voiceAgent.Model != nil || voiceAgent.InputSchema != nil || voiceAgent.OutputSchema != nil || + voiceAgent.Instructions != nil || len(voiceAgent.StructuredInputs) > 0 || + len(voiceAgent.Tools) > 0 || voiceAgent.ToolChoice != nil || voiceAgent.ParallelToolCalls != nil || + voiceAgent.MaxOutputTokens != nil || len(voiceAgent.Include) > 0 || len(voiceAgent.Handoff) > 0 { + return nil, fmt.Errorf( + "model, input_schema, output_schema, instructions, structured_inputs, tools, tool_choice, " + + "parallel_tool_calls, max_output_tokens, include, and handoff belong to the target hosted agent", + ) + } + } else if modelType != agent_api.VoiceModelTypeManaged && modelType != agent_api.VoiceModelTypeSelfDeployed { return nil, fmt.Errorf( - "model_type '%s' is not supported; use '%s' or '%s'", - voiceAgent.ModelType, VoiceModelTypeManaged, VoiceModelTypeSelfDeployed) + "model_type '%s' is not supported; use '%s', '%s', or '%s'", + voiceAgent.ModelType, VoiceModelTypeManaged, VoiceModelTypeSelfDeployed, VoiceModelTypeHostedAgent) } if errors := validateVoiceAgentAdvancedConfig(voiceAgent); len(errors) > 0 { return nil, fmt.Errorf("invalid prompt-voice configuration: %s", strings.Join(errors, "; ")) } - instructions := defaultVoiceInstructions - if voiceAgent.Instructions != nil && *voiceAgent.Instructions != "" { + modelID := "" + if voiceAgent.Model != nil { + modelID = strings.TrimSpace(voiceAgent.Model.Id) + } + if !hostedAgent && modelID == "" { + return nil, fmt.Errorf("model.id is required for a prompt-voice agent") + } + + instructions := "" + if !hostedAgent { + instructions = defaultVoiceInstructions + } + if !hostedAgent && voiceAgent.Instructions != nil && *voiceAgent.Instructions != "" { instructions = *voiceAgent.Instructions } @@ -815,6 +844,7 @@ func createVoiceAgentAPIRequest(voiceAgent VoiceAgent) (*agent_api.CreateAgentRe }, ModelType: modelType, Model: modelID, + TargetAgent: target, Instructions: instructions, StructuredInputs: mapVoiceStructuredInputs(voiceAgent.StructuredInputs), Audio: &agent_api.VoiceAudioConfig{ 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 74655ed946e..315b662c924 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 @@ -608,3 +608,67 @@ func TestCreateVoiceAgentAPIRequest_InvalidModelType(t *testing.T) { t.Error("expected error for unsupported model_type") } } + +func TestCreateHostedVoiceAgentAPIRequest(t *testing.T) { + t.Parallel() + store := false + agent := VoiceAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "voice-wrapper"}, + ModelType: VoiceModelTypeHostedAgent, + TargetAgent: &VoiceTargetAgent{Service: "voice-target", Version: "deployed"}, + Store: &store, + } + req, err := CreateHostedVoiceAgentAPIRequest(agent, agent_api.VoiceTargetAgentReference{ + Name: "deployed-target", + Version: "7", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + def := req.Definition.(agent_api.VoiceAgentDefinition) + if def.ModelType != agent_api.VoiceModelTypeHostedAgent { + t.Fatalf("ModelType = %q, want hosted_agent", def.ModelType) + } + if def.TargetAgent == nil || def.TargetAgent.Name != "deployed-target" || def.TargetAgent.Version != "7" { + t.Fatalf("TargetAgent = %+v", def.TargetAgent) + } + if def.Model != "" || def.Instructions != "" || len(def.Tools) != 0 { + t.Fatalf("hosted wrapper contains target-owned fields: %+v", def) + } + data, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + var wire map[string]any + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatal(err) + } + definitionValue, exists := wire["definition"] + if !exists { + t.Fatalf("hosted wrapper wire payload is missing definition: %s", data) + } + definition, ok := definitionValue.(map[string]any) + if !ok { + t.Fatalf("hosted wrapper definition has type %T, want object: %s", definitionValue, data) + } + if _, exists := definition["model"]; exists { + t.Fatalf("hosted wrapper wire payload contains model: %s", data) + } + if _, exists := definition["instructions"]; exists { + t.Fatalf("hosted wrapper wire payload contains instructions: %s", data) + } + if _, exists := definition["tools"]; exists { + t.Fatalf("hosted wrapper wire payload contains tools: %s", data) + } +} + +func TestCreateHostedVoiceAgentAPIRequestRequiresResolvedTarget(t *testing.T) { + t.Parallel() + agent := VoiceAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPromptVoice, Name: "voice-wrapper"}, + ModelType: VoiceModelTypeHostedAgent, + } + if _, err := CreateHostedVoiceAgentAPIRequest(agent, agent_api.VoiceTargetAgentReference{}); err == nil { + t.Fatal("expected missing resolved target error") + } +} 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 27da98f8b2c..300532a50c9 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 @@ -16,6 +16,10 @@ import ( ) var validAgentNamePattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) +var e164Pattern = regexp.MustCompile(`^\+[1-9][0-9]{6,14}$`) +var acsTpeRawIDPattern = regexp.MustCompile( + `^28:orgid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-` + + `[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) // LoadAndValidateAgentManifest parses YAML content and validates it as an AgentManifest // Returns the parsed manifest and any validation errors @@ -119,7 +123,7 @@ func ExtractAgentDefinition(manifestYamlContent []byte) (any, error) { agent.AgentDefinition = agentDef return agent, nil - case AgentKindPromptVoice: + case AgentKindPromptVoice, AgentKindVoice: var agent VoiceAgent if err := yaml.Unmarshal(templateBytes, &agent); err != nil { return nil, fmt.Errorf("failed to unmarshal to VoiceAgent: %w", err) @@ -390,12 +394,29 @@ func ValidateAgentDefinition(templateBytes []byte) error { errors = append(errors, fmt.Sprintf("template.name not in valid format: %v", err)) } + var fields map[string]yaml.Node + if fieldErr := yaml.Unmarshal(templateBytes, &fields); fieldErr == nil { + if modelType, ok := fields["model_type"]; ok && + modelType.Kind == yaml.ScalarNode && modelType.Value == string(VoiceModelTypeHostedAgent) && + !IsVoiceAgentKind(agentDef.Kind) { + errors = append(errors, + "template.model_type 'hosted_agent' is only valid for voice agents") + } + if _, ok := fields["target_agent"]; ok && !IsVoiceAgentKind(agentDef.Kind) { + errors = append(errors, + "template.target_agent is only valid for voice agents") + } + } + // Only hosted agents carry policies to the service, so a moderation block on any // other kind would be dropped silently instead of enforced. if agentDef.Kind != AgentKindHosted { errors = append(errors, validateInvocationsModerationKind(templateBytes, agentDef.Kind)...) } + if agentDef.Kind != AgentKindPromptVoice && rawTemplateHasKey(templateBytes, "telephony") { + errors = append(errors, "template.telephony is only supported for prompt-voice agents") + } switch AgentKind(agentDef.Kind) { case AgentKindHosted: @@ -448,7 +469,7 @@ func ValidateAgentDefinition(templateBytes []byte) error { } else { errors = append(errors, fmt.Sprintf("failed to unmarshal to Workflow: %v", err)) } - case AgentKindPromptVoice: + case AgentKindPromptVoice, AgentKindVoice: var agent VoiceAgent if err := yaml.Unmarshal(templateBytes, &agent); err == nil { var fields map[string]yaml.Node @@ -458,16 +479,64 @@ func ValidateAgentDefinition(templateBytes []byte) error { "template.toolbox is not supported for a prompt-voice agent; "+ "remove it from the agent definition") } + if _, hasProtocols := fields["protocols"]; hasProtocols { + errors = append(errors, + "template.protocols is not supported for a prompt-voice agent; "+ + "configure protocols on the hosted target") + } + } + var policyEnvelope struct { + Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + } + if err := yaml.Unmarshal(templateBytes, &policyEnvelope); err != nil { + errors = append(errors, fmt.Sprintf("template.policies is not valid: %v", err)) + } else if len(policyEnvelope.Policies) > 0 { + hasModeration := false + for _, policy := range policyEnvelope.Policies { + hasModeration = hasModeration || policy.InvocationsModeration != nil + } + if !hasModeration { + errors = append(errors, + "template.policies is not supported for prompt-voice agents; "+ + "move target-owned policies to the hosted target") + } } - if agent.Model == nil || strings.TrimSpace(agent.Model.Id) == "" { - errors = append(errors, "template.model.id is required for a prompt-voice agent") + if agent.ModelType == VoiceModelTypeHostedAgent { + if agent.TargetAgent == nil || + strings.TrimSpace(agent.TargetAgent.Service) == "" { + errors = append(errors, + "template.target_agent.service is required when model_type is 'hosted_agent'") + } + if agent.TargetAgent != nil && agent.TargetAgent.Version != "" && + agent.TargetAgent.Version != "deployed" { + errors = append(errors, "template.target_agent.version must be 'deployed' when specified") + } + if agent.Model != nil { + errors = append(errors, "template.model is not allowed when model_type is 'hosted_agent'") + } + if agent.InputSchema != nil || agent.OutputSchema != nil || agent.Instructions != nil || + len(agent.StructuredInputs) > 0 || len(agent.Tools) > 0 || + agent.ToolChoice != nil || agent.ParallelToolCalls != nil || + agent.MaxOutputTokens != nil || + len(agent.Include) > 0 || len(agent.Handoff) > 0 { + errors = append(errors, + "input_schema, output_schema, instructions, structured_inputs, tools, tool_choice, "+ + "parallel_tool_calls, max_output_tokens, include, and handoff belong to the "+ + "target hosted agent") + } + } else { + if agent.Model == nil || strings.TrimSpace(agent.Model.Id) == "" { + errors = append(errors, "template.model.id is required for a prompt-voice agent") + } + if agent.TargetAgent != nil { + errors = append(errors, "template.target_agent is only valid when model_type is 'hosted_agent'") + } } - if agent.ModelType != "" && - agent.ModelType != VoiceModelTypeManaged && - agent.ModelType != VoiceModelTypeSelfDeployed { + if agent.ModelType != "" && agent.ModelType != VoiceModelTypeManaged && + agent.ModelType != VoiceModelTypeSelfDeployed && agent.ModelType != VoiceModelTypeHostedAgent { errors = append(errors, fmt.Sprintf( - "template.model_type '%s' is not supported; use '%s' or '%s'", - agent.ModelType, VoiceModelTypeManaged, VoiceModelTypeSelfDeployed)) + "template.model_type '%s' is not supported; use '%s', '%s', or '%s'", + agent.ModelType, VoiceModelTypeManaged, VoiceModelTypeSelfDeployed, VoiceModelTypeHostedAgent)) } errors = append(errors, validateVoiceAgentAdvancedConfig(agent)...) } else { @@ -507,6 +576,7 @@ func validateVoiceAgentAdvancedConfig(agent VoiceAgent) []string { if err := validateVoiceMaxOutputTokens(agent.MaxOutputTokens); err != nil { errors = append(errors, err.Error()) } + errors = append(errors, validateVoiceTelephony(agent.Telephony)...) if agent.Audio == nil { return append(errors, validateVoiceIncludeTranscriptionCompatibility(agent, "")...) @@ -559,6 +629,60 @@ func isFinite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) } +func validateVoiceTelephony(telephony *VoiceTelephony) []string { + if telephony == nil { + return nil + } + var errors []string + if len(telephony.Bindings) == 0 { + errors = append(errors, "template.telephony.bindings must not be empty") + } + for i, binding := range telephony.Bindings { + path := fmt.Sprintf("template.telephony.bindings[%d]", i) + provider := strings.TrimSpace(binding.Provider) + identifier := strings.TrimSpace(binding.Identifier) + if provider == "" { + errors = append(errors, path+".provider is required") + } + if identifier == "" { + errors = append(errors, path+".identifier is required") + } + if strings.TrimSpace(binding.Connection) == "" { + errors = append(errors, path+".connection is required") + } + switch provider { + case "acs": + if identifier == "" { + continue + } + if after, ok := strings.CutPrefix(identifier, "4:"); ok { + if !e164Pattern.MatchString(after) { + errors = append(errors, path+".identifier must be 4:+ for acs-purchased numbers") + } + } else if !acsTpeRawIDPattern.MatchString(identifier) { + errors = append(errors, path+".identifier must be 28:orgid: or 4:+ for acs") + } + case "twilio": + if identifier != "" && !e164Pattern.MatchString(identifier) { + errors = append(errors, path+".identifier must be + for twilio") + } + case "": + default: + errors = append(errors, path+".provider must be acs or twilio") + } + } + return errors +} + +func rawTemplateHasKey(templateBytes []byte, key string) bool { + var raw map[string]any + if err := yaml.Unmarshal(templateBytes, &raw); err != nil { + return false + } + _, ok := raw[key] + return ok +} + func validateVoiceMaxOutputTokens(value any) error { if value == nil { return nil 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 index c74810ec327..91ef3da4799 100644 --- 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 @@ -329,3 +329,230 @@ include: t.Fatalf("expected azure-speech include config to be valid, got: %v", err) } } + +func TestValidateAgentDefinition_HostedVoiceAccepted(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice-wrapper +model_type: hosted_agent +target_agent: + service: voice-target + version: deployed +`) + if err := ValidateAgentDefinition(yamlContent); err != nil { + t.Fatalf("expected hosted voice definition to be valid, got: %v", err) + } +} + +func TestValidateAgentDefinition_HostedVoiceAcceptedWithVoiceKind(t *testing.T) { + yamlContent := []byte(` +kind: voice +name: voice-wrapper +model_type: hosted_agent +target_agent: + service: voice-target + version: deployed +`) + if err := ValidateAgentDefinition(yamlContent); err != nil { + t.Fatalf("expected hosted voice definition to be valid, got: %v", err) + } +} + +func TestValidateAgentDefinition_HostedAgentRejectsHostedVoiceModelType(t *testing.T) { + yamlContent := []byte(` +kind: hosted +name: hosted-target +model_type: hosted_agent +target_agent: + service: target +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "model_type 'hosted_agent' is only valid") || + !strings.Contains(err.Error(), "target_agent is only valid") { + t.Fatalf("expected hosted voice fields on hosted kind to fail, got: %v", err) + } +} + +func TestValidateAgentDefinition_HostedVoiceRequiresTarget(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice-wrapper +model_type: hosted_agent +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "target_agent.service is required") { + t.Fatalf("expected target agent validation error, got: %v", err) + } +} + +func TestValidateAgentDefinition_HostedVoiceRejectsTargetOwnedFields(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice-wrapper +model_type: hosted_agent +target_agent: + service: voice-target +model: + id: gpt-realtime +instructions: not allowed +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "belong to the target hosted agent") || + !strings.Contains(err.Error(), "model is not allowed") { + t.Fatalf("expected target-owned field validation errors, got: %v", err) + } +} + +func TestValidateAgentDefinition_HostedVoiceRejectsSchemas(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice-wrapper +model_type: hosted_agent +target_agent: + service: voice-target +inputSchema: + properties: [] +outputSchema: + properties: [] +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "input_schema, output_schema") { + t.Fatalf("expected target-owned schema validation error, got: %v", err) + } +} + +func TestValidateAgentDefinition_PromptVoiceRejectsProtocols(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice +model: + id: gpt-realtime +protocols: + - protocol: invocations_ws + version: 1.0.0 +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "protocols is not supported") { + t.Fatalf("expected prompt voice protocols validation error, got: %v", err) + } +} + +func TestValidateAgentDefinition_PromptVoiceRejectsPolicies(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice +model: + id: gpt-realtime +policies: + - type: rai_policy + rai_policy_name: policy +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "policies is not supported") { + t.Fatalf("expected prompt voice policy validation error, got: %v", err) + } +} + +func TestValidateAgentDefinition_PromptVoiceRejectsMalformedPolicies(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice +model: + id: gpt-realtime +policies: invalid +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "template.policies is not valid") { + t.Fatalf("expected malformed policy validation error, got: %v", err) + } +} + +func TestValidateAgentDefinition_PromptVoice_TelephonyBindings(t *testing.T) { + yamlContent := []byte(` +kind: prompt-voice +name: voice-agent +model: + id: gpt-realtime +telephony: + bindings: + - provider: acs + identifier: 28:orgid:00000000-0000-0000-0000-000000000001 + connection: telephony-acs + - provider: acs + identifier: 4:+14255550123 + connection: telephony-acs + - provider: twilio + identifier: +14255550124 + connection: telephony-twilio +`) + if err := ValidateAgentDefinition(yamlContent); err != nil { + t.Fatalf("expected telephony bindings to be valid, got: %v", err) + } +} + +func TestValidateAgentDefinition_PromptVoice_InvalidTelephonyBindings(t *testing.T) { + tests := []struct { + name string + yaml string + want string + }{ + { + name: "empty bindings", + yaml: "telephony:\n bindings: []", + want: "bindings must not be empty", + }, + { + name: "bad provider", + yaml: `telephony: + bindings: + - provider: sip + identifier: +14255550123 + connection: c`, + want: "provider must be acs or twilio", + }, + { + name: "bad twilio id", + yaml: `telephony: + bindings: + - provider: twilio + identifier: not-a-number + connection: c`, + want: "identifier must be +", + }, + { + name: "missing connection", + yaml: `telephony: + bindings: + - provider: acs + identifier: 4:+14255550123`, + want: "connection is required", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + yamlContent := []byte("kind: prompt-voice\nname: voice-agent\nmodel:\n id: gpt-realtime\n" + tt.yaml + "\n") + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected %q validation error, got: %v", tt.want, err) + } + }) + } +} + +func TestValidateAgentDefinition_RejectsTelephonyForNonPromptVoice(t *testing.T) { + yamlContent := []byte(` +kind: hosted +name: hosted-agent +language: python +image: example.azurecr.io/agent:latest +telephony: + bindings: + - provider: twilio + identifier: +14255550123 + connection: telephony-twilio +`) + err := ValidateAgentDefinition(yamlContent) + if err == nil || !strings.Contains(err.Error(), "telephony is only supported for prompt-voice") { + t.Fatalf("expected telephony kind validation error, got: %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 113c4c5c5ea..7ecc095f949 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 @@ -22,6 +22,9 @@ const ( // voice when building the create request. Reserving "prompt-voice" keeps a // clean boundary against a future hosted (code) voice agent. AgentKindPromptVoice AgentKind = "prompt-voice" + // AgentKindVoice is the preferred authoring kind for managed Voice agents. + // AgentKindPromptVoice remains accepted for backwards compatibility. + AgentKindVoice AgentKind = "voice" ) // VoiceModelType selects the model-inference mode for a voice agent. @@ -30,8 +33,17 @@ type VoiceModelType string const ( VoiceModelTypeManaged VoiceModelType = "managed" VoiceModelTypeSelfDeployed VoiceModelType = "self_deployed" + VoiceModelTypeHostedAgent VoiceModelType = "hosted_agent" ) +// VoiceTargetAgent identifies the hosted agent service that supplies the +// conversation logic for a hosted voice wrapper. Service is an azure.yaml +// service name; azd resolves it to the deployed Foundry agent name and version. +type VoiceTargetAgent struct { + Service string `json:"service" yaml:"service"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + // IsValidAgentKind checks if the provided AgentKind is valid func IsValidAgentKind(kind AgentKind) bool { return slices.Contains(ValidAgentKinds(), kind) @@ -43,9 +55,15 @@ func ValidAgentKinds() []AgentKind { AgentKindHosted, AgentKindWorkflow, AgentKindPromptVoice, + AgentKindVoice, } } +// IsVoiceAgentKind reports whether kind is a managed Voice agent authoring kind. +func IsVoiceAgentKind(kind AgentKind) bool { + return kind == AgentKindPromptVoice || kind == AgentKindVoice +} + type ResourceKind string const ( @@ -208,6 +226,8 @@ type VoiceAgent struct { // 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"` + // TargetAgent references the hosted agent service used when model_type is hosted_agent. + TargetAgent *VoiceTargetAgent `json:"targetAgent,omitempty" yaml:"target_agent,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 @@ -239,6 +259,21 @@ type VoiceAgent struct { MaxOutputTokens any `json:"maxOutputTokens,omitempty" yaml:"max_output_tokens,omitempty"` // Include requests additional service response fields. Include []string `json:"include,omitempty" yaml:"include,omitempty"` + // Telephony configures phone-number bindings for prompt voice agents. + Telephony *VoiceTelephony `json:"telephony,omitempty" yaml:"telephony,omitempty"` +} + +// VoiceTelephony declares telephony bindings for a prompt voice agent. +type VoiceTelephony struct { + Bindings []VoiceTelephonyBinding `json:"bindings,omitempty" yaml:"bindings,omitempty"` +} + +// VoiceTelephonyBinding maps a provider-side phone identifier to the voice agent. +type VoiceTelephonyBinding struct { + Provider string `json:"provider" yaml:"provider"` + Identifier string `json:"identifier" yaml:"identifier"` + Connection string `json:"connection" yaml:"connection"` + TransferTargets []map[string]any `json:"transferTargets,omitempty" yaml:"transfer_targets,omitempty"` } // VoiceAudio bundles optional prompt voice input/output audio overrides. 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 index f15d8334dcf..9ebbb3fca53 100644 --- 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 @@ -53,7 +53,7 @@ func IsPromptVoice(svc *azdext.ServiceConfig, projectRoot, overridePath string) if err != nil { return false, err } - return kind == string(agent_yaml.AgentKindPromptVoice), nil + return agent_yaml.IsVoiceAgentKind(agent_yaml.AgentKind(kind)), nil } // IsHosted reports whether the service resolves to kind: hosted. 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 3022611b0ca..6d2d577ad13 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 @@ -140,24 +140,26 @@ type AgentDefinitionInline struct { Policies []agent_yaml.Policy `json:"policies,omitempty"` SessionConfiguration *agent_yaml.SessionConfiguration `json:"sessionConfiguration,omitempty"` - // Voice-agent fields (kind: prompt-voice). All omitempty so container/ + // Voice-agent fields (kind: prompt-voice or 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"` - StructuredInputs map[string]any `json:"structuredInputs,omitempty"` - Audio *agent_yaml.VoiceAudio `json:"audio,omitempty"` - OutputModalities []string `json:"outputModalities,omitempty"` - Store *bool `json:"store,omitempty"` - Tools []map[string]any `json:"tools,omitempty"` - Avatar map[string]any `json:"avatar,omitempty"` - Greeting map[string]any `json:"greeting,omitempty"` - Handoff map[string]any `json:"handoff,omitempty"` - ToolChoice any `json:"toolChoice,omitempty"` - ParallelToolCalls *bool `json:"parallelToolCalls,omitempty"` - MaxOutputTokens any `json:"maxOutputTokens,omitempty"` - Include []string `json:"include,omitempty"` + ModelType agent_yaml.VoiceModelType `json:"modelType,omitempty"` + Model *agent_yaml.Model `json:"model,omitempty"` + TargetAgent *agent_yaml.VoiceTargetAgent `json:"targetAgent,omitempty"` + Instructions *string `json:"instructions,omitempty"` + Voice *string `json:"voice,omitempty"` + StructuredInputs map[string]any `json:"structuredInputs,omitempty"` + Audio *agent_yaml.VoiceAudio `json:"audio,omitempty"` + OutputModalities []string `json:"outputModalities,omitempty"` + Store *bool `json:"store,omitempty"` + Tools []map[string]any `json:"tools,omitempty"` + Avatar map[string]any `json:"avatar,omitempty"` + Greeting map[string]any `json:"greeting,omitempty"` + Handoff map[string]any `json:"handoff,omitempty"` + ToolChoice any `json:"toolChoice,omitempty"` + ParallelToolCalls *bool `json:"parallelToolCalls,omitempty"` + MaxOutputTokens any `json:"maxOutputTokens,omitempty"` + Include []string `json:"include,omitempty"` + Telephony *agent_yaml.VoiceTelephony `json:"telephony,omitempty"` } // voiceAgentDefinitionToInline projects a VoiceAgent into the inline definition @@ -167,6 +169,7 @@ func voiceAgentDefinitionToInline(va agent_yaml.VoiceAgent) AgentDefinitionInlin AgentDefinition: va.AgentDefinition, ModelType: va.ModelType, Model: va.Model, + TargetAgent: va.TargetAgent, Instructions: va.Instructions, Voice: va.Voice, StructuredInputs: va.StructuredInputs, @@ -181,6 +184,7 @@ func voiceAgentDefinitionToInline(va agent_yaml.VoiceAgent) AgentDefinitionInlin ParallelToolCalls: va.ParallelToolCalls, MaxOutputTokens: va.MaxOutputTokens, Include: va.Include, + Telephony: va.Telephony, } } @@ -190,6 +194,7 @@ func (d AgentDefinitionInline) toVoiceAgent() agent_yaml.VoiceAgent { AgentDefinition: d.AgentDefinition, ModelType: d.ModelType, Model: d.Model, + TargetAgent: d.TargetAgent, Instructions: d.Instructions, Voice: d.Voice, StructuredInputs: d.StructuredInputs, @@ -204,6 +209,7 @@ func (d AgentDefinitionInline) toVoiceAgent() agent_yaml.VoiceAgent { ParallelToolCalls: d.ParallelToolCalls, MaxOutputTokens: d.MaxOutputTokens, Include: d.Include, + Telephony: d.Telephony, } } @@ -798,7 +804,42 @@ func agentDefinitionFromStruct( } if inline.Kind != agent_yaml.AgentKindHosted { - if err := validateAgentServiceDefinition(s.AsMap()); err != nil { + definition := any(s.AsMap()) + if agent_yaml.IsVoiceAgentKind(inline.Kind) { + if len(inline.Protocols) > 0 { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "protocols are not supported on prompt voice agents", + "configure protocols on the hosted target", + ) + } + if inline.Toolbox != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "toolbox is not supported on prompt voice agents", + "remove toolbox from the prompt voice agent definition", + ) + } + if len(inline.Policies) > 0 { + for _, policy := range inline.Policies { + if policy.InvocationsModeration != nil { + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "invocationsModeration is only supported for 'hosted' agents", + "remove invocationsModeration from the prompt voice agent or move it to a hosted target", + ) + } + } + return agent_yaml.ContainerAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "policies are not supported on prompt voice agents", + "configure content policy fields supported by the Voice API, or move target-owned policy "+ + "configuration to the hosted target", + ) + } + definition = inline.toVoiceAgent() + } + if err := validateAgentServiceDefinition(definition); err != nil { return agent_yaml.ContainerAgent{}, false, err } return agent_yaml.ContainerAgent{}, false, nil @@ -962,7 +1003,8 @@ func voiceAgentFromDefinitionFile(path string) (agent_yaml.VoiceAgent, bool, err ) } - if kind, _ := genericTemplate["kind"].(string); kind != string(agent_yaml.AgentKindPromptVoice) { + if kind, _ := genericTemplate["kind"].(string); kind != string(agent_yaml.AgentKindPromptVoice) && + kind != string(agent_yaml.AgentKindVoice) { // Not a voice definition; let the container path handle the override. return agent_yaml.VoiceAgent{}, false, nil } @@ -1104,8 +1146,8 @@ func VoiceAgentFromResolvedService( if !structHasKind(resolved) { continue } - if resolved.GetFields()["kind"].GetStringValue() != - string(agent_yaml.AgentKindPromptVoice) { + kind := agent_yaml.AgentKind(resolved.GetFields()["kind"].GetStringValue()) + if !agent_yaml.IsVoiceAgentKind(kind) { // A definition is present but it is not a voice agent. return agent_yaml.VoiceAgent{}, false, nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go index 0fb7423f327..be9fc35d9be 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go @@ -374,7 +374,7 @@ func TestAgentPoliciesInvocationsModerationNonHostedInline(t *testing.T) { t.Run(kind, func(t *testing.T) { t.Parallel() - _, _, _, _, err := AgentDefinitionFromService(inlineAgentService(t, map[string]any{ + properties := map[string]any{ "kind": kind, "name": "rai-agent", "policies": []any{ @@ -388,7 +388,11 @@ func TestAgentPoliciesInvocationsModerationNonHostedInline(t *testing.T) { }, }, }, - })) + } + if kind == "prompt-voice" { + properties["model"] = map[string]any{"id": "gpt-realtime"} + } + _, _, _, _, err := AgentDefinitionFromService(inlineAgentService(t, properties)) require.ErrorContains(t, err, "invocationsModeration is only supported for 'hosted' agents") }) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go index 66753ec9c9b..0cf50863602 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go @@ -12,6 +12,7 @@ import ( "strings" "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/envkey" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -19,6 +20,13 @@ import ( type dependencyEnabled func(context.Context, string) (bool, error) +type hostedVoiceTarget struct { + ServiceName string + AgentName string + AgentVersion string + ProjectEndpoint string +} + const ( foundryProjectHost = "azure.ai.project" foundryConnectionHost = "azure.ai.connection" @@ -407,6 +415,67 @@ func validateFoundryAgentDependency(service *azdext.ServiceConfig, env map[strin return "" } +func resolveHostedVoiceTarget( + wrapper *azdext.ServiceConfig, + voiceAgentTarget *agent_yaml.VoiceTargetAgent, + services map[string]*azdext.ServiceConfig, + env map[string]string, + projectRoot string, +) (*hostedVoiceTarget, error) { + if voiceAgentTarget == nil || strings.TrimSpace(voiceAgentTarget.Service) == "" { + return nil, fmt.Errorf("targetAgent.service is required when modelType is hosted_agent") + } + targetServiceName := strings.TrimSpace(voiceAgentTarget.Service) + if !slices.Contains(wrapper.GetUses(), targetServiceName) { + return nil, fmt.Errorf( + "hosted voice target service %q must be declared in the %q service uses list", + targetServiceName, wrapper.GetName()) + } + targetService, ok := services[targetServiceName] + if !ok { + return nil, fmt.Errorf("hosted voice target service %q was not found in azure.yaml", targetServiceName) + } + if targetService.GetHost() != foundryAgentHost { + return nil, fmt.Errorf( + "hosted voice target service %q must use host %q, got %q", + targetServiceName, foundryAgentHost, targetService.GetHost()) + } + _, isHosted, _, err := LoadAgentDefinition(targetService, projectRoot) + if err != nil { + return nil, fmt.Errorf("loading hosted voice target service %q: %w", targetServiceName, err) + } + if !isHosted { + return nil, fmt.Errorf("hosted voice target service %q must have kind hosted", targetServiceName) + } + + key := normalizeAgentServiceKey(targetServiceName) + name := strings.TrimSpace(env[fmt.Sprintf("AGENT_%s_NAME", key)]) + version := strings.TrimSpace(env[fmt.Sprintf("AGENT_%s_VERSION", key)]) + projectEndpoint := strings.TrimSpace(env[envkey.AgentProjectEndpoint(targetServiceName)]) + baseEndpoint := strings.TrimSpace(env[fmt.Sprintf("AGENT_%s_ENDPOINT", key)]) + if projectEndpoint == "" && endpointBelongsToProject(baseEndpoint, env["FOUNDRY_PROJECT_ENDPOINT"]) { + projectEndpoint = strings.TrimRight(strings.TrimSpace(env["FOUNDRY_PROJECT_ENDPOINT"]), "/") + } + if name == "" || version == "" || projectEndpoint == "" { + return nil, fmt.Errorf( + "hosted voice target service %q is not deployed; run 'azd deploy %s' or 'azd deploy --all'", + targetServiceName, strconv.Quote(targetServiceName)) + } + if !sameProjectEndpoint(projectEndpoint, env["FOUNDRY_PROJECT_ENDPOINT"]) { + return nil, fmt.Errorf( + "hosted voice target service %q is deployed to a different Foundry project", + targetServiceName, + ) + } + + return &hostedVoiceTarget{ + ServiceName: targetServiceName, + AgentName: name, + AgentVersion: version, + ProjectEndpoint: projectEndpoint, + }, nil +} + func endpointBelongsToProject(resourceEndpoint, projectEndpoint string) bool { resourceEndpoint = strings.TrimRight(strings.TrimSpace(resourceEndpoint), "/") projectEndpoint = strings.TrimRight(strings.TrimSpace(projectEndpoint), "/") diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/hosted_voice_target_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/hosted_voice_target_test.go new file mode 100644 index 00000000000..e769f643ff0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/hosted_voice_target_test.go @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "net/http" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/envkey" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestFetchAndValidateHostedVoiceTarget(t *testing.T) { + t.Parallel() + target := &hostedVoiceTarget{AgentName: "target", AgentVersion: "7"} + called := false + err := fetchAndValidateHostedVoiceTarget(t.Context(), target, func( + _ context.Context, name, version, apiVersion string, includeDigitalWorkerType bool, + ) (*agent_api.AgentVersionObject, error) { + called = true + require.Equal(t, "target", name) + require.Equal(t, "7", version) + require.Equal(t, agent_api.AgentEndpointAPIVersion, apiVersion) + require.False(t, includeDigitalWorkerType) + return compatibleHostedVoiceVersion(), nil + }) + require.NoError(t, err) + require.True(t, called) +} + +func TestFetchAndValidateHostedVoiceTargetClassifiesAzureFailure(t *testing.T) { + t.Parallel() + err := fetchAndValidateHostedVoiceTarget(t.Context(), &hostedVoiceTarget{}, func( + context.Context, string, string, string, bool, + ) (*agent_api.AgentVersionObject, error) { + return nil, &azcore.ResponseError{StatusCode: http.StatusForbidden} + }) + serviceErr, ok := errors.AsType[*azdext.ServiceError](err) + require.True(t, ok) + require.Contains(t, serviceErr.Message, "getting hosted voice target version") +} + +func TestFetchAndValidateHostedVoiceTargetClassifiesCompatibilityFailure(t *testing.T) { + t.Parallel() + err := fetchAndValidateHostedVoiceTarget(t.Context(), &hostedVoiceTarget{}, func( + context.Context, string, string, string, bool, + ) (*agent_api.AgentVersionObject, error) { + version := compatibleHostedVoiceVersion() + version.Status = "failed" + return version, nil + }) + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + require.Equal(t, azdext.LocalErrorCategoryDependency, localErr.Category) +} + +func TestHostedVoiceTargetMarkers(t *testing.T) { + t.Parallel() + target := &hostedVoiceTarget{AgentName: "target", AgentVersion: "7"} + require.Equal(t, "target", hostedVoiceTargetName(target)) + require.Equal(t, "7", hostedVoiceTargetVersion(target)) + require.Empty(t, hostedVoiceTargetName(nil)) + require.Empty(t, hostedVoiceTargetVersion(nil)) +} + +func compatibleHostedVoiceVersion() *agent_api.AgentVersionObject { + return &agent_api.AgentVersionObject{ + Name: "target", + Version: "7", + Status: "active", + Metadata: map[string]string{ + "voiceLiveCompatible": "true", + "bridgeProtocolVersion": "1.0", + }, + Definition: map[string]any{ + "kind": "hosted", + "protocol_versions": []any{map[string]any{ + "protocol": "invocations_ws", + "version": "1.0.0", + }}, + }, + } +} + +func TestResolveHostedVoiceTarget(t *testing.T) { + targetProps, err := AgentDefinitionToServiceProperties(agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindHosted, Name: "remote-target"}, + }, nil) + require.NoError(t, err) + target := &azdext.ServiceConfig{ + Name: "voice-target", + Host: foundryAgentHost, + AdditionalProperties: targetProps, + } + wrapper := &azdext.ServiceConfig{ + Name: "voice-wrapper", + Host: foundryAgentHost, + Uses: []string{"voice-target"}, + } + projectEndpoint := "https://account.services.ai.azure.com/api/projects/project" + env := map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": projectEndpoint, + "AGENT_VOICE_TARGET_NAME": "remote-target", + "AGENT_VOICE_TARGET_VERSION": "4", + envkey.AgentProjectEndpoint("voice-target"): projectEndpoint, + } + + resolved, err := resolveHostedVoiceTarget( + wrapper, + &agent_yaml.VoiceTargetAgent{Service: "voice-target", Version: "deployed"}, + map[string]*azdext.ServiceConfig{"voice-target": target}, + env, + t.TempDir(), + ) + require.NoError(t, err) + require.Equal(t, "remote-target", resolved.AgentName) + require.Equal(t, "4", resolved.AgentVersion) +} + +func TestResolveHostedVoiceTargetRequiresUses(t *testing.T) { + wrapper := &azdext.ServiceConfig{Name: "voice-wrapper", Host: foundryAgentHost} + _, err := resolveHostedVoiceTarget( + wrapper, + &agent_yaml.VoiceTargetAgent{Service: "voice-target"}, + map[string]*azdext.ServiceConfig{}, + map[string]string{}, + t.TempDir(), + ) + require.ErrorContains(t, err, "uses list") +} + +func TestResolveHostedVoiceTargetSupportsLegacyEndpointMarker(t *testing.T) { + targetProps, err := AgentDefinitionToServiceProperties(agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindHosted, Name: "remote-target"}, + }, nil) + require.NoError(t, err) + target := &azdext.ServiceConfig{Name: "voice-target", Host: foundryAgentHost, AdditionalProperties: targetProps} + wrapper := &azdext.ServiceConfig{Name: "voice-wrapper", Host: foundryAgentHost, Uses: []string{"voice-target"}} + projectEndpoint := "https://account.services.ai.azure.com/api/projects/project" + resolved, err := resolveHostedVoiceTarget( + wrapper, + &agent_yaml.VoiceTargetAgent{Service: "voice-target"}, + map[string]*azdext.ServiceConfig{"voice-target": target}, + map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": projectEndpoint, + "AGENT_VOICE_TARGET_NAME": "remote-target", + "AGENT_VOICE_TARGET_VERSION": "4", + "AGENT_VOICE_TARGET_ENDPOINT": projectEndpoint + "/agents/remote-target/versions/4", + }, + t.TempDir(), + ) + require.NoError(t, err) + require.Equal(t, projectEndpoint, resolved.ProjectEndpoint) +} + +func TestResolveHostedVoiceTargetRejectsDifferentProject(t *testing.T) { + targetProps, err := AgentDefinitionToServiceProperties(agent_yaml.ContainerAgent{ + AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindHosted, Name: "remote-target"}, + }, nil) + require.NoError(t, err) + target := &azdext.ServiceConfig{Name: "voice-target", Host: foundryAgentHost, AdditionalProperties: targetProps} + wrapper := &azdext.ServiceConfig{Name: "voice-wrapper", Host: foundryAgentHost, Uses: []string{"voice-target"}} + _, err = resolveHostedVoiceTarget( + wrapper, + &agent_yaml.VoiceTargetAgent{Service: "voice-target"}, + map[string]*azdext.ServiceConfig{"voice-target": target}, + map[string]string{ + "FOUNDRY_PROJECT_ENDPOINT": "https://account.services.ai.azure.com/api/projects/current", + "AGENT_VOICE_TARGET_NAME": "remote-target", + "AGENT_VOICE_TARGET_VERSION": "4", + envkey.AgentProjectEndpoint("voice-target"): "https://account.services.ai.azure.com/api/projects/other", + }, + t.TempDir(), + ) + require.ErrorContains(t, err, "different Foundry project") +} + +func TestValidateHostedVoiceTarget(t *testing.T) { + err := validateHostedVoiceTargetVersion(&agent_api.AgentVersionObject{ + Name: "remote-target", + Version: "4", + Status: "active", + Metadata: map[string]string{ + "voiceLiveCompatible": "true", + "bridgeProtocolVersion": "1.0", + }, + Definition: map[string]any{ + "kind": "hosted", + "protocol_versions": []any{map[string]any{ + "protocol": "invocations_ws", + "version": "1.0.0", + }}, + }, + }) + require.NoError(t, err) +} + +func TestValidateHostedVoiceTargetRejectsIncompatibleProtocol(t *testing.T) { + err := validateHostedVoiceTargetVersion(&agent_api.AgentVersionObject{ + Status: "active", + Metadata: map[string]string{ + "voiceLiveCompatible": "true", + "bridgeProtocolVersion": "1.0", + }, + Definition: map[string]any{ + "kind": "hosted", + "protocol_versions": []any{map[string]any{ + "protocol": "responses", + "version": "2.0.0", + }}, + }, + }) + require.ErrorContains(t, err, "invocations_ws/1.0.0") +} 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 e10d8be8bbc..66d4db5e409 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 @@ -10,6 +10,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -21,6 +22,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "regexp" "slices" "strconv" @@ -171,11 +173,12 @@ var _ azdext.ServiceTargetProvider = &AgentServiceTargetProvider{} // AgentServiceTargetProvider is a minimal implementation of ServiceTargetProvider for demonstration type AgentServiceTargetProvider struct { - azdClient *azdext.AzdClient - serviceConfig *azdext.ServiceConfig - agentDefinitionPath string - projectPath string - servicePath string + azdClient *azdext.AzdClient + serviceConfig *azdext.ServiceConfig + agentDefinitionPath string + agentDefinitionPathFromEnv bool + projectPath string + servicePath string // deployContextReady is set by every successful ensureDeployContext path; // agentDefinitionPath is only set for the file-based and env-override paths // (not the inline unified shape), so both are checked as the idempotency guard. @@ -432,6 +435,7 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er } p.agentDefinitionPath = envPath + p.agentDefinitionPathFromEnv = true fmt.Printf("Using agent definition from environment variable: %s\n", color.New(color.FgHiGreen).Sprint(envPath)) p.deployContextReady = true return nil @@ -1406,6 +1410,13 @@ func (p *AgentServiceTargetProvider) Deploy( if err != nil { return nil, err } + if shouldRejectTelephonyForNonVoice(isVoice, p.agentDefinitionPathFromEnv, serviceConfig) { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "telephony bindings are only supported for prompt voice agents", + "remove telephony from this service or set kind: prompt-voice", + ) + } var agentDef agent_yaml.ContainerAgent if !isVoice { @@ -2343,6 +2354,26 @@ func (p *AgentServiceTargetProvider) finalizeDeploy( }, nil } +func serviceHasTelephony(serviceConfig *azdext.ServiceConfig) bool { + for _, props := range []*structpb.Struct{serviceConfig.GetAdditionalProperties(), serviceConfig.GetConfig()} { + if props == nil { + continue + } + if _, ok := props.GetFields()["telephony"]; ok { + return true + } + } + return false +} + +func shouldRejectTelephonyForNonVoice( + isVoice bool, + agentDefinitionPathFromEnv bool, + serviceConfig *azdext.ServiceConfig, +) bool { + return !isVoice && !agentDefinitionPathFromEnv && serviceHasTelephony(serviceConfig) +} + // deployHostedAgent deploys a container-based hosted agent to the Foundry service. func (p *AgentServiceTargetProvider) deployHostedAgent( ctx context.Context, @@ -2434,7 +2465,39 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( ) (*azdext.ServiceDeployResult, error) { progress("Deploying voice agent") - request, err := agent_yaml.CreateVoiceAgentAPIRequest(va) + var request *agent_api.CreateAgentRequest + var hostedTarget *hostedVoiceTarget + var err error + if va.ModelType == agent_yaml.VoiceModelTypeHostedAgent { + if va.TargetAgent != nil && p.dependencyEnabled != nil { + enabled, enabledErr := p.dependencyEnabled(ctx, strings.TrimSpace(va.TargetAgent.Service)) + if enabledErr != nil { + return nil, enabledErr + } + if !enabled { + return nil, exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("hosted voice target service %q is disabled", va.TargetAgent.Service), + "enable the target hosted agent service or remove the voice wrapper", + ) + } + } + hostedTarget, err = resolveHostedVoiceTarget( + serviceConfig, va.TargetAgent, p.projectServices, azdEnv, p.projectPath, + ) + if err != nil { + return nil, exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("cannot resolve hosted voice target: %s", err), + "deploy the target hosted agent in the same project, then retry", + ) + } + request, err = agent_yaml.CreateHostedVoiceAgentAPIRequest(va, agent_api.VoiceTargetAgentReference{ + Name: hostedTarget.AgentName, Version: hostedTarget.AgentVersion, + }) + } else { + request, err = agent_yaml.CreateVoiceAgentAPIRequest(va) + } if err != nil { return nil, exterrors.Validation( exterrors.CodeInvalidAgentManifest, @@ -2454,6 +2517,12 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( } agentClient := agent_api.NewAgentClient(projectEndpoint, p.credential) + if hostedTarget != nil { + progress("Validating hosted voice target") + if err := fetchAndValidateHostedVoiceTarget(ctx, hostedTarget, agentClient.GetAgentVersion); err != nil { + return nil, err + } + } serviceKey := p.getServiceKey(serviceConfig.Name) agentObject, deployOp, err := p.deployVoiceAgentRemote( @@ -2465,6 +2534,11 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( if err := validateVoiceAgentDeployResponse(agentObject); err != nil { return nil, err } + if err := p.deployVoiceTelephonyBindings( + ctx, agentClient, va, agentObject, azdEnv[voiceOverriddenHostEnvKey], + ); err != nil { + return nil, err + } fmt.Fprintf(os.Stderr, "Voice agent '%s' deployed successfully!\n", agentObject.Name) @@ -2485,6 +2559,8 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( {fmt.Sprintf("AGENT_%s_NAME", serviceKey), agentObject.Name}, {versionKey, versionValue}, {fmt.Sprintf("AGENT_%s_PROJECT_ENDPOINT", serviceKey), strings.TrimRight(projectEndpoint, "/")}, + {fmt.Sprintf("AGENT_%s_TARGET_NAME", serviceKey), hostedVoiceTargetName(hostedTarget)}, + {fmt.Sprintf("AGENT_%s_TARGET_VERSION", serviceKey), hostedVoiceTargetVersion(hostedTarget)}, {endpointKey, baseEndpoint}, } { if _, setErr := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ @@ -2510,6 +2586,207 @@ func (p *AgentServiceTargetProvider) deployVoiceAgent( return &azdext.ServiceDeployResult{Artifacts: artifacts}, nil } +type getAgentVersionFunc func( + context.Context, string, string, string, bool, +) (*agent_api.AgentVersionObject, error) + +func fetchAndValidateHostedVoiceTarget( + ctx context.Context, + target *hostedVoiceTarget, + getVersion getAgentVersionFunc, +) error { + version, err := getVersion( + ctx, target.AgentName, target.AgentVersion, agent_api.AgentEndpointAPIVersion, false, + ) + if err != nil { + return exterrors.ServiceFromAzure(err, "getting hosted voice target version") + } + if err := validateHostedVoiceTargetVersion(version); err != nil { + return exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("hosted voice target is not compatible: %s", err), + "deploy an active Voice Bridge 1.0 hosted agent with invocations_ws/1.0.0, then retry", + ) + } + return nil +} + +func hostedVoiceTargetName(target *hostedVoiceTarget) string { + if target == nil { + return "" + } + return target.AgentName +} + +func hostedVoiceTargetVersion(target *hostedVoiceTarget) string { + if target == nil { + return "" + } + return target.AgentVersion +} + +func validateHostedVoiceTargetVersion(version *agent_api.AgentVersionObject) error { + if version == nil { + return fmt.Errorf("target version response is empty") + } + if version.Status != "active" { + return fmt.Errorf("target %s:%s has status %q, expected active", version.Name, version.Version, version.Status) + } + definitionJSON, err := json.Marshal(version.Definition) + if err != nil { + return fmt.Errorf("reading target definition: %w", err) + } + var definition agent_api.HostedAgentDefinition + if err := json.Unmarshal(definitionJSON, &definition); err != nil { + return fmt.Errorf("reading target definition: %w", err) + } + if definition.Kind != agent_api.AgentKindHosted { + return fmt.Errorf("target kind is %q, expected hosted", definition.Kind) + } + compatibleProtocol := false + for _, protocol := range definition.ProtocolVersions { + if protocol.Protocol == agent_api.AgentProtocolInvocationsWS && protocol.Version == "1.0.0" { + compatibleProtocol = true + break + } + } + if !compatibleProtocol { + return fmt.Errorf("target does not declare invocations_ws/1.0.0") + } + if !strings.EqualFold(strings.TrimSpace(version.Metadata["voiceLiveCompatible"]), "true") { + return fmt.Errorf("target metadata voiceLiveCompatible must be true") + } + if strings.TrimSpace(version.Metadata["bridgeProtocolVersion"]) != "1.0" { + return fmt.Errorf("target metadata bridgeProtocolVersion must be 1.0") + } + return nil +} + +func (p *AgentServiceTargetProvider) deployVoiceTelephonyBindings( + ctx context.Context, + agentClient *agent_api.AgentClient, + voiceAgent agent_yaml.VoiceAgent, + agentObject *agent_api.AgentObject, + overriddenHost string, +) error { + if voiceAgent.Telephony == nil || len(voiceAgent.Telephony.Bindings) == 0 { + return nil + } + for _, binding := range voiceAgent.Telephony.Bindings { + request := &agent_api.TelephonyBindingRequest{ + Provider: telephonyWireProvider(binding.Provider), + Identifier: strings.TrimSpace(binding.Identifier), + ConnectionName: strings.TrimSpace(binding.Connection), + TransferTargets: binding.TransferTargets, + } + + bindingID := fmt.Sprintf("%s:%s", request.Provider, request.Identifier) + remoteBinding, getErr := agentClient.GetTelephonyBinding( + ctx, + agentObject.Name, + bindingID, + agent_api.TelephonyBindingAPIVersion, + overriddenHost, + ) + if getErr == nil { + if !telephonyBindingMatches(remoteBinding, request) { + return exterrors.Validation( + exterrors.CodeTelephonyBindingDrift, + fmt.Sprintf("telephony binding %q already exists with different configuration", bindingID), + "delete or update the remote binding, then run azd deploy again", + ) + } + fmt.Fprintf(os.Stderr, "Telephony binding '%s' already exists.\n", bindingID) + continue + } + if respErr, ok := errors.AsType[*azcore.ResponseError](getErr); !ok || respErr.StatusCode != http.StatusNotFound { + return exterrors.ServiceFromAzure(getErr, exterrors.OpGetTelephonyBinding) + } + + created, err := agentClient.CreateTelephonyBinding( + ctx, + agentObject.Name, + request, + agent_api.TelephonyBindingAPIVersion, + overriddenHost, + ) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateTelephonyBinding) + } + id := created.ID + if id == "" { + id = bindingID + } + fmt.Fprintf(os.Stderr, "Telephony binding '%s' created.\n", id) + } + return nil +} + +func telephonyWireProvider(provider string) string { + switch strings.TrimSpace(provider) { + case "acs": + return "azure-communication-service" + default: + return strings.TrimSpace(provider) + } +} + +func telephonyBindingMatches(remote *agent_api.TelephonyBinding, desired *agent_api.TelephonyBindingRequest) bool { + if remote == nil || desired == nil { + return false + } + desiredID := fmt.Sprintf("%s:%s", strings.TrimSpace(desired.Provider), strings.TrimSpace(desired.Identifier)) + if remoteID := normalizedTelephonyBindingID(remote.ID); remoteID != desiredID { + return false + } + if strings.TrimSpace(remote.Provider) != "" && + strings.TrimSpace(remote.Provider) != strings.TrimSpace(desired.Provider) && + !(strings.TrimSpace(desired.Provider) == "azure-communication-service" && + strings.TrimSpace(remote.Provider) == "teams_phone_extension") { + return false + } + if strings.TrimSpace(remote.Identifier) != "" && + strings.TrimSpace(remote.Identifier) != strings.TrimSpace(desired.Identifier) { + return false + } + if remoteConnection := telephonyBindingConnection(remote); remoteConnection != strings.TrimSpace(desired.ConnectionName) { + return false + } + return reflect.DeepEqual( + emptyTransferTargetsAsNil(remote.TransferTargets), + emptyTransferTargetsAsNil(desired.TransferTargets), + ) +} + +func telephonyBindingConnection(remote *agent_api.TelephonyBinding) string { + if remote == nil { + return "" + } + if strings.TrimSpace(remote.ConnectionName) != "" { + return strings.TrimSpace(remote.ConnectionName) + } + return strings.TrimSpace(remote.Connection) +} + +func normalizedTelephonyBindingID(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + unescaped, err := url.PathUnescape(value) + if err != nil { + return value + } + return unescaped +} + +func emptyTransferTargetsAsNil(targets []map[string]any) []map[string]any { + if len(targets) == 0 { + return nil + } + return targets +} + func validateVoiceAgentDeployResponse(agentObject *agent_api.AgentObject) error { if agentObject == nil { return fmt.Errorf("malformed voice agent service response: missing agent object") 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 d8baffe37ea..6fac0372324 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 @@ -45,6 +45,9 @@ func TestVoiceAgentInlineServicePropertiesRoundTrip_BYOM(t *testing.T) { Instructions: &instructions, Voice: &voice, Store: &store, + Telephony: &agent_yaml.VoiceTelephony{Bindings: []agent_yaml.VoiceTelephonyBinding{ + {Provider: "twilio", Identifier: "+14255550123", Connection: "telephony-twilio"}, + }}, }, nil) require.NoError(t, err) @@ -67,6 +70,74 @@ func TestVoiceAgentInlineServicePropertiesRoundTrip_BYOM(t *testing.T) { require.Equal(t, voice, *got.Voice) require.NotNil(t, got.Store) require.Equal(t, store, *got.Store) + require.NotNil(t, got.Telephony) + require.Len(t, got.Telephony.Bindings, 1) + require.Equal(t, "twilio", got.Telephony.Bindings[0].Provider) + require.Equal(t, "+14255550123", got.Telephony.Bindings[0].Identifier) + require.Equal(t, "telephony-twilio", got.Telephony.Bindings[0].Connection) +} + +func TestVoiceAgentInlineServicePropertiesRoundTrip_HostedAgent(t *testing.T) { + props, err := VoiceAgentDefinitionToServiceProperties(agent_yaml.VoiceAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPromptVoice, + Name: "voice-wrapper", + }, + ModelType: agent_yaml.VoiceModelTypeHostedAgent, + TargetAgent: &agent_yaml.VoiceTargetAgent{ + Service: "voice-target", + Version: "deployed", + }, + }, nil) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "voice-wrapper", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + got, found, err := VoiceAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, agent_yaml.VoiceModelTypeHostedAgent, got.ModelType) + require.Equal(t, "voice-target", got.TargetAgent.Service) + require.Equal(t, "deployed", got.TargetAgent.Version) +} + +func TestVoiceAgentInlineServicePropertiesRoundTrip_HostedAgentVoiceKind(t *testing.T) { + props, err := VoiceAgentDefinitionToServiceProperties(agent_yaml.VoiceAgent{ + AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindVoice, Name: "voice"}, + ModelType: agent_yaml.VoiceModelTypeHostedAgent, + TargetAgent: &agent_yaml.VoiceTargetAgent{ + Service: "voice-target", + Version: "deployed", + }, + }, nil) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "voice", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + got, found, err := VoiceAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, agent_yaml.AgentKindVoice, got.Kind) + require.Equal(t, "voice-target", got.TargetAgent.Service) +} + +func TestVoiceAgentInlineServicePropertiesRejectsProtocols(t *testing.T) { + _, _, _, _, err := AgentDefinitionFromService(inlineAgentService(t, map[string]any{ + "kind": "prompt-voice", + "name": "voice", + "model": map[string]any{"id": "gpt-realtime"}, + "protocols": []any{map[string]any{ + "protocol": "invocations_ws", + "version": "1.0.0", + }}, + })) + require.ErrorContains(t, err, "protocols are not supported on prompt voice agents") } func TestApplyAgentMetadata(t *testing.T) { @@ -111,6 +182,71 @@ func TestApplyAgentMetadata(t *testing.T) { } } +func TestServiceHasTelephony(t *testing.T) { + props := &structpb.Struct{Fields: map[string]*structpb.Value{ + "telephony": structpb.NewStructValue(&structpb.Struct{}), + }} + require.True(t, serviceHasTelephony(&azdext.ServiceConfig{AdditionalProperties: props})) + require.False(t, serviceHasTelephony(&azdext.ServiceConfig{})) +} + +func TestShouldRejectTelephonyForNonVoice(t *testing.T) { + props := &structpb.Struct{Fields: map[string]*structpb.Value{ + "telephony": structpb.NewStructValue(&structpb.Struct{}), + }} + svc := &azdext.ServiceConfig{AdditionalProperties: props} + + require.False(t, shouldRejectTelephonyForNonVoice(true, false, svc)) + require.False(t, shouldRejectTelephonyForNonVoice(false, true, svc)) + require.True(t, shouldRejectTelephonyForNonVoice(false, false, svc)) +} + +func TestTelephonyBindingMatches(t *testing.T) { + desired := &agent_api.TelephonyBindingRequest{ + Provider: "twilio", + Identifier: "+14255550123", + ConnectionName: "telephony-twilio", + TransferTargets: []map[string]any{{"kind": "phone", "target": "+14255550124"}}, + } + remote := &agent_api.TelephonyBinding{ + ID: "twilio:%2B14255550123", + Provider: "twilio", + Identifier: "+14255550123", + ConnectionName: "telephony-twilio", + TransferTargets: []map[string]any{{"kind": "phone", "target": "+14255550124"}}, + } + require.True(t, telephonyBindingMatches(remote, desired)) + remote.ConnectionName = "other" + require.False(t, telephonyBindingMatches(remote, desired)) + remote.ConnectionName = "telephony-twilio" + remote.TransferTargets = []map[string]any{} + desired.TransferTargets = nil + require.True(t, telephonyBindingMatches(remote, desired)) +} + +func TestTelephonyBindingMatches_ServiceOmittedFields(t *testing.T) { + desired := &agent_api.TelephonyBindingRequest{ + Provider: "azure-communication-service", + Identifier: "28:orgid:00000000-0000-0000-0000-000000000001", + ConnectionName: "telephony-acs", + } + remote := &agent_api.TelephonyBinding{ + ID: "azure-communication-service:28:orgid:00000000-0000-0000-0000-000000000001", + Provider: "teams_phone_extension", + Connection: "telephony-acs", + Status: "active", + } + require.True(t, telephonyBindingMatches(remote, desired)) + + remote.ID = "azure-communication-service:28:orgid:00000000-0000-0000-0000-000000000002" + require.False(t, telephonyBindingMatches(remote, desired)) +} + +func TestTelephonyWireProvider(t *testing.T) { + require.Equal(t, "azure-communication-service", telephonyWireProvider("acs")) + require.Equal(t, "twilio", telephonyWireProvider("twilio")) +} + type fakeProjectAgentChecker struct { err error } 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 d82885f6f87..4e9225fc694 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 @@ -46,8 +46,8 @@ }, "kind": { "type": "string", - "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"] + "description": "The agent kind. 'hosted' for a containerized/code agent; 'voice' for a managed speech-to-speech voice agent. 'prompt-voice' remains accepted for compatibility.", + "enum": ["hosted", "prompt-voice", "voice"] }, "language": { "type": "string", @@ -66,8 +66,11 @@ }, "modelType": { "type": "string", - "description": "Voice agent (kind: prompt-voice) model-inference mode. 'managed' uses a Voice Live-hosted model; 'self_deployed' (BYOM) references an existing Foundry model deployment.", - "enum": ["managed", "self_deployed"] + "description": "Voice agent model-inference mode. 'managed' uses a Voice Live-hosted model; 'self_deployed' references a Foundry model deployment; 'hosted_agent' routes turns to a deployed hosted agent service.", + "enum": ["managed", "self_deployed", "hosted_agent"] + }, + "targetAgent": { + "$ref": "#/definitions/VoiceTargetAgent" }, "model": { "type": "object", @@ -143,6 +146,9 @@ "description": "Voice agent (kind: prompt-voice) extra service response fields to include.", "items": { "type": "string" } }, + "telephony": { + "$ref": "#/definitions/VoiceTelephony" + }, "name": { "type": "string", "description": "The agent name." @@ -206,20 +212,65 @@ "additionalProperties": true, "allOf": [ { - "$comment": "A prompt-voice agent must declare a speech-to-speech model and cannot use the hosted-agent toolbox reference, which the voice deployment path does not support.", + "$comment": "A hosted-agent voice wrapper references a deployed hosted service; other voice modes declare a model. Prompt voice agents cannot use the hosted-agent toolbox reference.", "if": { "properties": { - "kind": { "const": "prompt-voice" } + "modelType": { "const": "hosted_agent" } }, - "required": ["kind"] + "required": ["modelType"] }, "then": { - "required": ["model"], + "properties": { + "kind": { "enum": ["prompt-voice", "voice"] } + }, + "required": ["kind", "targetAgent"], "not": { - "required": ["toolbox"] + "anyOf": [ + { "required": ["model"] }, + { "required": ["inputSchema"] }, + { "required": ["outputSchema"] }, + { "required": ["policies"] }, + { "required": ["protocols"] }, + { "required": ["instructions"] }, + { "required": ["structuredInputs"] }, + { "required": ["tools"] }, + { "required": ["toolChoice"] }, + { "required": ["parallelToolCalls"] }, + { "required": ["maxOutputTokens"] }, + { "required": ["include"] }, + { "required": ["handoff"] }, + { "required": ["toolbox"] } + ] + } + }, + "else": { + "if": { + "properties": { "kind": { "enum": ["prompt-voice", "voice"] } }, + "required": ["kind"] + }, + "then": { + "required": ["model"], + "not": { + "anyOf": [ + { "required": ["targetAgent"] }, + { "required": ["toolbox"] }, + { "required": ["policies"] } + ] + } } } }, + { + "$comment": "targetAgent is only valid for a hosted-agent voice wrapper.", + "if": { "required": ["targetAgent"] }, + "then": { + "properties": { + "kind": { "enum": ["prompt-voice", "voice"] }, + "modelType": { "const": "hosted_agent" } + }, + "required": ["kind", "modelType"] + } + }, { "$comment": "The activity.publish block is shared publish metadata for Activity agents. Microsoft 365 Digital Worker publish is tenant-only; permission scopes and access boundaries are optional.", "if": { @@ -293,6 +344,16 @@ } ], "definitions": { + "VoiceTargetAgent": { + "type": "object", + "description": "Hosted agent service used as the conversation target for a voice wrapper.", + "properties": { + "service": { "type": "string", "minLength": 1, "pattern": "\\S", "description": "azure.yaml service name of the target hosted agent." }, + "version": { "type": "string", "enum": ["deployed"], "default": "deployed", "description": "Pin the wrapper to the target version deployed by this azd environment." } + }, + "required": ["service"], + "additionalProperties": false + }, "ActivitySettings": { "type": "object", "description": "Activity-protocol Teams configuration. Omit digitalWorkerType for simple mode; m365 applies Digital Worker constraints via allOf.", @@ -375,6 +436,31 @@ "required": ["runtime", "entryPoint"], "additionalProperties": false }, + "VoiceTelephony": { + "type": "object", + "description": "Prompt voice telephony configuration. Only valid for kind: prompt-voice.", + "properties": { + "bindings": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/VoiceTelephonyBinding" } + } + }, + "required": ["bindings"], + "additionalProperties": false + }, + "VoiceTelephonyBinding": { + "type": "object", + "description": "Foundry-side binding from a phone provider identifier to this prompt voice agent.", + "properties": { + "provider": { "type": "string", "enum": ["acs", "twilio"] }, + "identifier": { "type": "string", "description": "ACS/TPE raw ID (28:orgid:), ACS purchased number (4:+), or Twilio E.164 number (+)." }, + "connection": { "type": "string", "description": "Foundry project connection name used by the telephony runtime." }, + "transferTargets": { "type": "array", "items": { "type": "object", "additionalProperties": true } } + }, + "required": ["provider", "identifier", "connection"], + "additionalProperties": false + }, "VoiceAudio": { "type": "object", "description": "Prompt voice input and output audio configuration.",