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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"fmt"

"azureaiagent/internal/exterrors"
"azureaiagent/internal/pkg/agents/agent_api"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
Expand Down Expand Up @@ -87,10 +88,11 @@ func resolveAgentEndpoint(ctx context.Context, accountName string, projectName s
Key: "AZURE_AI_PROJECT_ENDPOINT",
})
if err != nil || envValue.Value == "" {
return "", fmt.Errorf(
"AZURE_AI_PROJECT_ENDPOINT not found in azd environment '%s'\n\n"+
"Provide --account-name and --project-name flags, "+
"or run 'azd ai agent init' to configure the endpoint", envResponse.Environment.Name)
return "", exterrors.Dependency(
exterrors.CodeMissingAiProjectEndpoint,
fmt.Sprintf("AZURE_AI_PROJECT_ENDPOINT not found in azd environment '%s'", envResponse.Environment.Name),
"run 'azd provision' to provision your Azure resources and set the endpoint",
)
}

return envValue.Value, nil
Expand Down
117 changes: 95 additions & 22 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,12 @@ func (a *InitAction) Run(ctx context.Context) error {
return fmt.Errorf("configuring model choice: %w", err)
}

// Inject manifest resources (tool/toolbox) into the ContainerAgent tools section.
// The AgentSchema doesn't yet include tools on ContainerAgent, so resources
// defined at the manifest level need to be projected into the template so they
// are persisted in the local agent.yaml.
injectResourcesAsTools(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 @@ -1059,6 +1065,53 @@ func (a *InitAction) downloadAgentYaml(
return agentManifest, targetDir, nil
}

// injectResourcesAsTools projects manifest-level tool and toolbox resources into the
// ContainerAgent.Tools field so they are written to the local agent.yaml. This is a
// workaround until the AgentSchema adds native tools support on ContainerAgent.
func injectResourcesAsTools(manifest *agent_yaml.AgentManifest) {
if len(manifest.Resources) == 0 {
return
}

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

var tools []any
if containerAgent.Tools != nil {
tools = *containerAgent.Tools
}

for _, resource := range manifest.Resources {
switch res := resource.(type) {
case agent_yaml.ToolboxResource:
entry := map[string]any{
"kind": string(agent_yaml.ResourceKindToolbox),
"id": res.Id,
}
if res.Options != nil {
entry["options"] = res.Options
}
tools = append(tools, entry)
case agent_yaml.ToolResource:
entry := map[string]any{
"kind": string(agent_yaml.ResourceKindTool),
"id": res.Id,
}
if res.Options != nil {
entry["options"] = res.Options
}
tools = append(tools, entry)
}
}

if len(tools) > 0 {
containerAgent.Tools = &tools
manifest.Template = containerAgent
}
}

// writeAgentDefinitionFile writes the agent definition to disk as agent.yaml in targetDir.
// This should be called after all parameter/deployment injection is complete so the on-disk
// file has fully resolved values (no `{{...}}` placeholders).
Expand Down Expand Up @@ -1120,35 +1173,54 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa
var agentConfig = project.ServiceTargetAgentConfig{}

resourceDetails := []project.Resource{}
toolboxDetails := []project.Toolbox{}
switch agentDef.Kind {
case agent_yaml.AgentKindHosted:
// Handle tool resources that require connection names
// Handle tool resources that require connection names or toolbox dependencies
if agentManifest.Resources != nil {
for _, resource := range agentManifest.Resources {
// Try to cast to ToolResource
if toolResource, ok := resource.(agent_yaml.ToolResource); ok {
// Check if this is a resource that requires a connection name
if toolResource.Id == "bing_grounding" || toolResource.Id == "azure_ai_search" {
// Prompt the user for a connection name
resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{
Options: &azdext.PromptOptions{
Message: fmt.Sprintf("Enter a connection name for adding the resource %s to your Microsoft Foundry project", toolResource.Id),
IgnoreHintKeys: true,
DefaultValue: toolResource.Id,
},
})
if err != nil {
return fmt.Errorf("prompting for connection name for %s: %w", toolResource.Id, err)
}
switch res := resource.(type) {
case agent_yaml.ToolResource:
// Prompt the user for a connection name
resp, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{
Options: &azdext.PromptOptions{
Message: fmt.Sprintf("Enter a connection name for adding the resource %s to your Microsoft Foundry project", res.Id),
IgnoreHintKeys: true,
DefaultValue: res.Id,
},
})
if err != nil {
return fmt.Errorf("prompting for connection name for %s: %w", res.Id, err)
}

// Add to resource details
resourceDetails = append(resourceDetails, project.Resource{
Resource: toolResource.Id,
ConnectionName: resp.Value,
})
resourceDetails = append(resourceDetails, project.Resource{
Resource: res.Id,
ConnectionName: resp.Value,
})

case agent_yaml.ToolboxResource:
toolbox := project.Toolbox{
Name: res.Id,
}

if res.Options != nil {
if desc, ok := res.Options["description"].(string); ok {
toolbox.Description = desc
}
if tools, ok := res.Options["tools"].([]any); ok {
for _, t := range tools {
toolJSON, err := json.Marshal(t)
if err != nil {
return fmt.Errorf("failed to marshal tool definition for toolbox %s: %w",
res.Id, err)
}
toolbox.Tools = append(toolbox.Tools, toolJSON)
}
}
}

toolboxDetails = append(toolboxDetails, toolbox)
}
// Skip the resource if the cast fails
}
}

Expand All @@ -1162,6 +1234,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa

agentConfig.Deployments = a.deploymentDetails
agentConfig.Resources = resourceDetails
agentConfig.Toolboxes = toolboxDetails

// Detect startup command from the project source directory
startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.NoPrompt)
Expand Down
89 changes: 89 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 @@ -9,6 +9,8 @@ import (
"path/filepath"
"testing"

"azureaiagent/internal/pkg/agents/agent_yaml"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
Expand Down Expand Up @@ -394,3 +396,90 @@ func TestParseGitHubUrlNaive(t *testing.T) {
})
}
}

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

t.Run("toolbox resources become tools", func(t *testing.T) {
manifest := &agent_yaml.AgentManifest{
Template: agent_yaml.ContainerAgent{
AgentDefinition: agent_yaml.AgentDefinition{
Kind: agent_yaml.AgentKindHosted,
Name: "test-agent",
},
Protocols: []agent_yaml.ProtocolVersionRecord{
{Protocol: "responses", Version: "v1"},
},
},
Resources: []any{
agent_yaml.ToolboxResource{
Resource: agent_yaml.Resource{Kind: agent_yaml.ResourceKindToolbox},
Id: "echo-github-toolset",
Options: map[string]any{
"description": "GitHub tools",
},
},
},
}

injectResourcesAsTools(manifest)

ca := manifest.Template.(agent_yaml.ContainerAgent)
if ca.Tools == nil {
t.Fatal("Expected tools to be injected, got nil")
}
if len(*ca.Tools) != 1 {
t.Fatalf("Expected 1 tool, got %d", len(*ca.Tools))
}

tool := (*ca.Tools)[0].(map[string]any)
if tool["kind"] != "toolbox" {
t.Errorf("Expected kind 'toolbox', got %v", tool["kind"])
}
if tool["id"] != "echo-github-toolset" {
t.Errorf("Expected id 'echo-github-toolset', got %v", tool["id"])
}
opts := tool["options"].(map[string]any)
if opts["description"] != "GitHub tools" {
t.Errorf("Expected description 'GitHub tools', got %v", opts["description"])
}
})

t.Run("no resources is a no-op", func(t *testing.T) {
manifest := &agent_yaml.AgentManifest{
Template: agent_yaml.ContainerAgent{
AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindHosted, Name: "a"},
},
}

injectResourcesAsTools(manifest)

ca := manifest.Template.(agent_yaml.ContainerAgent)
if ca.Tools != nil {
t.Errorf("Expected nil tools, got %v", ca.Tools)
}
})

t.Run("preserves existing template tools", func(t *testing.T) {
existing := []any{map[string]any{"kind": "mcp", "id": "existing-tool"}}
manifest := &agent_yaml.AgentManifest{
Template: agent_yaml.ContainerAgent{
AgentDefinition: agent_yaml.AgentDefinition{Kind: agent_yaml.AgentKindHosted, Name: "a"},
Tools: &existing,
},
Resources: []any{
agent_yaml.ToolboxResource{
Resource: agent_yaml.Resource{Kind: agent_yaml.ResourceKindToolbox},
Id: "new-toolbox",
},
},
}

injectResourcesAsTools(manifest)

ca := manifest.Template.(agent_yaml.ContainerAgent)
if len(*ca.Tools) != 2 {
t.Fatalf("Expected 2 tools (1 existing + 1 injected), got %d", len(*ca.Tools))
}
})
}
32 changes: 32 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ func envUpdate(ctx context.Context, azdClient *azdext.AzdClient, azdProject *azd
}
}

if len(foundryAgentConfig.Toolboxes) > 0 {
if err := toolboxEnvUpdate(ctx, foundryAgentConfig.Toolboxes, azdClient, currentEnvResponse.Environment.Name); err != nil {
return err
}
}

return nil
}

Expand Down Expand Up @@ -219,6 +225,32 @@ func resourcesEnvUpdate(ctx context.Context, resources []project.Resource, azdCl
return setEnvVar(ctx, azdClient, envName, "AI_PROJECT_DEPENDENT_RESOURCES", escapedJsonString)
}

func toolboxEnvUpdate(ctx context.Context, toolboxes []project.Toolbox, azdClient *azdext.AzdClient, envName string) error {
// Resolve the project endpoint to construct MCP URLs
envResp, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
EnvName: envName,
Key: "AZURE_AI_PROJECT_ENDPOINT",
})
if err != nil {
return fmt.Errorf("failed to get AZURE_AI_PROJECT_ENDPOINT: %w", err)
}

projectEndpoint := envResp.Value
if projectEndpoint == "" {
return fmt.Errorf("AZURE_AI_PROJECT_ENDPOINT not set; required to resolve toolbox MCP endpoints")
}

for _, tb := range toolboxes {
mcpEndpoint := project.ToolboxMcpEndpoint(projectEndpoint, tb.Name)
envVar := project.ToolboxEnvVar(tb.Name)
if err := setEnvVar(ctx, azdClient, envName, envVar, mcpEndpoint); err != nil {
return err
}
}

return nil
}

func containerAgentHandling(ctx context.Context, azdClient *azdext.AzdClient, project *azdext.ProjectConfig, svc *azdext.ServiceConfig) error {
servicePath := svc.RelativePath
fullPath := filepath.Join(project.Path, servicePath)
Expand Down
1 change: 1 addition & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ func NewRootCommand() *cobra.Command {
rootCmd.AddCommand(newShowCommand())
rootCmd.AddCommand(newMonitorCommand())
rootCmd.AddCommand(newFilesCommand())
rootCmd.AddCommand(newToolboxCommand())

return rootCmd
}
26 changes: 26 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"github.com/spf13/cobra"
)

func newToolboxCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "toolbox",
Short: "Manage Foundry toolboxes.",
Long: `Manage Foundry toolboxes in the current Azure AI Foundry project.

Toolboxes are named collections of tools (MCP servers, OpenAPI endpoints, first-party tools)
exposed through a unified MCP-compatible endpoint with platform-managed auth.`,
}

cmd.AddCommand(newToolboxListCommand())
cmd.AddCommand(newToolboxShowCommand())
cmd.AddCommand(newToolboxCreateCommand())
cmd.AddCommand(newToolboxDeleteCommand())

return cmd
}
Loading
Loading