Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ func resolveAgentServiceFromProject(ctx context.Context, azdClient *azdext.AzdCl

// ServiceRunContext holds the resolved context needed for local development.
type ServiceRunContext struct {
ServiceName string // the resolved service name (from azure.yaml)
ProjectDir string // absolute path to the service source directory
StartupCommand string // startupCommand from AdditionalProperties (may be empty)
}
Expand All @@ -556,6 +557,7 @@ func resolveServiceRunContext(ctx context.Context, azdClient *azdext.AzdClient,
}

return &ServiceRunContext{
ServiceName: svc.Name,
ProjectDir: projectDir,
StartupCommand: startupCmd,
}, nil
Expand Down
31 changes: 19 additions & 12 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,22 +578,16 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context)
}

// Create a minimal Agent Definition
// Note: FOUNDRY_PROJECT_ENDPOINT and other FOUNDRY_* env vars are automatically
// injected into hosted agent containers by the platform, so we don't need to
// add them to agent.yaml. For local development, `azd ai agent run` translates
// azd environment values to FOUNDRY_* env vars.
definition := &agent_yaml.ContainerAgent{
AgentDefinition: agent_yaml.AgentDefinition{
Name: agentName,
Kind: agentKind,
},
Protocols: protocols,
EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{
{
Name: "AZURE_OPENAI_ENDPOINT",
Value: "${AZURE_OPENAI_ENDPOINT}",
},
{
Name: "AZURE_AI_PROJECT_ENDPOINT",
Value: "${AZURE_AI_PROJECT_ENDPOINT}",
},
},
}

// Add model resource if a model was selected
Expand All @@ -612,7 +606,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context)
},
})

*definition.EnvironmentVariables = append(*definition.EnvironmentVariables, agent_yaml.EnvironmentVariable{
definition.EnvironmentVariables = appendEnvVar(definition.EnvironmentVariables, agent_yaml.EnvironmentVariable{
Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME",
Value: "${AZURE_AI_MODEL_DEPLOYMENT_NAME}",
})
Expand All @@ -639,7 +633,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context)
},
})

*definition.EnvironmentVariables = append(*definition.EnvironmentVariables, agent_yaml.EnvironmentVariable{
definition.EnvironmentVariables = appendEnvVar(definition.EnvironmentVariables, agent_yaml.EnvironmentVariable{
Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME",
Value: "${AZURE_AI_MODEL_DEPLOYMENT_NAME}",
})
Expand Down Expand Up @@ -677,6 +671,19 @@ func (a *InitFromCodeAction) resolveSelectedModelDeployment(
return selector.getModelDetails(ctx, model.Name)
}

// appendEnvVar appends an environment variable to a possibly-nil slice pointer,
// initializing it if needed.
func appendEnvVar(
envVars *[]agent_yaml.EnvironmentVariable,
envVar agent_yaml.EnvironmentVariable,
) *[]agent_yaml.EnvironmentVariable {
if envVars == nil {
return &[]agent_yaml.EnvironmentVariable{envVar}
}
*envVars = append(*envVars, envVar)
return envVars
}

// sanitizeAgentName converts a string into a valid agent name:
// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens,
// strip leading/trailing hyphens, truncate to 63 chars.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,8 +415,6 @@ func TestWriteDefinitionToSrcDir(t *testing.T) {
{Protocol: "responses", Version: "v1"},
},
EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{
{Name: "AZURE_OPENAI_ENDPOINT", Value: "${AZURE_OPENAI_ENDPOINT}"},
{Name: "AZURE_AI_PROJECT_ENDPOINT", Value: "${AZURE_AI_PROJECT_ENDPOINT}"},
{Name: "AZURE_AI_MODEL_DEPLOYMENT_NAME", Value: "${AZURE_AI_MODEL_DEPLOYMENT_NAME}"},
},
}
Expand All @@ -440,9 +438,14 @@ func TestWriteDefinitionToSrcDir(t *testing.T) {

contentStr := string(content)
// Verify key content is present in the YAML
if !containsAll(contentStr, "name: test-agent", "kind: hosted", "responses", "AZURE_OPENAI_ENDPOINT") {
if !containsAll(contentStr, "name: test-agent", "kind: hosted", "responses", "AZURE_AI_MODEL_DEPLOYMENT_NAME") {
t.Errorf("written content missing expected fields:\n%s", contentStr)
}
// AZURE_OPENAI_ENDPOINT and AZURE_AI_PROJECT_ENDPOINT should NOT be written to agent.yaml.
// Hosted agents receive platform-provided FOUNDRY_* variables such as FOUNDRY_PROJECT_ENDPOINT instead.
if strings.Contains(contentStr, "AZURE_OPENAI_ENDPOINT") || strings.Contains(contentStr, "AZURE_AI_PROJECT_ENDPOINT") {
t.Errorf("agent.yaml should not contain AZURE_OPENAI_ENDPOINT or AZURE_AI_PROJECT_ENDPOINT:\n%s", contentStr)
}
})

t.Run("creates nested directories", func(t *testing.T) {
Expand Down
68 changes: 67 additions & 1 deletion cli/azd/extensions/azure.ai.agents/internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os/signal"
"path/filepath"
"runtime"
"slices"
"strings"
"syscall"

Expand Down Expand Up @@ -137,11 +138,15 @@ func runRun(ctx context.Context, flags *runFlags) error {
env = append(env, fmt.Sprintf("PORT=%d", flags.port))

// Load azd environment variables (e.g., AZURE_AI_PROJECT_ENDPOINT)
// so the agent can reach Azure services during local development
// so the agent can reach Azure services during local development.
// Also translate azd env keys to FOUNDRY_* env vars so the agent code
// works identically whether running locally or in a hosted container
// (where the platform automatically injects FOUNDRY_* env vars).
if azdEnvVars, err := loadAzdEnvironment(ctx, azdClient); err == nil {
for k, v := range azdEnvVars {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
env = appendFoundryEnvVars(env, azdEnvVars, runCtx.ServiceName)
}

url := fmt.Sprintf("http://localhost:%d", flags.port)
Expand Down Expand Up @@ -362,6 +367,67 @@ func venvBinDir(venvDir string) string {
return filepath.Join(venvDir, "bin")
}

// appendFoundryEnvVars translates azd environment keys to FOUNDRY_* env vars that hosted
// agent containers receive automatically from the platform. This ensures the agent code
// works identically whether running locally (via azd ai agent run) or in a hosted container.
//
// The mapping is:
//
// AZURE_AI_PROJECT_ENDPOINT → FOUNDRY_PROJECT_ENDPOINT
Comment thread
JeffreyCA marked this conversation as resolved.
// AZURE_AI_PROJECT_ID → FOUNDRY_PROJECT_ARM_ID
// AGENT_{SVC}_NAME → FOUNDRY_AGENT_NAME
// AGENT_{SVC}_VERSION → FOUNDRY_AGENT_VERSION
// APPLICATIONINSIGHTS_CONNECTION_STRING (unchanged — already matches platform name)
func appendFoundryEnvVars(env []string, azdEnv map[string]string, serviceName string) []string {
// Static mappings from azd env key names to FOUNDRY_* env var names
staticMappings := []struct {
azdKey string
foundryKey string
}{
{"AZURE_AI_PROJECT_ENDPOINT", "FOUNDRY_PROJECT_ENDPOINT"},
{"AZURE_AI_PROJECT_ID", "FOUNDRY_PROJECT_ARM_ID"},
}

for _, m := range staticMappings {
Comment thread
JeffreyCA marked this conversation as resolved.
if v := azdEnv[m.azdKey]; v != "" {
if _, exists := azdEnv[m.foundryKey]; !exists && !envSliceHasKey(env, m.foundryKey) {
env = append(env, fmt.Sprintf("%s=%s", m.foundryKey, v))
}
}
}

// Service-specific mappings (AGENT_{SVC}_NAME → FOUNDRY_AGENT_NAME, etc.)
if serviceName != "" {
serviceKey := toServiceKey(serviceName)
agentMappings := []struct {
azdKeyFmt string
foundryKey string
}{
{"AGENT_%s_NAME", "FOUNDRY_AGENT_NAME"},
{"AGENT_%s_VERSION", "FOUNDRY_AGENT_VERSION"},
}

for _, m := range agentMappings {
azdKey := fmt.Sprintf(m.azdKeyFmt, serviceKey)
if v := azdEnv[azdKey]; v != "" {
if _, exists := azdEnv[m.foundryKey]; !exists && !envSliceHasKey(env, m.foundryKey) {
env = append(env, fmt.Sprintf("%s=%s", m.foundryKey, v))
}
}
}
Comment thread
JeffreyCA marked this conversation as resolved.
}

return env
}

// envSliceHasKey reports whether the env slice already contains an entry for the given key.
func envSliceHasKey(env []string, key string) bool {
prefix := key + "="
return slices.ContainsFunc(env, func(entry string) bool {
return strings.HasPrefix(entry, prefix)
})
}

// loadAzdEnvironment reads all key-value pairs from the current azd environment.
func loadAzdEnvironment(ctx context.Context, azdClient *azdext.AzdClient) (map[string]string, error) {
envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
Expand Down
139 changes: 139 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
)

Expand Down Expand Up @@ -197,3 +198,141 @@ func createVenv(t *testing.T, projectDir string) string {

return venvDir
}

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

t.Run("maps AZURE_AI_PROJECT_ENDPOINT to FOUNDRY_PROJECT_ENDPOINT", func(t *testing.T) {
t.Parallel()
azdEnv := map[string]string{
"AZURE_AI_PROJECT_ENDPOINT": "https://myaccount.services.ai.azure.com/api/projects/myproject",
}
env := appendFoundryEnvVars(nil, azdEnv, "")
expected := "FOUNDRY_PROJECT_ENDPOINT=https://myaccount.services.ai.azure.com/api/projects/myproject"
if !slices.Contains(env, expected) {
t.Errorf("expected %q in env, got %v", expected, env)
}
})

t.Run("maps AZURE_AI_PROJECT_ID to FOUNDRY_PROJECT_ARM_ID", func(t *testing.T) {
t.Parallel()
azdEnv := map[string]string{
"AZURE_AI_PROJECT_ID": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.CognitiveServices/accounts/acct1/projects/proj1",
}
env := appendFoundryEnvVars(nil, azdEnv, "")
expected := "FOUNDRY_PROJECT_ARM_ID=/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.CognitiveServices/accounts/acct1/projects/proj1"
if !slices.Contains(env, expected) {
t.Errorf("expected %q in env, got %v", expected, env)
}
})

t.Run("maps service-specific agent vars to FOUNDRY_AGENT_*", func(t *testing.T) {
t.Parallel()
azdEnv := map[string]string{
"AGENT_MY_SVC_NAME": "my-agent",
"AGENT_MY_SVC_VERSION": "v3",
}
env := appendFoundryEnvVars(nil, azdEnv, "my-svc")
if !slices.Contains(env, "FOUNDRY_AGENT_NAME=my-agent") {
t.Errorf("expected FOUNDRY_AGENT_NAME=my-agent in env, got %v", env)
}
if !slices.Contains(env, "FOUNDRY_AGENT_VERSION=v3") {
t.Errorf("expected FOUNDRY_AGENT_VERSION=v3 in env, got %v", env)
}
})

t.Run("skips missing values", func(t *testing.T) {
t.Parallel()
azdEnv := map[string]string{}
env := appendFoundryEnvVars(nil, azdEnv, "my-svc")
if len(env) != 0 {
t.Errorf("expected empty env, got %v", env)
}
})

t.Run("includes all mappings together", func(t *testing.T) {
t.Parallel()
azdEnv := map[string]string{
"AZURE_AI_PROJECT_ENDPOINT": "https://acct.services.ai.azure.com/api/projects/proj",
"AZURE_AI_PROJECT_ID": "/subscriptions/sub/rg/rg/acct/proj",
"AGENT_AGENT1_NAME": "agent1",
"AGENT_AGENT1_VERSION": "v1",
}
env := appendFoundryEnvVars(nil, azdEnv, "agent1")
if len(env) != 4 {
t.Errorf("expected 4 env vars, got %d: %v", len(env), env)
}
})

t.Run("skips foundry key when already set in azd env", func(t *testing.T) {
t.Parallel()
azdEnv := map[string]string{
"AZURE_AI_PROJECT_ENDPOINT": "https://from-azd.services.ai.azure.com",
"FOUNDRY_PROJECT_ENDPOINT": "https://explicit.services.ai.azure.com",
"AGENT_MY_SVC_NAME": "my-agent",
"FOUNDRY_AGENT_NAME": "explicit-agent",
}
env := appendFoundryEnvVars(nil, azdEnv, "my-svc")

// Neither FOUNDRY_PROJECT_ENDPOINT nor FOUNDRY_AGENT_NAME should be
// appended because they already exist in azdEnv (and were thus already
// added to the env slice by the caller's loop over azdEnv).
for _, entry := range env {
if strings.HasPrefix(entry, "FOUNDRY_PROJECT_ENDPOINT=") ||
strings.HasPrefix(entry, "FOUNDRY_AGENT_NAME=") {
t.Errorf("should not translate when foundry key already in azdEnv, got %q", entry)
}
}

// AZURE_AI_PROJECT_ID has no explicit FOUNDRY_PROJECT_ARM_ID, so it should still be skipped
// (it's not in azdEnv either, so appendFoundryEnvVars skips it because the source key is empty)
if len(env) != 0 {
t.Errorf("expected no translated env vars, got %v", env)
}
})

t.Run("skips foundry key when already set in process env slice", func(t *testing.T) {
t.Parallel()
// Simulate os.Environ() already containing FOUNDRY_* vars set by the user's shell
existingEnv := []string{
"HOME=/home/user",
"FOUNDRY_PROJECT_ENDPOINT=https://user-shell.services.ai.azure.com",
"FOUNDRY_AGENT_NAME=shell-agent",
}
azdEnv := map[string]string{
"AZURE_AI_PROJECT_ENDPOINT": "https://from-azd.services.ai.azure.com",
"AZURE_AI_PROJECT_ID": "/subscriptions/sub/rg/rg/acct/proj",
"AGENT_MY_SVC_NAME": "my-agent",
"AGENT_MY_SVC_VERSION": "v2",
}
env := appendFoundryEnvVars(existingEnv, azdEnv, "my-svc")

// FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_AGENT_NAME should NOT be appended
// because they already exist in the process env slice.
foundryEndpointCount := 0
foundryAgentNameCount := 0
for _, entry := range env {
if strings.HasPrefix(entry, "FOUNDRY_PROJECT_ENDPOINT=") {
foundryEndpointCount++
}
if strings.HasPrefix(entry, "FOUNDRY_AGENT_NAME=") {
foundryAgentNameCount++
}
}
if foundryEndpointCount != 1 {
t.Errorf("expected exactly 1 FOUNDRY_PROJECT_ENDPOINT entry (from shell), got %d in %v", foundryEndpointCount, env)
}
if foundryAgentNameCount != 1 {
t.Errorf("expected exactly 1 FOUNDRY_AGENT_NAME entry (from shell), got %d in %v", foundryAgentNameCount, env)
}

// FOUNDRY_PROJECT_ARM_ID and FOUNDRY_AGENT_VERSION should still be translated
// since they are NOT already present in the env slice.
if !slices.Contains(env, "FOUNDRY_PROJECT_ARM_ID=/subscriptions/sub/rg/rg/acct/proj") {
t.Errorf("expected FOUNDRY_PROJECT_ARM_ID to be translated, got %v", env)
}
if !slices.Contains(env, "FOUNDRY_AGENT_VERSION=v2") {
t.Errorf("expected FOUNDRY_AGENT_VERSION to be translated, got %v", env)
}
})
}
Loading