Skip to content
Draft
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
264 changes: 259 additions & 5 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -475,6 +476,18 @@ func (a *InitAction) Run(ctx context.Context) error {
return fmt.Errorf("configuring model choice: %w", err)
}

// Prompt for manifest parameters (e.g. tool credentials) after project selection
agentManifest, err = registry_api.ProcessManifestParameters(
ctx, agentManifest, a.azdClient, a.flags.NoPrompt,
)
if err != nil {
return fmt.Errorf("failed to process manifest parameters: %w", err)
}

// Inject toolbox MCP endpoint env vars into hosted agent definitions
// so agent.yaml is self-documenting about what env vars will be set.
injectToolboxEnvVarsIntoDefinition(agentManifest)

// Write the final agent.yaml to disk (after deployment names have been injected)
if err := writeAgentDefinitionFile(targetDir, agentManifest); err != nil {
return fmt.Errorf("writing agent definition: %w", err)
Expand Down Expand Up @@ -1078,11 +1091,6 @@ func (a *InitAction) downloadAgentYaml(
}
}

agentManifest, err = registry_api.ProcessManifestParameters(ctx, agentManifest, a.azdClient, a.flags.NoPrompt)
if err != nil {
return nil, "", fmt.Errorf("failed to process manifest parameters: %w", err)
}

_, isPromptAgent := agentManifest.Template.(agent_yaml.PromptAgent)
if isPromptAgent {
agentManifest, err = agent_yaml.ProcessPromptAgentToolsConnections(ctx, agentManifest, a.azdClient)
Expand Down Expand Up @@ -1237,6 +1245,33 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa
agentConfig.Deployments = a.deploymentDetails
agentConfig.Resources = resourceDetails

// Process toolbox resources from the manifest
toolboxes, toolConnections, credEnvVars, err := extractToolboxAndConnectionConfigs(agentManifest)
if err != nil {
return err
}
agentConfig.Toolboxes = toolboxes
agentConfig.ToolConnections = toolConnections

// Persist credential values as azd environment variables so they are
// resolved at provision/deploy time instead of stored in azure.yaml.
for envKey, envVal := range credEnvVars {
if _, setErr := a.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{
EnvName: a.environment.Name,
Key: envKey,
Value: envVal,
}); setErr != nil {
return fmt.Errorf("storing credential env var %s: %w", envKey, setErr)
}
}

// Process connection resources from the manifest
connections, err := extractConnectionConfigs(agentManifest)
if err != nil {
return err
}
agentConfig.Connections = connections

// Detect startup command from the project source directory
startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.NoPrompt)
if err != nil {
Expand Down Expand Up @@ -1645,3 +1680,222 @@ func downloadDirectoryContentsWithoutGhCli(

return nil
}

// extractToolboxAndConnectionConfigs extracts toolbox resource definitions from the agent manifest
// and converts them into project.Toolbox config entries and project.ToolConnection entries.
// Tools with a target/authType also produce connection entries for Bicep provisioning.
// Built-in tools (bing_grounding, azure_ai_search, etc.) produce toolbox tools but no connections.
func extractToolboxAndConnectionConfigs(
manifest *agent_yaml.AgentManifest,
) ([]project.Toolbox, []project.ToolConnection, map[string]string, error) {
if manifest == nil || manifest.Resources == nil {
return nil, nil, nil, nil
}

var toolboxes []project.Toolbox
var connections []project.ToolConnection
// credentialEnvVars maps generated env var names to their raw values so
// the caller can persist them in the azd environment.
credentialEnvVars := map[string]string{}

for _, resource := range manifest.Resources {
tbResource, ok := resource.(agent_yaml.ToolboxResource)
if !ok {
continue
}

description := tbResource.Description

if len(tbResource.Tools) == 0 {
return nil, nil, nil, fmt.Errorf(
"toolbox resource '%s' is missing required 'tools'",
tbResource.Name,
)
}

var tools []map[string]any
for _, rawTool := range tbResource.Tools {
toolMap, ok := rawTool.(map[string]any)
if !ok {
return nil, nil, nil, fmt.Errorf(
"toolbox resource '%s' has invalid tool entry: expected object",
tbResource.Name,
)
}

// Manifest and API both use "type" for tool kind
toolType, _ := toolMap["type"].(string)

target, _ := toolMap["target"].(string)
if target == "" {
// No target — either a built-in tool or a pre-configured tool
// that already has project_connection_id. Pass through as-is.
result := make(map[string]any, len(toolMap))
for k, v := range toolMap {
result[k] = v
}
tools = append(tools, result)
continue
}

// External tools with target/authType need a connection
toolName, _ := toolMap["name"].(string)
authType, _ := toolMap["authType"].(string)
credentials, _ := toolMap["credentials"].(map[string]any)

connName := toolName
if connName == "" {
connName = tbResource.Name + "-" + toolType
}

conn := project.ToolConnection{
Name: connName,
Category: "RemoteTool",
Target: target,
AuthType: authType,
}

// Extract credentials, storing raw values as env vars and
// replacing them with ${VAR} references in the config.
if len(credentials) > 0 {
creds := make(map[string]any, len(credentials))
for k, v := range credentials {
envVar := credentialEnvVarName(connName, k)
credentialEnvVars[envVar] = fmt.Sprintf("%v", v)
creds[k] = fmt.Sprintf("${%s}", envVar)
}

// CustomKeys ARM type requires credentials nested under "keys"
if authType == "CustomKeys" {
conn.Credentials = map[string]any{"keys": creds}
} else {
conn.Credentials = creds
}
}

connections = append(connections, conn)

// Toolbox tool entry is minimal — deploy enriches from connection
tool := map[string]any{
"type": toolType,
"project_connection_id": connName,
}
tools = append(tools, tool)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For tools with a target, only type and project_connection_id survive. Other tool-specific fields (name, custom options, etc.) are dropped. The comment says "deploy enriches from connection" but enrichToolboxFromConnections only adds server_url/server_label. If tools carry additional config (e.g. headers, query parameters, auth scopes), those would be silently lost.

toolboxes = append(toolboxes, project.Toolbox{
Name: tbResource.Name,
Description: description,
Tools: tools,
})
}

return toolboxes, connections, credentialEnvVars, nil
}

// credentialEnvVarName builds a deterministic env var name for a connection
// credential key, e.g. ("github-copilot", "clientSecret") → "TOOL_GITHUB_COPILOT_CLIENTSECRET".
// All non-alphanumeric characters are replaced with underscores and consecutive
// underscores are collapsed to produce a valid [A-Z0-9_]+ environment variable name.
var nonAlphanumRe = regexp.MustCompile(`[^A-Z0-9]+`)

func credentialEnvVarName(connName, key string) string {
s := "TOOL_" + strings.ToUpper(connName) + "_" + strings.ToUpper(key)
return nonAlphanumRe.ReplaceAllString(s, "_")
}

// injectToolboxEnvVarsIntoDefinition adds TOOLBOX_{NAME}_MCP_ENDPOINT entries
// to the environment_variables section of a hosted agent definition for each toolbox
// resource in the manifest. Entries already present in the definition are not overwritten.
func injectToolboxEnvVarsIntoDefinition(manifest *agent_yaml.AgentManifest) {
if manifest == nil || manifest.Resources == nil {
return
}

containerAgent, ok := manifest.Template.(agent_yaml.ContainerAgent)
if !ok {
return
}

// Collect toolbox resource names
var toolboxNames []string
for _, resource := range manifest.Resources {
if tbResource, ok := resource.(agent_yaml.ToolboxResource); ok {
toolboxNames = append(toolboxNames, tbResource.Name)
}
}
if len(toolboxNames) == 0 {
return
}

if containerAgent.EnvironmentVariables == nil {
envVars := []agent_yaml.EnvironmentVariable{}
containerAgent.EnvironmentVariables = &envVars
}

existingNames := make(map[string]bool, len(*containerAgent.EnvironmentVariables))
for _, ev := range *containerAgent.EnvironmentVariables {
existingNames[ev.Name] = true
}

for _, tbName := range toolboxNames {
envKey := toolboxMCPEndpointEnvKey(tbName)
if !existingNames[envKey] {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

existingNames isn't updated after appending. If two toolboxes normalize to the same env key (e.g. "my-tools" and "my tools" both become TOOLBOX_MY_TOOLS_MCP_ENDPOINT), duplicates get injected. Add existingNames[envKey] = true after the append.

*containerAgent.EnvironmentVariables = append(
*containerAgent.EnvironmentVariables,
agent_yaml.EnvironmentVariable{
Name: envKey,
Value: fmt.Sprintf("${%s}", envKey),
},
)
}
}

manifest.Template = containerAgent
}

// extractConnectionConfigs extracts connection resource definitions from the agent manifest
// and converts them into project.Connection config entries.
func extractConnectionConfigs(manifest *agent_yaml.AgentManifest) ([]project.Connection, error) {
if manifest == nil || manifest.Resources == nil {
return nil, nil
}

var connections []project.Connection

for _, resource := range manifest.Resources {
connResource, ok := resource.(agent_yaml.ConnectionResource)
if !ok {
continue
}

conn := project.Connection{
Name: connResource.Name,
Category: string(connResource.Category),
Target: connResource.Target,
AuthType: string(connResource.AuthType),
Credentials: connResource.Credentials,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connection credentials from ConnectionResource are copied directly into project.Connection, which gets serialized to azure.yaml in plaintext. Compare with toolbox tool credentials (line ~1770) which are externalized to env vars via credentialEnvVars and replaced with variable references. Consider the same externalization pattern for connection credentials to avoid secrets in config files.

Metadata: connResource.Metadata,
ExpiryTime: connResource.ExpiryTime,
IsSharedToAll: connResource.IsSharedToAll,
SharedUserList: connResource.SharedUserList,
PeRequirement: connResource.PeRequirement,
PeStatus: connResource.PeStatus,
UseWorkspaceManagedIdentity: connResource.UseWorkspaceManagedIdentity,
Error: connResource.Error,
}

// Surface credentials.type to top-level authType when not explicitly set.
// The API expects authType at the connection level, not nested in credentials.
if conn.AuthType == "" {
if credType, ok := conn.Credentials["type"].(string); ok && credType != "" {
conn.AuthType = credType
delete(conn.Credentials, "type")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conn.Credentials is assigned from connResource.Credentials (shared map reference at line 1877). This delete mutates the manifest's original map. If the manifest is referenced again after this call (e.g. written to disk, logged), the type field will be silently missing. Clone before mutating:

creds := maps.Clone(connResource.Credentials)
conn.Credentials = creds

}
}

connections = append(connections, conn)
}

return connections, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -848,13 +848,13 @@ type protocolInfo struct {

// knownProtocols lists the protocols offered during init, in display order.
var knownProtocols = []protocolInfo{
{Name: "responses", Version: "v1"},
{Name: "invocations", Version: "v0.0.1"},
{Name: "responses", Version: "1.0.0"},
{Name: "invocations", Version: "1.0.0"},
}

// promptProtocols asks the user which protocols their agent supports.
// When flagProtocols is non-empty the prompt is skipped and those values are used directly.
// When noPrompt is true and no flag values are provided, defaults to [responses/v1].
// When noPrompt is true and no flag values are provided, defaults to [responses/1.0.0].
func promptProtocols(
ctx context.Context,
promptClient azdext.PromptServiceClient,
Expand Down Expand Up @@ -897,7 +897,7 @@ func promptProtocols(
// Non-interactive mode: default to responses.
if noPrompt {
return []agent_yaml.ProtocolVersionRecord{
{Protocol: "responses", Version: "v1"},
{Protocol: "responses", Version: "1.0.0"},
}, nil
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ func TestWriteDefinitionToSrcDir(t *testing.T) {
Kind: agent_yaml.AgentKindHosted,
},
Protocols: []agent_yaml.ProtocolVersionRecord{
{Protocol: "responses", Version: "v1"},
{Protocol: "responses", Version: "1.0.0"},
},
EnvironmentVariables: &[]agent_yaml.EnvironmentVariable{
{Name: "AZURE_OPENAI_ENDPOINT", Value: "${AZURE_OPENAI_ENDPOINT}"},
Expand Down Expand Up @@ -601,22 +601,22 @@ func TestPromptProtocols_FlagValues(t *testing.T) {
name: "responses only",
flagProtocols: []string{"responses"},
wantProtocols: []agent_yaml.ProtocolVersionRecord{
{Protocol: "responses", Version: "v1"},
{Protocol: "responses", Version: "1.0.0"},
},
},
{
name: "invocations only",
flagProtocols: []string{"invocations"},
wantProtocols: []agent_yaml.ProtocolVersionRecord{
{Protocol: "invocations", Version: "v0.0.1"},
{Protocol: "invocations", Version: "1.0.0"},
},
},
{
name: "both protocols",
flagProtocols: []string{"responses", "invocations"},
wantProtocols: []agent_yaml.ProtocolVersionRecord{
{Protocol: "responses", Version: "v1"},
{Protocol: "invocations", Version: "v0.0.1"},
{Protocol: "responses", Version: "1.0.0"},
{Protocol: "invocations", Version: "1.0.0"},
},
},
{
Expand All @@ -629,8 +629,8 @@ func TestPromptProtocols_FlagValues(t *testing.T) {
name: "duplicates are removed",
flagProtocols: []string{"responses", "responses", "invocations"},
wantProtocols: []agent_yaml.ProtocolVersionRecord{
{Protocol: "responses", Version: "v1"},
{Protocol: "invocations", Version: "v0.0.1"},
{Protocol: "responses", Version: "1.0.0"},
{Protocol: "invocations", Version: "1.0.0"},
},
},
}
Expand Down Expand Up @@ -680,8 +680,8 @@ func TestPromptProtocols_NoPromptDefault(t *testing.T) {
if got[0].Protocol != "responses" {
t.Errorf("protocol = %q, want %q", got[0].Protocol, "responses")
}
if got[0].Version != "v1" {
t.Errorf("version = %q, want %q", got[0].Version, "v1")
if got[0].Version != "1.0.0" {
t.Errorf("version = %q, want %q", got[0].Version, "1.0.0")
}
}

Expand Down Expand Up @@ -736,8 +736,8 @@ func TestPromptProtocols_Interactive(t *testing.T) {
}, nil
},
wantProtocols: []agent_yaml.ProtocolVersionRecord{
{Protocol: "responses", Version: "v1"},
{Protocol: "invocations", Version: "v0.0.1"},
{Protocol: "responses", Version: "1.0.0"},
{Protocol: "invocations", Version: "1.0.0"},
},
},
{
Expand All @@ -751,7 +751,7 @@ func TestPromptProtocols_Interactive(t *testing.T) {
}, nil
},
wantProtocols: []agent_yaml.ProtocolVersionRecord{
{Protocol: "responses", Version: "v1"},
{Protocol: "responses", Version: "1.0.0"},
},
},
{
Expand Down
Loading