Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
02d3b86
Maybe working changes for tools
trangevi Mar 24, 2026
ce540de
Attempting to handle new yaml
trangevi Apr 8, 2026
1c7f433
Clean up dead/duplicated code after toolbox merge
trangevi Apr 8, 2026
c594deb
Missing test file
trangevi Apr 8, 2026
f4a1a12
Some more fixes
trangevi Apr 8, 2026
284d5a2
Merge branch 'main' into trangevi/toolbox
trangevi Apr 8, 2026
b238af1
Fix merge
trangevi Apr 8, 2026
224c27c
More fixes
trangevi Apr 8, 2026
9154f4d
Add connection resource type for provisioning
trangevi Apr 8, 2026
3c7b319
Move to /toolboxes api
trangevi Apr 10, 2026
9ec878d
Add version
trangevi Apr 10, 2026
af85a8f
Add unit tests and testdata for azure.ai.agents extension (#7634)
glharper Apr 10, 2026
2209279
Revert "Add unit tests and testdata for azure.ai.agents extension (#7…
trangevi Apr 10, 2026
a93e25b
PR comments part 1
trangevi Apr 10, 2026
1d3ee25
Update feature header
trangevi Apr 11, 2026
2e0b078
PR comment
trangevi Apr 13, 2026
8f6f5b0
fix(agents): add OAuth URL, audience, connectorName fields to Connect…
lindazqli Apr 13, 2026
b26dc14
PR comments
trangevi Apr 13, 2026
9e88d3a
Merge branch 'trangevi/toolbox' of https://github.com/Azure/azure-dev…
trangevi Apr 13, 2026
5f2d943
Remove credential from azure.yaml file
trangevi Apr 13, 2026
12a8d0e
Update azure.ai.agent.json
lindazqli Apr 13, 2026
d650891
Update azure.ai.agent.json (#7696)
lindazqli Apr 13, 2026
2787429
Merge branch 'main' into trangevi/toolbox
trangevi Apr 13, 2026
89213c9
Fix json
trangevi Apr 13, 2026
d2fab5b
Apply suggestions from code review
trangevi Apr 13, 2026
c8cfba4
Test update
trangevi Apr 14, 2026
7a267c4
Merge branch 'trangevi/toolbox' of https://github.com/Azure/azure-dev…
trangevi Apr 14, 2026
f8db5e0
PR comments
trangevi Apr 14, 2026
e9256c8
A couple of build failure fixes
trangevi Apr 14, 2026
f1db2d9
modernization fix
trangevi Apr 14, 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
6 changes: 6 additions & 0 deletions cli/azd/extensions/azure.ai.agents/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ words:
- uaenorth
- uksouth
# Project terms
- ADLS
- agentserver
- aiservices
- Alphanum
- anonymousconnection
- aoai
- azdaiagent
- CLIENTSECRET
- curr
- dataagent
- exterrors
Expand All @@ -38,9 +41,12 @@ words:
- mcpservertoolneverrequireapprovalmode
- mcpservertoolspecifyapprovalmode
- myregistry
- normalises
- openapitool
- posixpath
- projectpkg
- protocolversionrecord
- Qdrant
- Toolsets
- Vnext
- webp
327 changes: 322 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 @@ -11,10 +11,12 @@ import (
"fmt"
"io"
"log"
"maps"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -515,6 +517,20 @@ func (a *InitAction) Run(ctx context.Context) error {
agentManifest.Template = hostedAgent
}

// 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.
if err := injectToolboxEnvVarsIntoDefinition(agentManifest); err != nil {
return fmt.Errorf("injecting toolbox env vars: %w", err)
}

// 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 @@ -1356,11 +1372,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 @@ -1511,6 +1522,44 @@ 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, connCredEnvVars, err := extractConnectionConfigs(agentManifest)
if err != nil {
return err
}
agentConfig.Connections = connections

// Store connection credential env vars alongside toolbox ones
for envKey, envVal := range connCredEnvVars {
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)
}
}

// 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 @@ -2148,3 +2197,271 @@ 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 i, 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))
maps.Copy(result, toolMap)
tools = append(tools, result)
continue
}

if toolType == "" {
return nil, nil, nil, fmt.Errorf(
"toolbox resource '%s': external tool at index %d has a 'target' but no 'type'",
tbResource.Name, i,
)
}

// 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 = fmt.Sprintf("%s-%s-%d", tbResource.Name, toolType, i)
}

conn := project.ToolConnection{
Name: connName,
Category: "RemoteTool",
Target: target,
AuthType: authType,
}
Comment thread
trangevi marked this conversation as resolved.

// Extract credentials, storing raw values as env vars and
// replacing them with ${VAR} references in the config.
if len(credentials) > 0 {
conn.Credentials = externalizeCredentials(
credentials, []string{connName}, credentialEnvVars,
)
}

connections = append(connections, conn)

// Preserve all tool fields, replacing consumed connection fields
// with the project_connection_id reference.
tool := make(map[string]any, len(toolMap))
maps.Copy(tool, toolMap)
tool["type"] = toolType
tool["project_connection_id"] = connName
delete(tool, "target")
delete(tool, "authType")
delete(tool, "credentials")
tools = append(tools, tool)
}

Comment thread
trangevi marked this conversation as resolved.
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") → "PARAM_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(parts ...string) string {
s := "PARAM_" + strings.ToUpper(strings.Join(parts, "_"))
return nonAlphanumRe.ReplaceAllString(s, "_")
}

// externalizeCredentials recursively walks a credential map. String leaf values
// are stored as env vars and replaced with ${VAR} references. Nested maps are
// preserved structurally. keyPath accumulates segments for the env var name.
func externalizeCredentials(
creds map[string]any,
keyPath []string,
envVars map[string]string,
) map[string]any {
result := make(map[string]any, len(creds))
for k, v := range creds {
path := append(keyPath, k)
switch val := v.(type) {
case map[string]any:
result[k] = externalizeCredentials(val, path, envVars)
default:
envName := credentialEnvVarName(path...)
envVars[envName] = fmt.Sprintf("%v", val)
result[k] = fmt.Sprintf("${%s}", envName)
}
}
return result
}

// injectToolboxEnvVarsIntoDefinition adds TOOLBOX_{NAME}_MCP_ENDPOINT entries
// to the environment_variables section of a hosted agent definition for each toolbox
// resource in the manifest. Returns an error if two toolboxes produce the same
// environment variable name or if the key already exists in the definition.
func injectToolboxEnvVarsIntoDefinition(manifest *agent_yaml.AgentManifest) error {
if manifest == nil || manifest.Resources == nil {
return nil
}

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

// 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 nil
}

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] {
return fmt.Errorf(
"duplicate toolbox environment variable %q (from toolbox %q)",
envKey, tbName,
)
}
existingNames[envKey] = true
*containerAgent.EnvironmentVariables = append(
*containerAgent.EnvironmentVariables,
agent_yaml.EnvironmentVariable{
Name: envKey,
Value: fmt.Sprintf("${%s}", envKey),
},
)
}

manifest.Template = containerAgent
return nil
}

// extractConnectionConfigs extracts connection resource definitions from the agent manifest
// and converts them into project.Connection config entries. Credential values are externalized
// to environment variables and replaced with ${VAR} references in the returned connections.
func extractConnectionConfigs(
manifest *agent_yaml.AgentManifest,
) ([]project.Connection, map[string]string, error) {
if manifest == nil || manifest.Resources == nil {
return nil, nil, nil
}

var connections []project.Connection
credentialEnvVars := map[string]string{}

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

creds := maps.Clone(connResource.Credentials)
authType := string(connResource.AuthType)

// Surface credentials.type to top-level authType when not explicitly set.
// This must happen before externalization so we capture the raw value.
if authType == "" && len(creds) > 0 {
if credType, ok := creds["type"].(string); ok && credType != "" {
authType = credType
delete(creds, "type")
}
}

// Externalize credential values to env vars and replace with ${VAR} references.
if len(creds) > 0 {
creds = externalizeCredentials(
creds, []string{connResource.Name}, credentialEnvVars,
)
}

conn := project.Connection{
Name: connResource.Name,
Category: string(connResource.Category),
Target: connResource.Target,
AuthType: authType,
Credentials: creds,
Metadata: connResource.Metadata,
ExpiryTime: connResource.ExpiryTime,
IsSharedToAll: connResource.IsSharedToAll,
SharedUserList: connResource.SharedUserList,
PeRequirement: connResource.PeRequirement,
PeStatus: connResource.PeStatus,
UseWorkspaceManagedIdentity: connResource.UseWorkspaceManagedIdentity,
Error: connResource.Error,
AuthorizationUrl: connResource.AuthorizationUrl,
TokenUrl: connResource.TokenUrl,
RefreshUrl: connResource.RefreshUrl,
Scopes: connResource.Scopes,
Audience: connResource.Audience,
ConnectorName: connResource.ConnectorName,
}

connections = append(connections, conn)
}

return connections, credentialEnvVars, nil
}
Loading
Loading