Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7fb0877
feat(agents): support declarative prompt-voice agents (managed model)
Copilot Jul 30, 2026
29b8724
test(agents): add prompt-voice unit tests, schema, and cspell entry
Copilot Jul 30, 2026
82e094a
test(projects): sync synthesis parity copy for prompt-voice comment
Copilot Jul 30, 2026
fd2d701
fix(agents): address prompt-voice review feedback
Copilot Aug 3, 2026
438466c
fix(agents): address prompt-voice review round 2
Copilot Aug 3, 2026
a508d1a
chore(agents): add Nanami to cspell dictionary
Copilot Aug 3, 2026
9c5ca01
refactor(agents): unify prompt-voice kind resolution in a leaf package
Copilot Aug 3, 2026
4c47fab
test(agents): tighten agentkind test dir perms to 0o750 (gosec G301)
Copilot Aug 3, 2026
2bc2ce9
fix(agents): resolve project root in Endpoints for $ref/on-disk voice…
Copilot Aug 3, 2026
470ea64
fix(agents): thread filterHostedRegions through configureFoundryProject
Copilot Aug 3, 2026
cef22a2
fix(agents): honor AGENT_DEFINITION_PATH override in Endpoints
Copilot Aug 3, 2026
de4b8be
fix(agents): honor AGENT_DEFINITION_PATH in next-step, document voice…
Copilot Aug 3, 2026
cfe817a
test(agents): add coverage for voice manifest, CreateVoiceAgent, Go 1…
Copilot Aug 3, 2026
94fde92
fix(agents): require model for prompt-voice in service-target schema
Copilot Aug 3, 2026
7f5667f
fix(agents): normalize OpenAI voice name casing on the wire
Copilot Aug 3, 2026
4db597e
fix(agents): avoid double name prompt in interactive voice init
Copilot Aug 4, 2026
e75d86e
Merge remote-tracking branch 'origin/main' into users/wujia/prompt-vo…
Copilot Aug 4, 2026
bea4d9c
fix(agents): reject --kind prompt-voice combined with --manifest
Copilot Aug 4, 2026
7b30f47
fix(agents): append prompt-voice agent to existing azd project
Copilot Aug 4, 2026
03f8a6c
fix(agents): trim prompt-voice --agent-name hint
Copilot Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cli/azd/extensions/azure.ai.agents/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ words:
- harsheet
# Terms
- Reprompt
# Voice (prompt-voice) agents
- BYOM
- Nanami
# Azure region names
- australiaeast
- brazilsouth
Expand Down Expand Up @@ -87,6 +90,7 @@ words:
# Doctor / next-step terms
- nextstep
- nextsteps
- agentkind
- undeployed
- unredacted
- UNKN
Expand Down
338 changes: 329 additions & 9 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,7 @@ func runInitFromAzureYaml(
ctx, azdClient, azureContext, env.Name,
flags.projectResourceId, flags.noPrompt,
skipACR,
skipACR, // filterHostedRegions: adopt path is container/code only (no voice); preserve prior behavior
)
if err != nil {
if exterrors.IsCancellation(err) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func configureFoundryProject(
projectResourceId string,
noPrompt bool,
skipACR bool,
filterHostedRegions bool,
) (*foundryProjectSetupResult, error) {
result := &foundryProjectSetupResult{}

Expand Down Expand Up @@ -71,6 +72,7 @@ func configureFoundryProject(
ctx, azdClient, newCred, azureContext, envName,
azureContext.Scope.SubscriptionId, projectResourceId,
skipACR,
filterHostedRegions,
true, // bicepless
)
if err != nil {
Expand Down Expand Up @@ -144,6 +146,7 @@ func configureFoundryProject(
ctx, azdClient, newCred, azureContext, envName,
azureContext.Scope.SubscriptionId, "",
skipACR,
filterHostedRegions,
true, // bicepless
)
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,9 @@ func sortModelDeploymentCandidates(candidates []*azdext.AiModelDeployment, defau
// "Create a new Foundry project" or no projects exist.
// When a project is selected, configures all project-related environment variables.
// skipACR skips ACR connection discovery (used for code deploy);
// filterHostedRegions restricts the project list to regions that support hosted
// agents (code deploy or --image). These are separate decisions: a prompt-voice
// (managed) agent skips ACR but is not hosted, so it must not be region-filtered.
// bicepless skips both ACR and AppInsights prompts (see
// configureFoundryProjectEnv).
func selectFoundryProject(
Expand All @@ -1412,6 +1415,7 @@ func selectFoundryProject(
subscriptionId string,
projectResourceId string,
skipACR bool,
filterHostedRegions bool,
bicepless bool,
) (*FoundryProjectInfo, error) {
spinnerText := "Searching for Foundry projects in your subscription..."
Expand Down Expand Up @@ -1450,9 +1454,11 @@ func selectFoundryProject(
return nil, fmt.Errorf("failed to list Foundry projects: %w", err)
}

// When ACR is skipped (code deploy, or a pre-built --image), the agent runs as a
// hosted agent, so restrict to regions that support hosted agents.
if skipACR {
// Hosted agents (code deploy or a pre-built --image) must run in a region that
// supports hosted agents, so restrict the project list to those regions. This
// is intentionally gated on filterHostedRegions rather than skipACR: a managed
// prompt-voice agent also skips ACR but is not hosted and must not be filtered.
if filterHostedRegions {
supportedRegions, regErr := supportedRegionsForInit(ctx)
if regErr != nil {
// Propagate context cancellation/timeout — these are not recoverable fetch failures.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,8 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context)
ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name,
a.azureContext.Scope.SubscriptionId, a.flags.projectResourceId,
deployMode == "code",
true, // bicepless
deployMode == "code", // filterHostedRegions: code deploy targets hosted agents
true, // bicepless
)
if err != nil {
return nil, err
Expand Down Expand Up @@ -405,7 +406,8 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context)
ctx, a.azdClient, a.credential, a.azureContext, a.environment.Name,
a.azureContext.Scope.SubscriptionId, "",
deployMode == "code",
true, // bicepless
deployMode == "code", // filterHostedRegions: code deploy targets hosted agents
true, // bicepless
)
if err != nil {
return nil, err
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,28 +96,55 @@ func (t *AgentTemplate) EffectiveType() string {
const (
initModeFromCode = "from_code"
initModeTemplate = "template"
// initModeVoice is chosen when the user wants to create a declarative
// (managed) voice agent. It maps to the same synthesized-manifest fast path
// as `azd ai agent init --kind prompt-voice`.
initModeVoice = "prompt_voice"
)

// promptInitMode asks the user whether to use existing code or start from a template.
// If the current directory is empty, automatically returns initModeTemplate.
// In no-prompt mode with existing local files, defaults to using the current directory.
// Returns initModeFromCode or initModeTemplate.
// voiceInitChoice is the interactive menu entry for creating a prompt voice agent.
// It is appended to the init-mode choices in both the empty-dir and existing-code
// cases so the option is always offered interactively.
var voiceInitChoice = &azdext.SelectChoice{
Label: "Create a prompt voice agent",
Value: initModeVoice,
}

// promptInitMode asks the user whether to use existing code, start from a
// template, or create a prompt voice agent.
// If the current directory is empty, the "use existing code" option is omitted
// (there is no code to use) but the template / voice options are still offered
// interactively.
// In no-prompt mode the directory contents decide: empty -> template, otherwise
// use the current directory. Voice is only selectable interactively (or via
// --kind prompt-voice in no-prompt mode).
// Returns initModeFromCode, initModeTemplate, or initModeVoice.
func promptInitMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool) (string, error) {
empty, err := dirIsEmpty(".")
if err != nil {
return "", fmt.Errorf("checking current directory: %w", err)
}

if empty {
return initModeTemplate, nil
}
if noPrompt {
if empty {
return initModeTemplate, nil
}
return initModeFromCode, nil
}

choices := []*azdext.SelectChoice{
{Label: "Use the code in the current directory", Value: initModeFromCode},
{Label: "Start new from a template", Value: initModeTemplate},
var choices []*azdext.SelectChoice
if empty {
// No local code to adopt; offer template + voice.
choices = []*azdext.SelectChoice{
{Label: "Start new from a template", Value: initModeTemplate},
voiceInitChoice,
}
} else {
choices = []*azdext.SelectChoice{
{Label: "Use the code in the current directory", Value: initModeFromCode},
{Label: "Start new from a template", Value: initModeTemplate},
voiceInitChoice,
}
}

defaultIndex := int32(0)
Expand Down
92 changes: 92 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ func TestSkipACR(t *testing.T) {
name string
isCodeDeploy bool
image string
isVoiceAgent bool
want bool
}{
{
Expand All @@ -289,6 +290,13 @@ func TestSkipACR(t *testing.T) {
image: "myacr.azurecr.io/agent:v1",
want: true,
},
{
name: "voice agent skips ACR",
isCodeDeploy: false,
image: "",
isVoiceAgent: true,
want: true,
},
{
name: "neither set does not skip ACR",
isCodeDeploy: false,
Expand All @@ -303,6 +311,7 @@ func TestSkipACR(t *testing.T) {

action := &InitAction{
isCodeDeploy: tt.isCodeDeploy,
isVoiceAgent: tt.isVoiceAgent,
flags: &initFlags{image: tt.image},
}

Expand All @@ -311,6 +320,40 @@ func TestSkipACR(t *testing.T) {
}
}

// TestIsHostedAgent verifies that isHostedAgent is decoupled from skipACR: a
// voice agent skips ACR but is not a hosted agent, so it must not be treated as
// hosted for region filtering.
func TestIsHostedAgent(t *testing.T) {
t.Parallel()

tests := []struct {
name string
isCodeDeploy bool
image string
isVoiceAgent bool
want bool
}{
{name: "code deploy is hosted", isCodeDeploy: true, want: true},
{name: "image is hosted", image: "myacr.azurecr.io/agent:v1", want: true},
{name: "voice is not hosted", isVoiceAgent: true, want: false},
{name: "plain container is not hosted", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

action := &InitAction{
isCodeDeploy: tt.isCodeDeploy,
isVoiceAgent: tt.isVoiceAgent,
flags: &initFlags{image: tt.image},
}

require.Equal(t, tt.want, action.isHostedAgent())
})
}
}

func TestSynthesizeImageManifestFile(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -3798,3 +3841,52 @@ func TestRemoveContainerFiles(t *testing.T) {
}
})
}

// TestSynthesizeVoiceManifestFile verifies the --kind prompt-voice scaffold path
// writes a valid managed voice manifest that round-trips through the real parser,
// covering the default model, the explicit model/voice overrides, and that no
// voice key is emitted when none is supplied.
func TestSynthesizeVoiceManifestFile(t *testing.T) {
t.Parallel()

parse := func(t *testing.T, path string) agent_yaml.VoiceAgent {
t.Helper()
data, err := os.ReadFile(path) //nolint:gosec // path is produced by the function under test
require.NoError(t, err)
def, err := agent_yaml.ExtractAgentDefinition(data)
require.NoError(t, err)
va, ok := def.(agent_yaml.VoiceAgent)
require.True(t, ok, "expected VoiceAgent, got %T", def)
return va
}

t.Run("defaults model when empty and omits voice", func(t *testing.T) {
t.Parallel()
path, cleanup, err := synthesizeVoiceManifestFile("my-voice", "", "")
require.NoError(t, err)
defer cleanup()

va := parse(t, path)
require.Equal(t, agent_yaml.AgentKindPromptVoice, va.Kind)
require.Equal(t, agent_yaml.VoiceModelTypeManaged, va.ModelType)
require.NotNil(t, va.Model)
require.Equal(t, defaultVoiceModel, va.Model.Id)
require.Nil(t, va.Voice, "no voice key should be emitted when none is supplied")
})

t.Run("honors explicit model and voice", func(t *testing.T) {
t.Parallel()
path, cleanup, err := synthesizeVoiceManifestFile(
"my-voice", "gpt-realtime-preview", "en-US-Ava:DragonHDLatestNeural",
)
require.NoError(t, err)
defer cleanup()

va := parse(t, path)
require.Equal(t, agent_yaml.VoiceModelTypeManaged, va.ModelType)
require.NotNil(t, va.Model)
require.Equal(t, "gpt-realtime-preview", va.Model.Id)
require.NotNil(t, va.Voice)
require.Equal(t, "en-US-Ava:DragonHDLatestNeural", *va.Voice)
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"strings"

"azureaiagent/internal/pkg/agents/agent_yaml"
"azureaiagent/internal/pkg/agents/agentkind"
"azureaiagent/internal/pkg/envkey"
"azureaiagent/internal/pkg/paths"

Expand All @@ -34,6 +35,12 @@ const (
// agent service. Filled with the upper-cased service key.
agentVersionVarFormat = "AGENT_%s_VERSION"

// agentEndpointVarFormat is the base endpoint env-var written for every
// deployed agent. Voice agents (kind: prompt-voice) are created
// synchronously with no agent-version object, so this base endpoint is the
// only deployment marker they set — isDeployed falls back to it.
agentEndpointVarFormat = "AGENT_%s_ENDPOINT"

// projectEndpointVar is the env-var that carries the Foundry project
// endpoint URL produced by `azd ai agent init`.
projectEndpointVar = "FOUNDRY_PROJECT_ENDPOINT"
Expand Down Expand Up @@ -446,7 +453,7 @@ func collectServices(
RelativePath: svc.RelativePath,
Protocol: protocol,
MultiProtocol: multiProtocol,
IsDeployed: isDeployed(ctx, src, envName, svc.Name, errs),
IsDeployed: isDeployed(ctx, src, envName, svc.Name, isVoiceService(project.Path, svc), errs),
})
}

Expand Down Expand Up @@ -513,6 +520,17 @@ func structHasKind(s *structpb.Struct) bool {
return ok && strings.TrimSpace(v.GetStringValue()) != ""
}

// isVoiceService reports whether the service declares kind: prompt-voice. It
// delegates to the shared agentkind lookup so the next-step reader classifies a
// service identically to the deploy path and Endpoints, including honoring an
// explicit AGENT_DEFINITION_PATH override (which deploy follows). Kind
// resolution is best-effort for next-step hints, so any error is treated as
// not-voice.
func isVoiceService(projectPath string, svc *azdext.ServiceConfig) bool {
isVoice, err := agentkind.IsPromptVoice(svc, projectPath, os.Getenv("AGENT_DEFINITION_PATH"))
return err == nil && isVoice
}

func loadServiceProtocolFromFile(projectPath, relativePath string) (string, bool) {
if projectPath == "" {
return "", false
Expand Down Expand Up @@ -741,6 +759,7 @@ func isDeployed(
ctx context.Context,
src Source,
envName, serviceName string,
isVoice bool,
errs *[]error,
) bool {
if envName == "" || serviceName == "" {
Expand All @@ -752,7 +771,30 @@ func isDeployed(
*errs = append(*errs, fmt.Errorf("read %s: %w", key, err))
return false
}
return value != ""
if value != "" {
return true
}

// Voice agents (kind: prompt-voice) deploy without an agent-version object,
// so they never set AGENT_<KEY>_VERSION. Fall back to the base endpoint
// marker, which every voice deploy writes, so a successfully created voice
// agent is not reported as undeployed. Gate this on the service's actual
// declared kind: a hosted agent whose deploy partially failed can also
// present an empty VERSION with a lingering ENDPOINT, and must stay reported
// as not-deployed. This mirrors the kind gate in
// AgentServiceTargetProvider.Endpoints (project package); the two live in
// separate packages because project imports nextstep, so a literally shared
// helper would create an import cycle.
if !isVoice {
return false
}
endpointKey := fmt.Sprintf(agentEndpointVarFormat, serviceKey(serviceName))
Comment thread
v1212 marked this conversation as resolved.
endpointValue, err := src.EnvValue(ctx, envName, endpointKey)
if err != nil {
*errs = append(*errs, fmt.Errorf("read %s: %w", endpointKey, err))
return false
}
return endpointValue != ""
}

// serviceKey converts a service name into the env-var key fragment used by
Expand Down
Loading
Loading