-
Notifications
You must be signed in to change notification settings - Fork 288
[Agents extension] Toolbox support #7640
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
02d3b86
ce540de
1c7f433
c594deb
f4a1a12
284d5a2
b238af1
224c27c
9154f4d
3c7b319
9ec878d
af85a8f
2209279
a93e25b
1d3ee25
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ import ( | |
| "net/url" | ||
| "os" | ||
| "path/filepath" | ||
| "regexp" | ||
| "strings" | ||
| "time" | ||
|
|
||
|
|
@@ -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) | ||
|
|
@@ -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) | ||
|
|
@@ -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 { | ||
|
|
@@ -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) | ||
| } | ||
|
|
||
| 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, "_") | ||
| } | ||
trangevi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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] { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| *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, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Connection credentials from |
||
| 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") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
creds := maps.Clone(connResource.Credentials)
conn.Credentials = creds |
||
| } | ||
| } | ||
|
|
||
| connections = append(connections, conn) | ||
| } | ||
|
|
||
| return connections, nil | ||
| } | ||
There was a problem hiding this comment.
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, onlytypeandproject_connection_idsurvive. Other tool-specific fields (name, custom options, etc.) are dropped. The comment says "deploy enriches from connection" butenrichToolboxFromConnectionsonly addsserver_url/server_label. If tools carry additional config (e.g. headers, query parameters, auth scopes), those would be silently lost.