diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go new file mode 100644 index 00000000000..1e62615100a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -0,0 +1,762 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "text/tabwriter" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// filesFlags holds the common flags shared by all file subcommands. +type filesFlags struct { + agentName string // optional: agent name (matches azure.yaml service name) + session string // optional: explicit session ID override +} + +// isVNextEnabled checks whether hosted agent vnext is enabled +// by looking at both the OS environment and the azd environment. +func isVNextEnabled(ctx context.Context) bool { + if v := os.Getenv("enableHostedAgentVNext"); v != "" { + if enabled, err := strconv.ParseBool(v); err == nil && enabled { + return true + } + } + + // Best-effort check of azd environment + azdClient, err := azdext.NewAzdClient() + if err != nil { + return false + } + defer azdClient.Close() + + azdEnv, err := loadAzdEnvironment(ctx, azdClient) + if err != nil { + return false + } + + if v := azdEnv["enableHostedAgentVNext"]; v != "" { + if enabled, err := strconv.ParseBool(v); err == nil && enabled { + return true + } + } + + return false +} + +func newFilesCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "files", + Short: "Manage files in a hosted agent session.", + Hidden: !isVNextEnabled(context.Background()), + Long: `Manage files in a hosted agent session. + +Upload, download, list, and remove files in the session-scoped filesystem +of a hosted agent. This is useful for debugging, seeding data, and agent setup. + +Agent details (name, version, endpoint) are automatically resolved from the +azd environment. Use --agent-name to select a specific agent when the project +has multiple azure.ai.agent services. The session ID is automatically resolved +from the last invoke session, or can be overridden with --session.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Chain with parent's PersistentPreRunE (root sets NoPrompt) + if parent := cmd.Parent(); parent != nil && parent.PersistentPreRunE != nil { + if err := parent.PersistentPreRunE(cmd, args); err != nil { + return err + } + } + + ctx := azdext.WithAccessToken(cmd.Context()) + if !isVNextEnabled(ctx) { + return fmt.Errorf( + "files commands require hosted agent vnext to be enabled\n\n" + + "Set 'enableHostedAgentVNext' to 'true' in your azd environment or as an OS environment variable.", + ) + } + return nil + }, + } + + cmd.AddCommand(newFilesUploadCommand()) + cmd.AddCommand(newFilesDownloadCommand()) + cmd.AddCommand(newFilesListCommand()) + cmd.AddCommand(newFilesRemoveCommand()) + cmd.AddCommand(newFilesMkdirCommand()) + cmd.AddCommand(newFilesStatCommand()) + + return cmd +} + +// addFilesFlags registers the common flags on a cobra command. +func addFilesFlags(cmd *cobra.Command, flags *filesFlags) { + cmd.Flags().StringVarP(&flags.agentName, "agent-name", "n", "", "Agent name (matches azure.yaml service name; auto-detected when only one exists)") + cmd.Flags().StringVarP(&flags.session, "session", "s", "", "Session ID override (defaults to last invoke session)") +} + +// filesContext holds the resolved agent context and session for file operations. +type filesContext struct { + *AgentContext + sessionID string +} + +// resolveFilesContext resolves agent details and session from the azd environment. +func resolveFilesContext(ctx context.Context, flags *filesFlags) (*filesContext, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return nil, fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.agentName, rootFlags.NoPrompt) + if err != nil { + return nil, err + } + + if info.AgentName == "" { + return nil, fmt.Errorf( + "agent name not found in azd environment for service %q\n\n"+ + "Run 'azd deploy' to deploy the agent, or check that the service is configured in azure.yaml", + info.ServiceName, + ) + } + if info.Version == "" { + return nil, fmt.Errorf( + "agent version not found in azd environment for service %q\n\n"+ + "Run 'azd deploy' to deploy the agent, or check that the service is configured in azure.yaml", + info.ServiceName, + ) + } + + endpoint, err := resolveAgentEndpoint(ctx, "", "") + if err != nil { + return nil, err + } + + sessionID, err := resolveSessionID(ctx, azdClient, info.AgentName, flags.session, false) + if err != nil { + return nil, err + } + + return &filesContext{ + AgentContext: &AgentContext{ + ProjectEndpoint: endpoint, + Name: info.AgentName, + Version: info.Version, + }, + sessionID: sessionID, + }, nil +} + +// --- upload --- + +type filesUploadFlags struct { + filesFlags + file string + targetPath string +} + +// FilesUploadAction handles uploading a file to a session. +type FilesUploadAction struct { + *AgentContext + flags *filesUploadFlags + sessionID string +} + +func newFilesUploadCommand() *cobra.Command { + flags := &filesUploadFlags{} + + cmd := &cobra.Command{ + Use: "upload", + Short: "Upload a file to a hosted agent session.", + Long: `Upload a file to a hosted agent session. + +Reads a local file and uploads it to the specified remote path +in the session's filesystem. If --target-path is not provided, +the remote path defaults to the local file path. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Upload a file (remote path defaults to local path) + azd ai agent files upload --file ./data/input.csv + + # Upload to a specific remote path + azd ai agent files upload --file ./input.csv --target-path /data/input.csv + + # Upload with explicit agent name and session + azd ai agent files upload --file ./input.csv --agent-name my-agent --session `, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + fc, err := resolveFilesContext(ctx, &flags.filesFlags) + if err != nil { + return err + } + + action := &FilesUploadAction{ + AgentContext: fc.AgentContext, + flags: flags, + sessionID: fc.sessionID, + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVarP(&flags.file, "file", "f", "", "Local file path to upload (required)") + cmd.Flags().StringVarP(&flags.targetPath, "target-path", "t", "", "Remote destination path (defaults to local file path)") + _ = cmd.MarkFlagRequired("file") + + return cmd +} + +// Run executes the upload action. +func (a *FilesUploadAction) Run(ctx context.Context) error { + remotePath := a.flags.targetPath + if remotePath == "" { + remotePath = a.flags.file + } + + //nolint:gosec // G304: file path is provided by the user via CLI flag + file, err := os.Open(a.flags.file) + if err != nil { + return fmt.Errorf("failed to open local file %q: %w", a.flags.file, err) + } + defer file.Close() + + agentClient, err := a.NewClient() + if err != nil { + return err + } + + err = agentClient.UploadSessionFile( + ctx, + a.Name, + a.Version, + a.sessionID, + remotePath, + DefaultVNextAgentAPIVersion, + file, + ) + if err != nil { + return fmt.Errorf("failed to upload file: %w", err) + } + + fmt.Printf("Uploaded %s → %s\n", a.flags.file, remotePath) + return nil +} + +// --- download --- + +type filesDownloadFlags struct { + filesFlags + file string + targetPath string +} + +// FilesDownloadAction handles downloading a file from a session. +type FilesDownloadAction struct { + *AgentContext + flags *filesDownloadFlags + sessionID string +} + +func newFilesDownloadCommand() *cobra.Command { + flags := &filesDownloadFlags{} + + cmd := &cobra.Command{ + Use: "download", + Short: "Download a file from a hosted agent session.", + Long: `Download a file from a hosted agent session. + +Downloads a file from the specified remote path in the session's +filesystem and saves it locally. If --target-path is not provided, +the local path defaults to the basename of the remote file. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Download a file (local path defaults to remote filename) + azd ai agent files download --file /data/output.csv + + # Download to a specific local path + azd ai agent files download --file /data/output.csv --target-path ./output.csv + + # Download with explicit session + azd ai agent files download --file /data/output.csv --session `, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + fc, err := resolveFilesContext(ctx, &flags.filesFlags) + if err != nil { + return err + } + + action := &FilesDownloadAction{ + AgentContext: fc.AgentContext, + flags: flags, + sessionID: fc.sessionID, + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVarP(&flags.file, "file", "f", "", "Remote file path to download (required)") + cmd.Flags().StringVarP(&flags.targetPath, "target-path", "t", "", "Local destination path (defaults to remote filename)") + _ = cmd.MarkFlagRequired("file") + + return cmd +} + +// Run executes the download action. +func (a *FilesDownloadAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + body, err := agentClient.DownloadSessionFile( + ctx, + a.Name, + a.Version, + a.sessionID, + a.flags.file, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to download file: %w", err) + } + defer body.Close() + + targetPath := a.flags.targetPath + if targetPath == "" { + targetPath = filepath.Base(a.flags.file) + } + + //nolint:gosec // G304: targetPath is provided by the user via CLI flag + outFile, err := os.Create(targetPath) + if err != nil { + return fmt.Errorf("failed to create output file %q: %w", targetPath, err) + } + defer outFile.Close() + + if _, err := io.Copy(outFile, body); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + fmt.Printf("Downloaded %s → %s\n", a.flags.file, targetPath) + return nil +} + +// --- list --- + +type filesListFlags struct { + filesFlags + output string +} + +// FilesListAction handles listing files in a session. +type FilesListAction struct { + *AgentContext + flags *filesListFlags + sessionID string + remotePath string +} + +func newFilesListCommand() *cobra.Command { + flags := &filesListFlags{} + + cmd := &cobra.Command{ + Use: "list [remote-path]", + Short: "List files in a hosted agent session.", + Long: `List files in a hosted agent session. + +Lists files and directories at the specified path in the session's filesystem. +When no path is provided, lists the root directory. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # List files in the root directory (agent auto-detected) + azd ai agent files list + + # List files in a specific directory + azd ai agent files list /data + + # List files in table format + azd ai agent files list /data --output table + + # List with explicit session + azd ai agent files list --session `, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + fc, err := resolveFilesContext(ctx, &flags.filesFlags) + if err != nil { + return err + } + + remotePath := "" + if len(args) > 0 { + remotePath = args[0] + } + + action := &FilesListAction{ + AgentContext: fc.AgentContext, + flags: flags, + sessionID: fc.sessionID, + remotePath: remotePath, + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVar(&flags.output, "output", "json", "Output format (json or table)") + + return cmd +} + +// Run executes the list action. +func (a *FilesListAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + fileList, err := agentClient.ListSessionFiles( + ctx, + a.Name, + a.Version, + a.sessionID, + a.remotePath, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to list files: %w", err) + } + + switch a.flags.output { + case "table": + return printFileListTable(fileList) + default: + return printFileListJSON(fileList) + } +} + +func printFileListJSON(fileList *agent_api.SessionFileList) error { + jsonBytes, err := json.MarshalIndent(fileList, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal file list to JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil +} + +func printFileListTable(fileList *agent_api.SessionFileList) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tPATH\tTYPE\tSIZE\tLAST MODIFIED") + fmt.Fprintln(w, "----\t----\t----\t----\t-------------") + + for _, f := range fileList.Entries { + fileType := "file" + if f.IsDirectory { + fileType = "dir" + } + modified := "" + if f.LastModified != nil { + modified = *f.LastModified + } + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", f.Name, f.Path, fileType, f.Size, modified) + } + + return w.Flush() +} + +// --- remove --- + +type filesRemoveFlags struct { + filesFlags + recursive bool +} + +// FilesRemoveAction handles removing a file or directory from a session. +type FilesRemoveAction struct { + *AgentContext + flags *filesRemoveFlags + sessionID string + remotePath string +} + +func newFilesRemoveCommand() *cobra.Command { + flags := &filesRemoveFlags{} + var filePath string + + cmd := &cobra.Command{ + Use: "remove", + Short: "Remove a file or directory from a hosted agent session.", + Long: `Remove a file or directory from a hosted agent session. + +Removes the specified file or directory from the session's filesystem. +Use --recursive to remove directories and their contents. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Remove a file (agent auto-detected) + azd ai agent files remove --file /data/old-file.csv + + # Remove a directory recursively + azd ai agent files remove --file /data/temp --recursive + + # Remove with explicit session + azd ai agent files remove --file /data/old-file.csv --session `, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + fc, err := resolveFilesContext(ctx, &flags.filesFlags) + if err != nil { + return err + } + + action := &FilesRemoveAction{ + AgentContext: fc.AgentContext, + flags: flags, + sessionID: fc.sessionID, + remotePath: filePath, + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVarP(&filePath, "file", "f", "", "Remote file or directory path to remove") + _ = cmd.MarkFlagRequired("file") + cmd.Flags().BoolVar(&flags.recursive, "recursive", false, "Recursively remove directories and their contents") + + return cmd +} + +// Run executes the remove action. +func (a *FilesRemoveAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + err = agentClient.RemoveSessionFile( + ctx, + a.Name, + a.Version, + a.sessionID, + a.remotePath, + a.flags.recursive, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to remove file: %w", err) + } + + fmt.Printf("Removed %s\n", a.remotePath) + return nil +} + +// --- mkdir --- + +// FilesMkdirAction handles creating a directory in a session. +type FilesMkdirAction struct { + *AgentContext + sessionID string + remotePath string +} + +func newFilesMkdirCommand() *cobra.Command { + flags := &filesFlags{} + var dirPath string + + cmd := &cobra.Command{ + Use: "mkdir", + Short: "Create a directory in a hosted agent session.", + Long: `Create a directory in a hosted agent session. + +Creates the specified directory in the session's filesystem. +Parent directories are created as needed. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Create a directory (agent auto-detected) + azd ai agent files mkdir --dir /data/output + + # Create with explicit session + azd ai agent files mkdir --dir /data/output --session `, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + fc, err := resolveFilesContext(ctx, flags) + if err != nil { + return err + } + + action := &FilesMkdirAction{ + AgentContext: fc.AgentContext, + sessionID: fc.sessionID, + remotePath: dirPath, + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, flags) + cmd.Flags().StringVarP(&dirPath, "dir", "d", "", "Remote directory path to create") + _ = cmd.MarkFlagRequired("dir") + + return cmd +} + +// Run executes the mkdir action. +func (a *FilesMkdirAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + err = agentClient.MkdirSessionFile( + ctx, + a.Name, + a.Version, + a.sessionID, + a.remotePath, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + fmt.Printf("Created %s\n", a.remotePath) + return nil +} + +// --- stat --- + +type filesStatFlags struct { + filesFlags + output string +} + +// FilesStatAction handles getting file/directory metadata from a session. +type FilesStatAction struct { + *AgentContext + flags *filesStatFlags + sessionID string + remotePath string +} + +func newFilesStatCommand() *cobra.Command { + flags := &filesStatFlags{} + + cmd := &cobra.Command{ + Use: "stat ", + Short: "Get file or directory metadata in a hosted agent session.", + Long: `Get file or directory metadata in a hosted agent session. + +Returns metadata about the specified file or directory in the session's filesystem. + +Agent details are automatically resolved from the azd environment.`, + Example: ` # Get metadata for a file + azd ai agent files stat /data/output.csv + + # Get metadata in table format + azd ai agent files stat /data/output.csv --output table + + # Get metadata with explicit session + azd ai agent files stat /data/output.csv --session `, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + fc, err := resolveFilesContext(ctx, &flags.filesFlags) + if err != nil { + return err + } + + action := &FilesStatAction{ + AgentContext: fc.AgentContext, + flags: flags, + sessionID: fc.sessionID, + remotePath: args[0], + } + + return action.Run(ctx) + }, + } + + addFilesFlags(cmd, &flags.filesFlags) + cmd.Flags().StringVarP(&flags.output, "output", "o", "json", "Output format (json or table)") + + return cmd +} + +// Run executes the stat action. +func (a *FilesStatAction) Run(ctx context.Context) error { + agentClient, err := a.NewClient() + if err != nil { + return err + } + + fileInfo, err := agentClient.StatSessionFile( + ctx, + a.Name, + a.Version, + a.sessionID, + a.remotePath, + DefaultVNextAgentAPIVersion, + ) + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + + if a.flags.output == "table" { + return printFileInfoTable(fileInfo) + } + + output, err := json.MarshalIndent(fileInfo, "", " ") + if err != nil { + return fmt.Errorf("failed to format output: %w", err) + } + + fmt.Println(string(output)) + return nil +} + +func printFileInfoTable(f *agent_api.SessionFileInfo) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tPATH\tTYPE\tSIZE\tLAST MODIFIED") + fmt.Fprintln(w, "----\t----\t----\t----\t-------------") + + fileType := "file" + if f.IsDirectory { + fileType = "dir" + } + modified := "" + if f.LastModified != nil { + modified = *f.LastModified + } + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", f.Name, f.Path, fileType, f.Size, modified) + + return w.Flush() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go new file mode 100644 index 00000000000..9abdc9d3729 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilesCommand_HasSubcommands(t *testing.T) { + cmd := newFilesCommand() + + subcommands := cmd.Commands() + names := make([]string, len(subcommands)) + for i, c := range subcommands { + names[i] = c.Name() + } + + assert.Contains(t, names, "upload") + assert.Contains(t, names, "download") + assert.Contains(t, names, "list") + assert.Contains(t, names, "remove") +} + +func TestFilesUploadCommand_MissingFile(t *testing.T) { + cmd := newFilesUploadCommand() + + // Missing required --file flag + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "file") +} + +func TestFilesUploadCommand_HasFlags(t *testing.T) { + cmd := newFilesUploadCommand() + + for _, name := range []string{"file", "target-path", "agent-name", "session"} { + f := cmd.Flags().Lookup(name) + require.NotNil(t, f, "expected flag %q", name) + assert.Equal(t, "", f.DefValue) + } +} + +func TestFilesDownloadCommand_MissingFile(t *testing.T) { + cmd := newFilesDownloadCommand() + + // Missing required --file flag + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "file") +} + +func TestFilesDownloadCommand_HasFlags(t *testing.T) { + cmd := newFilesDownloadCommand() + + for _, name := range []string{"file", "target-path", "agent-name", "session"} { + f := cmd.Flags().Lookup(name) + require.NotNil(t, f, "expected flag %q", name) + assert.Equal(t, "", f.DefValue) + } +} + +func TestFilesListCommand_DefaultOutputFormat(t *testing.T) { + cmd := newFilesListCommand() + + output, _ := cmd.Flags().GetString("output") + assert.Equal(t, "json", output) +} + +func TestFilesListCommand_OptionalRemotePath(t *testing.T) { + cmd := newFilesListCommand() + + // Verify the command accepts 0 or 1 args + assert.NotNil(t, cmd.Args) +} + +func TestFilesRemoveCommand_MissingFile(t *testing.T) { + cmd := newFilesRemoveCommand() + + // Missing required --file flag + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "file") +} + +func TestFilesRemoveCommand_HasFlags(t *testing.T) { + cmd := newFilesRemoveCommand() + + for _, name := range []string{"file", "recursive", "agent-name", "session"} { + f := cmd.Flags().Lookup(name) + require.NotNil(t, f, "expected flag %q", name) + } + + recursive, _ := cmd.Flags().GetBool("recursive") + assert.False(t, recursive, "recursive should default to false") +} + +func TestFilesMkdirCommand_MissingDir(t *testing.T) { + cmd := newFilesMkdirCommand() + + // Missing required --dir flag + cmd.SetArgs([]string{}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "dir") +} + +func TestFilesMkdirCommand_HasFlags(t *testing.T) { + cmd := newFilesMkdirCommand() + + for _, name := range []string{"dir", "agent-name", "session"} { + f := cmd.Flags().Lookup(name) + require.NotNil(t, f, "expected flag %q", name) + assert.Equal(t, "", f.DefValue) + } +} + +func TestPrintFileListJSON(t *testing.T) { + modified := "2025-01-01T00:00:00Z" + fileList := &agent_api.SessionFileList{ + Path: "/data", + Entries: []agent_api.SessionFileInfo{ + { + Name: "test.txt", + Path: "/data/test.txt", + IsDirectory: false, + Size: 1024, + LastModified: &modified, + }, + { + Name: "subdir", + Path: "/data/subdir", + IsDirectory: true, + }, + }, + } + + err := printFileListJSON(fileList) + require.NoError(t, err) +} + +func TestPrintFileListTable(t *testing.T) { + modified := "2025-01-01T00:00:00Z" + fileList := &agent_api.SessionFileList{ + Path: "/data", + Entries: []agent_api.SessionFileInfo{ + { + Name: "test.txt", + Path: "/data/test.txt", + IsDirectory: false, + Size: 1024, + LastModified: &modified, + }, + { + Name: "subdir", + Path: "/data/subdir", + IsDirectory: true, + }, + }, + } + + err := printFileListTable(fileList) + require.NoError(t, err) +} + +func TestPrintFileListJSON_Empty(t *testing.T) { + fileList := &agent_api.SessionFileList{ + Path: "/", + Entries: []agent_api.SessionFileInfo{}, + } + + err := printFileListJSON(fileList) + require.NoError(t, err) +} + +func TestPrintFileListTable_Empty(t *testing.T) { + fileList := &agent_api.SessionFileList{ + Path: "/", + Entries: []agent_api.SessionFileInfo{}, + } + + err := printFileListTable(fileList) + require.NoError(t, err) +} 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 91fdfbf271e..27f6f8c7477 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -68,6 +68,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newMetadataCommand()) rootCmd.AddCommand(newShowCommand()) rootCmd.AddCommand(newMonitorCommand()) + rootCmd.AddCommand(newFilesCommand()) return rootCmd } 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 92181d83ec3..045e6ee226a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -79,6 +79,13 @@ const ( CodeModelResolutionFailed = "model_resolution_failed" ) +// Error codes for file operation errors. +const ( + CodeFileNotFound = "file_not_found" + CodeFileUploadFailed = "file_upload_failed" + CodeInvalidFilePath = "invalid_file_path" +) + // 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/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index 758b34ede50..f2d9de784b2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -682,3 +682,19 @@ type StructuredOutputDefinition struct { Schema map[string]any `json:"schema"` Strict *bool `json:"strict"` } + +// SessionFileInfo represents a file or directory entry in a session. +type SessionFileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + IsDirectory bool `json:"is_dir"` + Size int64 `json:"size,omitempty"` + Mode int `json:"mode,omitempty"` + LastModified *string `json:"modified_time,omitempty"` +} + +// SessionFileList represents the response from listing session files. +type SessionFileList struct { + Path string `json:"path"` + Entries []SessionFileInfo `json:"entries"` +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go index ae189ca26c8..24b6a14a08c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go @@ -981,3 +981,292 @@ func (c *AgentClient) GetAgentContainerOperation(ctx context.Context, agentName, return &operation, nil } + +// UploadSessionFile uploads a file to a session's filesystem. +// remotePath is the destination path on the session's filesystem. +// body is the file content to upload. +func (c *AgentClient) UploadSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath, apiVersion string, + body io.ReadSeeker, +) error { + u, err := url.Parse(c.endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + query.Set("path", remotePath) + u.RawQuery = query.Encode() + + req, err := runtime.NewRequest(ctx, http.MethodPut, u.String()) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + if err := req.SetBody(streaming.NopCloser(body), "application/octet-stream"); err != nil { + return fmt.Errorf("failed to set request body: %w", err) + } + + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return runtime.NewResponseError(resp) + } + + return nil +} + +// DownloadSessionFile downloads a file from a session's filesystem. +// remotePath is the source path on the session's filesystem. +// Returns an io.ReadCloser with the file content; the caller must close it. +func (c *AgentClient) DownloadSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath, apiVersion string, +) (io.ReadCloser, error) { + u, err := url.Parse(c.endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + query.Set("path", remotePath) + u.RawQuery = query.Encode() + + req, err := runtime.NewRequest(ctx, http.MethodGet, u.String()) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + runtime.SkipBodyDownload(req) + + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + + if !runtime.HasStatusCode(resp, http.StatusOK) { + defer resp.Body.Close() + return nil, runtime.NewResponseError(resp) + } + + return resp.Body, nil +} + +// ListSessionFiles lists files in a session's filesystem. +// remotePath is the directory path to list (empty string for root). +func (c *AgentClient) ListSessionFiles( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath, apiVersion string, +) (*SessionFileList, error) { + u, err := url.Parse(c.endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files/list", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + if remotePath != "" { + query.Set("path", remotePath) + } + u.RawQuery = query.Encode() + + req, err := runtime.NewRequest(ctx, http.MethodGet, u.String()) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + + 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) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var fileList SessionFileList + if err := json.Unmarshal(respBody, &fileList); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &fileList, nil +} + +// RemoveSessionFile removes a file or directory from a session's filesystem. +// remotePath is the path to remove. +// recursive controls whether to recursively remove directories. +func (c *AgentClient) RemoveSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath string, + recursive bool, + apiVersion string, +) error { + u, err := url.Parse(c.endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + query.Set("path", remotePath) + query.Set("recursive", strconv.FormatBool(recursive)) + u.RawQuery = query.Encode() + + req, err := runtime.NewRequest(ctx, http.MethodDelete, u.String()) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent) { + return runtime.NewResponseError(resp) + } + + return nil +} + +// MkdirSessionFile creates a directory in a session's filesystem. +func (c *AgentClient) MkdirSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath string, + apiVersion string, +) error { + u, err := url.Parse(c.endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files/mkdir", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + u.RawQuery = query.Encode() + + body, err := json.Marshal(map[string]string{"path": remotePath}) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, u.String()) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Raw().Header.Set("Content-Type", "application/json") + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(body)), "application/json"); err != nil { + return fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated, http.StatusNoContent) { + return runtime.NewResponseError(resp) + } + + return nil +} + +// StatSessionFile returns file/directory metadata from a session's filesystem. +func (c *AgentClient) StatSessionFile( + ctx context.Context, + agentName, agentVersion, sessionID, remotePath, apiVersion string, +) (*SessionFileInfo, error) { + u, err := url.Parse(c.endpoint) + if err != nil { + return nil, fmt.Errorf("invalid endpoint URL: %w", err) + } + + u.Path += fmt.Sprintf( + "/agents/%s/versions/%s/sessions/%s/files/stat", + agentName, agentVersion, sessionID, + ) + + query := u.Query() + query.Set("api-version", apiVersion) + query.Set("path", remotePath) + u.RawQuery = query.Encode() + + req, err := runtime.NewRequest(ctx, http.MethodGet, u.String()) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Raw().Header.Set("Foundry-Features", "HostedAgents=V1Preview") + + 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) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var fileInfo SessionFileInfo + if err := json.Unmarshal(respBody, &fileInfo); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &fileInfo, nil +}