diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go index f2433f04d85..d07951f79cb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go @@ -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" @@ -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 diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index e4fbf460ae2..639e02b8ea3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -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) @@ -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). @@ -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 } } @@ -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) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index 6fb935235a3..c391f3c44c4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -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" @@ -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)) + } + }) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index a4f8b5ecdee..b6d30e767cc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -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 } @@ -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) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index ecd9aee52c9..f7aa88642a4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -78,6 +78,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newShowCommand()) rootCmd.AddCommand(newMonitorCommand()) rootCmd.AddCommand(newFilesCommand()) + rootCmd.AddCommand(newToolboxCommand()) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go new file mode 100644 index 00000000000..ec52549fc26 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go @@ -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 +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go new file mode 100644 index 00000000000..4e4a478d68a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_create.go @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/project" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/spf13/cobra" +) + +func newToolboxCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a toolbox in the Foundry project.", + Long: `Create a new toolbox from a JSON payload file. + +The payload file must contain a JSON object with at least "name" and "tools" fields. +If a toolbox with the same name already exists, you will be prompted to confirm +before overwriting (use --no-prompt to auto-confirm).`, + Example: ` # Create a toolbox from a JSON file + azd ai agent toolbox create toolbox.json + + # Create with auto-confirm for scripting + azd ai agent toolbox create toolbox.json --no-prompt`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + if len(args) == 0 { + return exterrors.Validation( + exterrors.CodeInvalidToolboxPayload, + "missing required payload file path", + "Provide a path to a toolbox JSON file, for example:\n"+ + " azd ai agent toolbox create path/to/toolbox.json", + ) + } + + payloadPath := args[0] + + // Read and parse the payload file + data, err := os.ReadFile(payloadPath) //nolint:gosec // G304: path is from user CLI arg, validated below + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidToolboxPayload, + fmt.Sprintf("failed to read payload file '%s': %s", payloadPath, err), + "Check that the file path is correct and the file is readable", + ) + } + + var createReq agent_api.CreateToolboxRequest + if err := json.Unmarshal(data, &createReq); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidToolboxPayload, + fmt.Sprintf("failed to parse payload file '%s': %s", payloadPath, err), + "Ensure the file contains valid JSON with 'name' and 'tools' fields", + ) + } + + if createReq.Name == "" { + return exterrors.Validation( + exterrors.CodeInvalidToolboxPayload, + "toolbox payload is missing required 'name' field", + "Add a 'name' field to the JSON payload", + ) + } + if len(createReq.Tools) == 0 { + return exterrors.Validation( + exterrors.CodeInvalidToolboxPayload, + "toolbox payload is missing required 'tools' field or tools array is empty", + "Add a 'tools' array with at least one tool definition", + ) + } + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } + + credential, err := newAgentCredential() + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create credential: %s", err), + "Run 'azd auth login' to authenticate", + ) + } + + client := agent_api.NewAgentClient(endpoint, credential) + + // Check if toolbox already exists + existing, err := client.GetToolbox(ctx, createReq.Name, agent_api.ToolboxAPIVersion) + if err == nil && existing != nil { + // Toolbox exists — prompt for overwrite confirmation + if !rootFlags.NoPrompt { + azdClient, azdErr := azdext.NewAzdClient() + if azdErr != nil { + return fmt.Errorf("failed to create azd client for prompting: %w", azdErr) + } + defer azdClient.Close() + + resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf( + "Toolbox '%s' already exists with %d tool(s). Overwrite?", + existing.Name, len(existing.Tools), + ), + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("toolbox creation cancelled") + } + return fmt.Errorf("failed to prompt for confirmation: %w", promptErr) + } + if resp == nil || resp.Value == nil || !*resp.Value { + fmt.Println("toolbox creation cancelled.") + return nil + } + } + + // Update the existing toolbox + updateReq := &agent_api.UpdateToolboxRequest{ + Description: createReq.Description, + Metadata: createReq.Metadata, + Tools: createReq.Tools, + } + + toolbox, updateErr := client.UpdateToolbox(ctx, createReq.Name, updateReq, agent_api.ToolboxAPIVersion) + if updateErr != nil { + return exterrors.ServiceFromAzure(updateErr, exterrors.OpUpdateToolbox) + } + + mcpEndpoint := project.ToolboxMcpEndpoint(endpoint, toolbox.Name) + fmt.Printf("Toolbox '%s' updated successfully (%d tool(s)).\n", toolbox.Name, len(toolbox.Tools)) + fmt.Printf("MCP Endpoint: %s\n", mcpEndpoint) + printMcpEnvTip(toolbox.Name, mcpEndpoint) + return nil + } + + // Check if the error is a 404 (not found) — proceed with create + var respErr *azcore.ResponseError + if err != nil && !(errors.As(err, &respErr) && respErr.StatusCode == 404) { + return exterrors.ServiceFromAzure(err, exterrors.OpGetToolbox) + } + + // Create new toolbox + toolbox, createErr := client.CreateToolbox(ctx, &createReq, agent_api.ToolboxAPIVersion) + if createErr != nil { + return exterrors.ServiceFromAzure(createErr, exterrors.OpCreateToolbox) + } + + mcpEndpoint := project.ToolboxMcpEndpoint(endpoint, toolbox.Name) + fmt.Printf("Toolbox '%s' created successfully (%d tool(s)).\n", toolbox.Name, len(toolbox.Tools)) + fmt.Printf("MCP Endpoint: %s\n", mcpEndpoint) + printMcpEnvTip(toolbox.Name, mcpEndpoint) + return nil + }, + } + + return cmd +} + +func printMcpEnvTip(toolboxName, mcpEndpoint string) { + envVar := project.ToolboxEnvVar(toolboxName) + fmt.Println() + fmt.Println(output.WithHintFormat( + "Hint: Store the endpoint in your azd environment so your agent code can reference it:")) + fmt.Printf(" %s\n", output.WithHighLightFormat( + "azd env set %s %s", envVar, mcpEndpoint)) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go new file mode 100644 index 00000000000..10e08370cb3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_delete.go @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "fmt" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +func newToolboxDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a toolbox from the Foundry project.", + Long: `Delete a toolbox by name from the current Azure AI Foundry project. + +You will be prompted to confirm before deleting (use --no-prompt to auto-confirm).`, + Example: ` # Delete a toolbox (with confirmation prompt) + azd ai agent toolbox delete my-toolbox + + # Delete without prompting + azd ai agent toolbox delete my-toolbox --no-prompt`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } + + credential, err := newAgentCredential() + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create credential: %s", err), + "Run 'azd auth login' to authenticate", + ) + } + + // Prompt for confirmation + if !rootFlags.NoPrompt { + azdClient, azdErr := azdext.NewAzdClient() + if azdErr != nil { + return fmt.Errorf("failed to create azd client for prompting: %w", azdErr) + } + defer azdClient.Close() + + resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf("Are you sure you want to delete Toolbox '%s'?", name), + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("toolbox deletion cancelled") + } + return fmt.Errorf("failed to prompt for confirmation: %w", promptErr) + } + if resp == nil || resp.Value == nil || !*resp.Value { + fmt.Println("toolbox deletion cancelled.") + return nil + } + } + + client := agent_api.NewAgentClient(endpoint, credential) + + _, err = client.DeleteToolbox(ctx, name, agent_api.ToolboxAPIVersion) + if err != nil { + var respErr *azcore.ResponseError + if errors.As(err, &respErr) && respErr.StatusCode == 404 { + return exterrors.Validation( + exterrors.CodeToolboxNotFound, + fmt.Sprintf("Toolbox '%s' not found", name), + "Run 'azd ai agent toolbox list' to see available toolboxes", + ) + } + return exterrors.ServiceFromAzure(err, exterrors.OpDeleteToolbox) + } + + fmt.Printf("Toolbox '%s' deleted successfully.\n", name) + return nil + }, + } + + return cmd +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_list.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_list.go new file mode 100644 index 00000000000..cfba2a3bb21 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_list.go @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + "time" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type toolboxListFlags struct { + output string +} + +func newToolboxListCommand() *cobra.Command { + flags := &toolboxListFlags{} + + cmd := &cobra.Command{ + Use: "list", + Short: "List all toolboxes in the Foundry project.", + Long: `List all toolboxes in the current Azure AI Foundry project. + +Displays the name, description, number of tools, and creation time +for each toolbox. Requires AZURE_AI_PROJECT_ENDPOINT in the azd environment.`, + Example: ` # List toolboxes in table format (default) + azd ai agent toolbox list + + # List toolboxes as JSON + azd ai agent toolbox list --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } + + credential, err := newAgentCredential() + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create credential: %s", err), + "Run 'azd auth login' to authenticate", + ) + } + + client := agent_api.NewAgentClient(endpoint, credential) + list, err := client.ListToolboxes(ctx, agent_api.ToolboxAPIVersion) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpListToolboxes) + } + + switch flags.output { + case "json": + return printToolboxListJSON(list) + default: + return printToolboxListTable(ctx, list) + } + }, + } + + cmd.Flags().StringVarP(&flags.output, "output", "o", "table", "Output format (json or table)") + + return cmd +} + +func printToolboxListJSON(list *agent_api.ToolboxList) error { + jsonBytes, err := json.MarshalIndent(list, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal toolbox list to JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil +} + +func printToolboxListTable(_ context.Context, list *agent_api.ToolboxList) error { + if len(list.Data) == 0 { + fmt.Println("No toolboxes found.") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tDESCRIPTION\tTOOLS\tCREATED") + fmt.Fprintln(w, "----\t-----------\t-----\t-------") + + for _, ts := range list.Data { + desc := ts.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + + created := "" + if ts.CreatedAt > 0 { + created = time.Unix(ts.CreatedAt, 0).Format(time.RFC3339) + } + + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", ts.Name, desc, len(ts.Tools), created) + } + + return w.Flush() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go new file mode 100644 index 00000000000..a108fe9ba9f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_show.go @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "text/tabwriter" + "time" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type toolboxShowFlags struct { + output string +} + +func newToolboxShowCommand() *cobra.Command { + flags := &toolboxShowFlags{} + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show details of a toolbox.", + Long: `Show details of a Foundry toolbox by name. + +Displays the toolbox's properties, included tools, and the MCP endpoint URL +that can be used to connect an agent to the toolbox.`, + Example: ` # Show toolbox details as JSON (default) + azd ai agent toolbox show my-toolbox + + # Show toolbox details as a table + azd ai agent toolbox show my-toolbox --output table`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } + + credential, err := newAgentCredential() + if err != nil { + return exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create credential: %s", err), + "Run 'azd auth login' to authenticate", + ) + } + + client := agent_api.NewAgentClient(endpoint, credential) + toolbox, err := client.GetToolbox(ctx, name, agent_api.ToolboxAPIVersion) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetToolbox) + } + + mcpEndpoint := project.ToolboxMcpEndpoint(endpoint, name) + + switch flags.output { + case "table": + return printToolboxShowTable(toolbox, mcpEndpoint) + default: + return printToolboxShowJSON(toolbox, mcpEndpoint) + } + }, + } + + cmd.Flags().StringVarP(&flags.output, "output", "o", "json", "Output format (json or table)") + + return cmd +} + +// toolboxShowOutput wraps the toolbox object with the computed MCP endpoint for JSON output. +type toolboxShowOutput struct { + agent_api.ToolboxObject + MCPEndpoint string `json:"mcp_endpoint"` +} + +func printToolboxShowJSON(toolbox *agent_api.ToolboxObject, mcpEndpoint string) error { + output := toolboxShowOutput{ + ToolboxObject: *toolbox, + MCPEndpoint: mcpEndpoint, + } + + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal toolbox to JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil +} + +func printToolboxShowTable(toolbox *agent_api.ToolboxObject, mcpEndpoint string) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "FIELD\tVALUE") + fmt.Fprintln(w, "-----\t-----") + + fmt.Fprintf(w, "Name\t%s\n", toolbox.Name) + fmt.Fprintf(w, "ID\t%s\n", toolbox.ID) + if toolbox.Description != "" { + fmt.Fprintf(w, "Description\t%s\n", toolbox.Description) + } + + if toolbox.CreatedAt > 0 { + fmt.Fprintf(w, "Created\t%s\n", time.Unix(toolbox.CreatedAt, 0).Format(time.RFC3339)) + } + if toolbox.UpdatedAt > 0 { + fmt.Fprintf(w, "Updated\t%s\n", time.Unix(toolbox.UpdatedAt, 0).Format(time.RFC3339)) + } + + fmt.Fprintf(w, "Tools\t%d\n", len(toolbox.Tools)) + for i, raw := range toolbox.Tools { + toolType, toolName := agent_api.ToolSummary(raw) + label := toolType + if toolName != "" { + label = fmt.Sprintf("%s (%s)", toolType, toolName) + } + fmt.Fprintf(w, " Tool %d\t%s\n", i+1, label) + } + + fmt.Fprintf(w, "MCP Endpoint\t%s\n", mcpEndpoint) + + return w.Flush() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go new file mode 100644 index 00000000000..180ddd0fe90 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox_test.go @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/project" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToolboxCommand_HasSubcommands(t *testing.T) { + cmd := newToolboxCommand() + + subcommands := cmd.Commands() + names := make([]string, len(subcommands)) + for i, c := range subcommands { + names[i] = c.Name() + } + + assert.Contains(t, names, "list") + assert.Contains(t, names, "show") + assert.Contains(t, names, "create") + assert.Contains(t, names, "delete") +} + +func TestToolboxListCommand_DefaultOutputFormat(t *testing.T) { + cmd := newToolboxListCommand() + + output, _ := cmd.Flags().GetString("output") + assert.Equal(t, "table", output) +} + +func TestToolboxListCommand_HasFlags(t *testing.T) { + cmd := newToolboxListCommand() + + f := cmd.Flags().Lookup("output") + require.NotNil(t, f, "expected flag 'output'") + assert.Equal(t, "o", f.Shorthand) +} + +func TestToolboxShowCommand_RequiresArg(t *testing.T) { + cmd := newToolboxShowCommand() + + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestToolboxShowCommand_DefaultOutputFormat(t *testing.T) { + cmd := newToolboxShowCommand() + + output, _ := cmd.Flags().GetString("output") + assert.Equal(t, "json", output) +} + +func TestToolboxShowCommand_HasFlags(t *testing.T) { + cmd := newToolboxShowCommand() + + f := cmd.Flags().Lookup("output") + require.NotNil(t, f, "expected flag 'output'") + assert.Equal(t, "o", f.Shorthand) +} + +func TestToolboxCreateCommand_AcceptsOneArg(t *testing.T) { + cmd := newToolboxCreateCommand() + assert.NotNil(t, cmd.Args) +} + +func TestToolboxDeleteCommand_RequiresArg(t *testing.T) { + cmd := newToolboxDeleteCommand() + + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestToolboxNameToEnvVar(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"simple", "my-toolbox", "MY_TOOLBOX"}, + {"already upper", "MY_TOOLBOX", "MY_TOOLBOX"}, + {"dots and spaces", "my.toolbox name", "MY_TOOLBOX_NAME"}, + {"numeric", "tools123", "TOOLS123"}, + {"empty", "", ""}, + {"special chars", "a@b#c", "A_B_C"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := project.ToolboxNameToEnvVar(tt.in) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestToolboxEnvVar(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"simple", "my-toolbox", "FOUNDRY_TOOLBOX__MY_TOOLBOX__ENDPOINT"}, + {"echo github toolset", "echo-github-toolset", "FOUNDRY_TOOLBOX__ECHO_GITHUB_TOOLSET__ENDPOINT"}, + {"already upper", "MY_TOOLBOX", "FOUNDRY_TOOLBOX__MY_TOOLBOX__ENDPOINT"}, + {"empty name", "", "FOUNDRY_TOOLBOX____ENDPOINT"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := project.ToolboxEnvVar(tt.in) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestPrintToolboxListTable_Empty(t *testing.T) { + list := &agent_api.ToolboxList{} + err := printToolboxListTable(t.Context(), list) + require.NoError(t, err) +} + +func TestPrintToolboxListJSON_Empty(t *testing.T) { + list := &agent_api.ToolboxList{} + err := printToolboxListJSON(list) + require.NoError(t, err) +} + +func TestPrintToolboxListTable_WithData(t *testing.T) { + list := &agent_api.ToolboxList{ + Data: []agent_api.ToolboxObject{ + { + Name: "test-toolbox", + Description: "A Test toolbox", + Tools: []json.RawMessage{json.RawMessage(`{"type":"mcp_server"}`)}, + CreatedAt: 1700000000, + }, + { + Name: "long-desc", + Description: "This description is longer than fifty characters and should be truncated", + Tools: nil, + }, + }, + } + + err := printToolboxListTable(t.Context(), list) + require.NoError(t, err) +} + +func TestPrintToolboxListJSON_WithData(t *testing.T) { + list := &agent_api.ToolboxList{ + Data: []agent_api.ToolboxObject{ + { + Name: "test-toolbox", + Tools: []json.RawMessage{json.RawMessage(`{"type":"mcp_server"}`)}, + }, + }, + } + + err := printToolboxListJSON(list) + require.NoError(t, err) +} + +func TestPrintToolboxShowJSON(t *testing.T) { + toolbox := &agent_api.ToolboxObject{ + Name: "my-toolbox", + ID: "ts-123", + Tools: []json.RawMessage{json.RawMessage(`{"type":"openapi"}`)}, + } + + err := printToolboxShowJSON(toolbox, "https://example.com/toolsets/my-toolbox/mcp") + require.NoError(t, err) +} + +func TestPrintToolboxShowTable(t *testing.T) { + toolbox := &agent_api.ToolboxObject{ + Name: "my-toolbox", + ID: "ts-123", + Description: "Test toolbox", + CreatedAt: 1700000000, + UpdatedAt: 1700001000, + Tools: []json.RawMessage{ + json.RawMessage(`{"type":"mcp_server","server_label":"my-mcp"}`), + json.RawMessage(`{"type":"openapi"}`), + }, + } + + err := printToolboxShowTable(toolbox, "https://example.com/toolsets/my-toolbox/mcp") + require.NoError(t, err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 045e6ee226a..27c60a8bf1b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -86,6 +86,21 @@ const ( CodeInvalidFilePath = "invalid_file_path" ) +// Error codes for toolbox operations. +const ( + CodeToolboxNotFound = "toolbox_not_found" + CodeInvalidToolboxPayload = "invalid_toolbox_payload" +) + +// Operation names for toolbox [ServiceFromAzure] errors. +const ( + OpListToolboxes = "list_toolboxes" + OpGetToolbox = "get_toolbox" + OpCreateToolbox = "create_toolbox" + OpUpdateToolbox = "update_toolbox" + OpDeleteToolbox = "delete_toolbox" +) + // Error codes commonly used for internal errors. // // These are usually paired with [Internal] for unexpected failures diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go new file mode 100644 index 00000000000..25c766ef443 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_models.go @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import "encoding/json" + +// ToolboxAPIVersion is the API version for toolbox operations. +const ToolboxAPIVersion = "v1" + +// ToolboxFeatureHeader is the required preview feature flag header for toolbox operations. +const ToolboxFeatureHeader = "Toolsets=V1Preview" + +// ToolboxObject represents a toolbox returned by the Foundry Toolboxes API. +type ToolboxObject struct { + Object string `json:"object"` + ID string `json:"id"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Tools []json.RawMessage `json:"tools"` +} + +// ToolboxList represents the paginated list response from the Toolboxes API. +type ToolboxList struct { + Data []ToolboxObject `json:"data"` +} + +// DeleteToolboxResponse represents the response from deleting a toolbox. +type DeleteToolboxResponse struct { + Object string `json:"object"` + Name string `json:"name"` + Deleted bool `json:"deleted"` +} + +// CreateToolboxRequest represents the request body for creating a toolbox. +type CreateToolboxRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Tools []json.RawMessage `json:"tools"` +} + +// UpdateToolboxRequest represents the request body for updating a toolbox. +type UpdateToolboxRequest struct { + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Tools []json.RawMessage `json:"tools"` +} + +// ToolSummary extracts a display-friendly summary from a raw tool JSON object. +// Returns the tool type and name (if available). +func ToolSummary(raw json.RawMessage) (toolType string, toolName string) { + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return "unknown", "" + } + + if t, ok := m["type"]; ok { + var s string + if json.Unmarshal(t, &s) == nil { + toolType = s + } + } + if toolType == "" { + toolType = "unknown" + } + + // Try common name fields + for _, key := range []string{"server_label", "name"} { + if v, ok := m[key]; ok { + var s string + if json.Unmarshal(v, &s) == nil && s != "" { + toolName = s + break + } + } + } + + return toolType, toolName +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_operations.go new file mode 100644 index 00000000000..263eb598f40 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolbox_operations.go @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" +) + +// ListToolboxes returns all toolboxes in the Foundry project. +func (c *AgentClient) ListToolboxes(ctx context.Context, apiVersion string) (*ToolboxList, error) { + url := fmt.Sprintf("%s/toolsets?api-version=%s", c.endpoint, apiVersion) + + req, err := runtime.NewRequest(ctx, http.MethodGet, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var list ToolboxList + if err := json.Unmarshal(body, &list); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &list, nil +} + +// GetToolbox retrieves a specific toolbox by name. +func (c *AgentClient) GetToolbox(ctx context.Context, name, apiVersion string) (*ToolboxObject, error) { + url := fmt.Sprintf("%s/toolsets/%s?api-version=%s", c.endpoint, name, apiVersion) + + req, err := runtime.NewRequest(ctx, http.MethodGet, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var toolbox ToolboxObject + if err := json.Unmarshal(body, &toolbox); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &toolbox, nil +} + +// CreateToolbox creates a new toolbox. +func (c *AgentClient) CreateToolbox( + ctx context.Context, request *CreateToolboxRequest, apiVersion string, +) (*ToolboxObject, error) { + url := fmt.Sprintf("%s/toolsets?api-version=%s", c.endpoint, apiVersion) + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) + + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var toolbox ToolboxObject + if err := json.Unmarshal(body, &toolbox); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &toolbox, nil +} + +// UpdateToolbox updates an existing toolbox by name. +func (c *AgentClient) UpdateToolbox( + ctx context.Context, name string, request *UpdateToolboxRequest, apiVersion string, +) (*ToolboxObject, error) { + url := fmt.Sprintf("%s/toolsets/%s?api-version=%s", c.endpoint, name, apiVersion) + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) + + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var toolbox ToolboxObject + if err := json.Unmarshal(body, &toolbox); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &toolbox, nil +} + +// DeleteToolbox deletes a toolbox by name. +func (c *AgentClient) DeleteToolbox(ctx context.Context, name, apiVersion string) (*DeleteToolboxResponse, error) { + url := fmt.Sprintf("%s/toolsets/%s?api-version=%s", c.endpoint, name, apiVersion) + + req, err := runtime.NewRequest(ctx, http.MethodDelete, url) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Raw().Header.Set("Foundry-Features", ToolboxFeatureHeader) + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var deleteResp DeleteToolboxResponse + if err := json.Unmarshal(body, &deleteResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &deleteResp, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models_test.go new file mode 100644 index 00000000000..250cf317abd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/toolset_models_test.go @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToolSummary(t *testing.T) { + tests := []struct { + name string + raw json.RawMessage + wantType string + wantName string + }{ + { + name: "mcp_server with server_label", + raw: json.RawMessage(`{"type":"mcp_server","server_label":"my-mcp"}`), + wantType: "mcp_server", + wantName: "my-mcp", + }, + { + name: "openapi with name", + raw: json.RawMessage(`{"type":"openapi","name":"weather-api"}`), + wantType: "openapi", + wantName: "weather-api", + }, + { + name: "server_label takes precedence over name", + raw: json.RawMessage(`{"type":"mcp_server","server_label":"label","name":"fallback"}`), + wantType: "mcp_server", + wantName: "label", + }, + { + name: "type only no name", + raw: json.RawMessage(`{"type":"bing_grounding"}`), + wantType: "bing_grounding", + wantName: "", + }, + { + name: "empty object", + raw: json.RawMessage(`{}`), + wantType: "unknown", + wantName: "", + }, + { + name: "invalid json", + raw: json.RawMessage(`not-json`), + wantType: "unknown", + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotType, gotName := ToolSummary(tt.raw) + assert.Equal(t, tt.wantType, gotType) + assert.Equal(t, tt.wantName, gotName) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index f859ff8a31a..318bed100d8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -170,6 +170,12 @@ func ExtractResourceDefinitions(manifestYamlContent []byte) ([]any, error) { return nil, fmt.Errorf("failed to unmarshal to ToolResource: %w", err) } resourceDefs = append(resourceDefs, toolDef) + case ResourceKindToolbox: + var toolboxDef ToolboxResource + if err := yaml.Unmarshal(resourceBytes, &toolboxDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal to ToolboxResource: %w", err) + } + resourceDefs = append(resourceDefs, toolboxDef) default: return nil, fmt.Errorf("unrecognized resource kind: %s", resourceDef.Kind) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go index 3679d528713..016c3b7cc9f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go @@ -40,6 +40,53 @@ template: } } +// TestExtractAgentDefinition_WithTools tests that tools in the template are preserved during parsing +func TestExtractAgentDefinition_WithTools(t *testing.T) { + yamlContent := []byte(` +name: test-manifest +template: + kind: hosted + name: test-agent + protocols: + - protocol: responses + version: v1 + tools: + - type: toolbox + toolbox_name: echo-github-toolset +`) + + agent, err := ExtractAgentDefinition(yamlContent) + if err != nil { + t.Fatalf("ExtractAgentDefinition failed: %v", err) + } + + containerAgent, ok := agent.(ContainerAgent) + if !ok { + t.Fatalf("Expected ContainerAgent, got %T", agent) + } + + if containerAgent.Tools == nil { + t.Fatal("Expected tools to be present, got nil") + } + + if len(*containerAgent.Tools) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(*containerAgent.Tools)) + } + + tool, ok := (*containerAgent.Tools)[0].(map[string]any) + if !ok { + t.Fatalf("Expected tool to be map[string]any, got %T", (*containerAgent.Tools)[0]) + } + + if tool["type"] != "toolbox" { + t.Errorf("Expected tool type 'toolbox', got '%v'", tool["type"]) + } + + if tool["toolbox_name"] != "echo-github-toolset" { + t.Errorf("Expected toolbox_name 'echo-github-toolset', got '%v'", tool["toolbox_name"]) + } +} + // TestExtractAgentDefinition_EmptyTemplateField tests that an empty or null template field returns an error func TestExtractAgentDefinition_EmptyTemplateField(t *testing.T) { testCases := []struct { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index bb9231c7651..75ea3eb5466 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -31,8 +31,9 @@ func ValidAgentKinds() []AgentKind { type ResourceKind string const ( - ResourceKindModel ResourceKind = "model" - ResourceKindTool ResourceKind = "tool" + ResourceKindModel ResourceKind = "model" + ResourceKindTool ResourceKind = "tool" + ResourceKindToolbox ResourceKind = "toolbox" ) type ToolKind string @@ -104,6 +105,7 @@ type Workflow struct { type ContainerAgent struct { AgentDefinition `json:",inline" yaml:",inline"` Protocols []ProtocolVersionRecord `json:"protocols" yaml:"protocols"` + Tools *[]any `json:"tools,omitempty" yaml:"tools,omitempty"` EnvironmentVariables *[]EnvironmentVariable `json:"environmentVariables,omitempty" yaml:"environment_variables,omitempty"` } @@ -305,6 +307,13 @@ type ToolResource struct { Options map[string]any `json:"options" yaml:"options"` } +// ToolboxResource Represents a Foundry project toolbox dependency +type ToolboxResource struct { + Resource `json:",inline" yaml:",inline"` + Id string `json:"id" yaml:"id"` + Options map[string]any `json:"options,omitempty" yaml:"options,omitempty"` +} + // Template Template model for defining prompt templates. // This model specifies the rendering engine used for slot filling prompts, // the parser used to process the rendered template into API-compatible format, diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index 4694d649466..2b58ff10dbe 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -6,6 +6,7 @@ package project import ( "encoding/json" "fmt" + "strings" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/structpb" @@ -48,6 +49,7 @@ type ServiceTargetAgentConfig struct { Container *ContainerSettings `json:"container,omitempty"` Deployments []Deployment `json:"deployments,omitempty"` Resources []Resource `json:"resources,omitempty"` + Toolboxes []Toolbox `json:"toolboxes,omitempty"` StartupCommand string `json:"startupCommand,omitempty"` } @@ -108,6 +110,40 @@ type Resource struct { ConnectionName string `json:"connectionName"` } +// Toolbox represents a Foundry project toolbox dependency for an agent. +// When Tools is populated, the toolbox will be created if it doesn't exist. +// When Tools is empty, the toolbox is expected to already exist. +type Toolbox struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Tools []json.RawMessage `json:"tools,omitempty"` +} + +// ToolboxNameToEnvVar converts a toolbox name to an environment variable prefix +// by upper-casing and replacing non-alphanumeric characters with underscores. +func ToolboxNameToEnvVar(name string) string { + var b strings.Builder + for _, r := range strings.ToUpper(name) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +// ToolboxEnvVar returns the full environment variable name for a toolbox's MCP endpoint. +// The format is FOUNDRY_TOOLBOX____ENDPOINT. +func ToolboxEnvVar(name string) string { + return "FOUNDRY_TOOLBOX__" + ToolboxNameToEnvVar(name) + "__ENDPOINT" +} + +// ToolboxMcpEndpoint returns the MCP endpoint URL for a toolbox. +func ToolboxMcpEndpoint(projectEndpoint, toolboxName string) string { + return fmt.Sprintf("%s/toolsets/%s/mcp", projectEndpoint, toolboxName) +} + // UnmarshalStruct converts a structpb.Struct to a Go struct of type T func UnmarshalStruct[T any](s *structpb.Struct, out *T) error { structBytes, err := protojson.Marshal(s) diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 367e7fd003d..4310ba940e1 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -579,6 +579,7 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( agentVersionResponse.Version, azdEnv["AZURE_AI_PROJECT_ID"], azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + nil, ) return &azdext.ServiceDeployResult{ @@ -645,6 +646,13 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( ) } + // Ensure toolbox dependencies exist before creating the agent + if len(foundryAgentConfig.Toolboxes) > 0 { + if err := p.ensureToolboxes(ctx, foundryAgentConfig.Toolboxes, azdEnv, progress); err != nil { + return nil, err + } + } + var cpu, memory string if foundryAgentConfig.Container != nil && foundryAgentConfig.Container.Resources != nil { cpu = foundryAgentConfig.Container.Resources.Cpu @@ -708,6 +716,7 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( agentVersionResponse.Version, azdEnv["AZURE_AI_PROJECT_ID"], azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + foundryAgentConfig.Toolboxes, ) return &azdext.ServiceDeployResult{ @@ -721,6 +730,7 @@ func (p *AgentServiceTargetProvider) deployArtifacts( agentVersion string, projectResourceID string, projectEndpoint string, + toolboxes []Toolbox, ) []*azdext.Artifact { artifacts := []*azdext.Artifact{} @@ -759,10 +769,24 @@ func (p *AgentServiceTargetProvider) deployArtifacts( }) } + // Add toolbox MCP endpoints + if projectEndpoint != "" { + for _, tb := range toolboxes { + mcpEndpoint := ToolboxMcpEndpoint(projectEndpoint, tb.Name) + artifacts = append(artifacts, &azdext.Artifact{ + Kind: azdext.ArtifactKind_ARTIFACT_KIND_ENDPOINT, + Location: mcpEndpoint, + LocationKind: azdext.LocationKind_LOCATION_KIND_REMOTE, + Metadata: map[string]string{ + "label": fmt.Sprintf("Toolbox MCP endpoint (%s)", tb.Name), + "clickable": "false", + }, + }) + } + } + return artifacts } - -// agentEndpoint constructs the agent endpoint URL from the provided parameters func (p *AgentServiceTargetProvider) agentEndpoint(projectEndpoint, agentName, agentVersion string) string { return fmt.Sprintf("%s/agents/%s/versions/%s", projectEndpoint, agentName, agentVersion) } @@ -827,6 +851,53 @@ func (p *AgentServiceTargetProvider) createAgent( return agentVersionResponse, nil } +// ensureToolboxes checks that each toolbox dependency exists, creating greenfield ones as needed. +func (p *AgentServiceTargetProvider) ensureToolboxes( + ctx context.Context, + toolboxes []Toolbox, + azdEnv map[string]string, + progress azdext.ProgressReporter, +) error { + agentClient := agent_api.NewAgentClient( + azdEnv["AZURE_AI_PROJECT_ENDPOINT"], + p.credential, + ) + + for _, tb := range toolboxes { + progress(fmt.Sprintf("Checking toolbox '%s'", tb.Name)) + _, err := agentClient.GetToolbox(ctx, tb.Name, agent_api.ToolboxAPIVersion) + if err == nil { + fmt.Fprintf(os.Stderr, "Toolbox '%s' already exists, skipping creation\n", tb.Name) + continue + } + + // If no tools defined, this is brownfield — the toolbox must already exist + if len(tb.Tools) == 0 { + return exterrors.Dependency( + exterrors.CodeToolboxNotFound, + fmt.Sprintf("toolbox '%s' not found and no tool definitions provided to create it", tb.Name), + fmt.Sprintf("create the toolbox first with 'azd ai agent toolbox create --name %s' "+ + "or add tool definitions in azure.yaml config.toolboxes", tb.Name), + ) + } + + // Greenfield — create the toolbox + progress(fmt.Sprintf("Creating toolbox '%s'", tb.Name)) + createReq := &agent_api.CreateToolboxRequest{ + Name: tb.Name, + Description: tb.Description, + Tools: tb.Tools, + } + _, err = agentClient.CreateToolbox(ctx, createReq, agent_api.ToolboxAPIVersion) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateToolbox) + } + fmt.Fprintf(os.Stderr, "Toolbox '%s' created successfully\n", tb.Name) + } + + return nil +} + // startAgentContainer starts the hosted agent container func (p *AgentServiceTargetProvider) startAgentContainer( ctx context.Context, diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index 22d77ce8341..6ca6f4ba081 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -24,6 +24,11 @@ "description": "List of external resources for agent execution.", "items": { "$ref": "#/definitions/Resource" } }, + "toolboxes": { + "type": "array", + "description": "List of Foundry project toolboxes the agent depends on.", + "items": { "$ref": "#/definitions/Toolbox" } + }, "startupCommand": { "type": "string", "description": "Command to start the agent server (e.g., 'python main.py'). Used by 'azd ai agent run' for local development." @@ -119,6 +124,21 @@ }, "required": ["resource", "connectionName"], "additionalProperties": false + }, + "Toolbox": { + "type": "object", + "description": "A Foundry project toolbox dependency.", + "properties": { + "name": { "type": "string", "description": "Toolbox name in the Foundry project." }, + "description": { "type": "string", "description": "Optional toolbox description." }, + "tools": { + "type": "array", + "description": "Tool definitions. If present, the toolbox will be created if it doesn't exist.", + "items": { "type": "object", "additionalProperties": true } + } + }, + "required": ["name"], + "additionalProperties": false } } } \ No newline at end of file