diff --git a/.dockerignore b/.dockerignore index 10440ecb..2c3e3eca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,4 +12,6 @@ credentials/ **/.next **/node_modules -config \ No newline at end of file +config + +.dist \ No newline at end of file diff --git a/.gitignore b/.gitignore index 09deeb7b..62d8a045 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ config/**.yaml screenshots -**/tmp \ No newline at end of file +**/tmp + +.dist \ No newline at end of file diff --git a/dist/npiai-0.1.0-py3-none-any.whl b/dist/npiai-0.1.0-py3-none-any.whl deleted file mode 100644 index 04febfec..00000000 Binary files a/dist/npiai-0.1.0-py3-none-any.whl and /dev/null differ diff --git a/dist/npiai-0.1.0.tar.gz b/dist/npiai-0.1.0.tar.gz deleted file mode 100644 index 6ff05ed0..00000000 Binary files a/dist/npiai-0.1.0.tar.gz and /dev/null differ diff --git a/npiai/app/github/npi.yml b/npiai/app/github/npi.yml index 1b5d63f9..a3e21305 100644 --- a/npiai/app/github/npi.yml +++ b/npiai/app/github/npi.yml @@ -6,9 +6,9 @@ env: dependencies: - name: python version: "^3.10" + - name: npiai + version: "0.1.1" - name: openai version: "^1.30.3" - - name: termcolor - version: "^2.4.0" - name: pygithub version: "^2.3.0" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index dfabec70..17a77a6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "npiai" -version = "0.1.0" +version = "0.1.1" description = "" authors = ["wenfeng "] readme = "README.md" diff --git a/server/model/tool.go b/server/model/tool.go index c33be7d4..8cb3feb5 100644 --- a/server/model/tool.go +++ b/server/model/tool.go @@ -108,10 +108,10 @@ type ToolFunctionSpec struct { Functions []Function `json:"functions" bson:"functions"` } -type Language int +type Language string const ( - Python = Language(1) + Python = Language("python") ) type Runtime struct { @@ -126,17 +126,17 @@ type Dependency struct { } type Function struct { - Name string `json:"name" bson:"name"` - Description string `json:"description" bson:"description"` - Parameters []Parameter `json:"parameters" bson:"parameters"` - FewShots []FewShot `json:"few_shots" bson:"few_shots"` + Name string `json:"name" bson:"name"` + Description string `json:"description" bson:"description"` + Parameters Parameters `json:"parameters" bson:"parameters"` + FewShots []FewShot `json:"fewShots" bson:"few_shots"` } func (f *Function) Arguments(lan Language) string { switch lan { case Python: args := "" - for _, para := range f.Parameters { + for _, para := range f.Parameters.Properties { args += fmt.Sprintf("%s=event['%s'], ", para.Name, para.Name) } return strings.TrimRight(args, ", ") @@ -146,15 +146,11 @@ func (f *Function) Arguments(lan Language) string { func (f *Function) Schema() map[string]interface{} { properties := map[string]interface{}{} - var required []string - for _, para := range f.Parameters { + for _, para := range f.Parameters.Properties { properties[para.Name] = map[string]string{ "type": para.Type.Name(), "description": para.Description, } - if para.Required { - required = append(required, para.Name) - } } schema := map[string]interface{}{ @@ -165,7 +161,7 @@ func (f *Function) Schema() map[string]interface{} { "parameters": map[string]interface{}{ "type": "object", "properties": properties, - "required": required, + "required": f.Parameters.Required, }, }, } @@ -190,7 +186,7 @@ func (f *Function) OpenAPISchema() *v3.PathItem { content := orderedmap.New[string, *v3.MediaType]() properties := orderedmap.New[string, *base.SchemaProxy]() - for _, para := range f.Parameters { + for _, para := range f.Parameters.Properties { properties.Set(para.Name, base.CreateSchemaProxy(&base.Schema{ Type: []string{para.Type.Name()}, Description: para.Description, @@ -219,7 +215,7 @@ func (f *Function) OpenAPISchema() *v3.PathItem { return item } -type ParameterType int +type ParameterType string func (pt ParameterType) Name() string { switch pt { @@ -240,20 +236,25 @@ func (pt ParameterType) Name() string { } const ( - String = ParameterType(1) - Int = ParameterType(2) - Float = ParameterType(3) - Bool = ParameterType(4) - Map = ParameterType(5) - List = ParameterType(6) + String = ParameterType("string") + Int = ParameterType("integer") + Float = ParameterType("number") + Bool = ParameterType("bool") + Map = ParameterType("object") + List = ParameterType("array") ) -type Parameter struct { +type Parameters struct { + Description string `json:"description" bson:"description"` + Type ParameterType `json:"type" bson:"type"` + Required []string `json:"required" bson:"required"` + Properties map[string]Property `json:"properties" bson:"properties"` +} + +type Property struct { Name string `json:"name" bson:"name"` Description string `json:"description" bson:"description"` Type ParameterType `json:"type" bson:"type"` - Required bool `json:"required" bson:"required"` - Default interface{} `json:"default" bson:"default"` } type FewShot struct { diff --git a/server/reconcile/tool.go b/server/reconcile/tool.go index 400426b2..beed9113 100644 --- a/server/reconcile/tool.go +++ b/server/reconcile/tool.go @@ -2,12 +2,19 @@ package reconcile import ( "context" + "encoding/json" + "errors" + "github.com/mattn/go-isatty" + "github.com/npi-ai/npi/server/api" "github.com/npi-ai/npi/server/db" "github.com/npi-ai/npi/server/log" "github.com/npi-ai/npi/server/model" "github.com/npi-ai/npi/server/service" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" + "os" + "os/exec" + "strings" "time" ) @@ -81,16 +88,63 @@ func (tc *toolReconciler) Start(ctx context.Context) error { return nil } -func (tc *toolReconciler) BuildingHandler(ctx context.Context, ws model.ToolInstance) error { - imageURI, err := tc.builderSvc.Build(ctx, ws.Metadata) +func (tc *toolReconciler) BuildingHandler(ctx context.Context, toolInstance model.ToolInstance) error { + imageURI, err := tc.builderSvc.Build(ctx, toolInstance.Metadata) if err != nil { return err } + + args := []string{"run", "--rm", "--entrypoint", "poetry", imageURI, "run", "python", "main.py", "spec"} + // get tool spec + if isatty.IsTerminal(os.Stdin.Fd()) { + //args = append([]string{"-it"}, args...) + } + cmd := exec.Command("docker", args...) + output, err := cmd.CombinedOutput() + if err != nil { + log.Warn().Str("output", string(output)).Msg("Failed to build docker image") + return api.ErrInternal. + WithMessage("Failed to build docker image").WithError(err) + } + + strings.Split(string(output), "\n") + success := false + var md model.ToolMetadata + var spec model.ToolFunctionSpec + for _, line := range strings.Split(string(output), "\n") { + if strings.HasPrefix(line, "{\"kind\"") { + md, spec, err = parseToolSpec([]byte(line)) + if err != nil { + return api.ErrInternal. + WithMessage("Failed to parse tool spec").WithError(err) + } + + success = true + break + } + } + if !success { + return api.ErrInternal.WithMessage("Failed to get tool spec") + } + + toolInstance.Metadata.Name = md.Name + toolInstance.Metadata.Description = md.Description + toolInstance.FunctionSpec = spec + // upload image + log.Info().Str("image", imageURI).Msg("Uploading docker image") + cmd = exec.Command("docker", "push", imageURI) + if output, err = cmd.CombinedOutput(); err != nil { + log.Warn().Str("output", string(output)).Msg("Failed to upload docker image") + return api.ErrInternal. + WithMessage("Failed to upload docker image").WithError(err) + } // update tool instance _, err = tc.coll.UpdateOne(ctx, - bson.M{"_id": ws.ID}, + bson.M{"_id": toolInstance.ID}, bson.M{ "$set": bson.M{ + "metadata": toolInstance.Metadata, + "spec": toolInstance.FunctionSpec, "image": imageURI, "updated_at": time.Now(), }, @@ -112,3 +166,37 @@ func (tc *toolReconciler) PausingHandler(ctx context.Context, ws model.ToolInsta println("pausing") return nil } + +func parseToolSpec(data []byte) (model.ToolMetadata, model.ToolFunctionSpec, error) { + m := map[string]interface{}{} + md := model.ToolMetadata{} + spec := model.ToolFunctionSpec{} + if err := json.Unmarshal(data, &m); err != nil { + return md, spec, err + } + if v, ok := m["metadata"]; ok { + mdMap := v.(map[string]interface{}) + value, exist := mdMap["name"] + if !exist { + return md, spec, errors.New("metadata.name not found") + } + md.Name = value.(string) + + value, exist = mdMap["description"] + if !exist { + return md, spec, errors.New("metadata.description not found") + } + md.Description = value.(string) + } + if v, ok := m["spec"]; ok { + d, err := json.Marshal(v) + if err != nil { + return md, spec, errors.New("failed to parse function spec") + } + if err = json.Unmarshal(d, &spec); err != nil { + return md, spec, err + } + // TODO merge dependencies + } + return md, spec, nil +} diff --git a/server/reconcile/tool.spec.json b/server/reconcile/tool.spec.json new file mode 100644 index 00000000..d9b414e4 --- /dev/null +++ b/server/reconcile/tool.spec.json @@ -0,0 +1 @@ +{"kind": "Function", "metadata": {"name": "github", "description": "Manage GitHub issues and pull requests", "provider": "private"}, "spec": {"runtime": {"language": "python", "version": "3.11"}, "dependencies": [{"name": "npiai", "version": "0.1.0"}], "functions": [{"description": "Add a comment to the target issue.", "name": "add_issue_comment", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}, "number": {"description": "Issue number", "type": "integer"}, "body": {"description": "Body of the comment in markdown format", "type": "string"}}, "required": ["repo", "number", "body"], "type": "object"}, "fewShots": null}, {"description": "Add a comment to the target pull request.", "name": "add_pull_request_comment", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}, "number": {"description": "Pull request number", "type": "integer"}, "body": {"description": "Body of the comment in markdown format", "type": "string"}}, "required": ["repo", "number", "body"], "type": "object"}, "fewShots": null}, {"description": "Create an issue under the given repository.", "name": "create_issue", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}, "title": {"description": "Title of the issue", "type": "string"}, "body": {"description": "Body of the issue in markdown format", "type": "string"}, "labels": {"default": null, "description": "List of labels to add to the issue", "items": {"type": "string"}, "type": "array"}, "assignees": {"default": null, "description": "List of users to assign to the issue", "items": {"type": "string"}, "type": "array"}}, "required": ["repo", "title", "body"], "type": "object"}, "fewShots": null}, {"description": "Create a pull request under the given repository.", "name": "create_pull_request", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}, "title": {"description": "Title of the pull request", "type": "string"}, "body": {"description": "Body of the pull request", "type": "string"}, "base": {"description": "Base branch of the pull request, i.e., the branch to merge the pull request into", "type": "string"}, "head": {"description": "Head branch of the pull request, i.e., the branch with your changes", "type": "string"}, "is_draft": {"default": false, "description": "Whether the pull request is a draft or not", "type": "boolean"}, "labels": {"default": null, "description": "List of labels to add to the pull request", "items": {"type": "string"}, "type": "array"}, "assignees": {"default": null, "description": "List of users to assign to the pull request", "items": {"type": "string"}, "type": "array"}}, "required": ["repo", "title", "body", "base", "head"], "type": "object"}, "fewShots": null}, {"description": "Edit an existing issue. You can also close or reopen an issue by specifying the state parameter.", "name": "edit_issue", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}, "number": {"description": "Issue number", "type": "integer"}, "title": {"default": null, "description": "Title of the issue", "type": "string"}, "body": {"default": null, "description": "Body of the issue in markdown format", "type": "string"}, "labels": {"default": null, "description": "List of labels to add to the issue", "items": {"type": "string"}, "type": "array"}, "assignees": {"default": null, "description": "List of users to assign to the issue", "items": {"type": "string"}, "type": "array"}, "state": {"default": null, "description": "Whether the issue is open or closed", "enum": ["open", "closed"], "type": "string"}}, "required": ["repo", "number"], "type": "object"}, "fewShots": null}, {"description": "Edit an existing pull request. You can also close or reopen a pull request by specifying the state parameter.", "name": "edit_pull_request", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}, "number": {"description": "Pull request number", "type": "integer"}, "title": {"default": null, "description": "Title of the pull request", "type": "string"}, "body": {"default": null, "description": "Body of the pull request in markdown format", "type": "string"}, "base": {"default": null, "description": "Base branch of the pull request, i.e., the branch with your changes", "type": "string"}, "labels": {"default": null, "description": "List of labels to add to the pull request", "items": {"type": "string"}, "type": "array"}, "assignees": {"default": null, "description": "List of users to assign to the pull request", "items": {"type": "string"}, "type": "array"}, "state": {"default": null, "description": "Whether the pull request is open or closed", "enum": ["open", "closed"], "type": "string"}}, "required": ["repo", "number"], "type": "object"}, "fewShots": null}, {"description": "Fork a repository on GitHub.", "name": "fork", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}}, "required": ["repo"], "type": "object"}, "fewShots": null}, {"description": "Get an issue from the given repository.", "name": "get_issue", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}, "number": {"description": "Issue number", "type": "integer"}}, "required": ["repo", "number"], "type": "object"}, "fewShots": null}, {"description": "Get a pull request from the given repository.", "name": "get_pull_request", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}, "number": {"description": "Pull request number", "type": "integer"}}, "required": ["repo", "number"], "type": "object"}, "fewShots": null}, {"description": "Search for issues or pull requests on GitHub.", "name": "search_issue_pr", "parameters": {"properties": {"query": {"description": "Search query to search for GitHub issues and pull requests. Below are some query examples:\n1. Search for all issues containing \"performance\" in the repository npi/npi: `is:issue repo:npi/npi performance`\n2. Search for open pull requests in repository npi/npi: `is:pr is:open repo:npi/npi`", "type": "string"}, "max_results": {"description": "Maximum number of results to return", "type": "integer"}}, "required": ["query", "max_results"], "type": "object"}, "fewShots": null}, {"description": "Search for repositories on GitHub.", "name": "search_repositories", "parameters": {"properties": {"query": {"description": "Search query to search for GitHub repositories. Below are some query examples:\n1. Search for all repositories that contain \"test\" and use Python: `test language:python`\n2. Search for repositories with more than 1000 stars: `stars:>1000`", "type": "string"}, "max_results": {"description": "Maximum number of results to return", "type": "integer"}}, "required": ["query", "max_results"], "type": "object"}, "fewShots": null}, {"description": "Star a repository on GitHub.", "name": "star", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}}, "required": ["repo"], "type": "object"}, "fewShots": null}, {"description": "Subscribe to notifications (a.k.a. watch) for activity in a repository on GitHub.", "name": "watch", "parameters": {"properties": {"repo": {"description": "Name of the repository in format {owner}/{repo}", "type": "string"}}, "required": ["repo"], "type": "object"}, "fewShots": null}]}} \ No newline at end of file diff --git a/server/reconcile/tool_test.go b/server/reconcile/tool_test.go new file mode 100644 index 00000000..1d9608a6 --- /dev/null +++ b/server/reconcile/tool_test.go @@ -0,0 +1,17 @@ +package reconcile + +import ( + "os" + "testing" +) + +func Test_ParseToolSpec(t *testing.T) { + data, err := os.ReadFile("tool.spec.json") + if err != nil { + t.Fatal(err) + } + _, _, err = parseToolSpec(data) + if err != nil { + t.Fatal(err) + } +} diff --git a/server/scripts/entrypoint.py b/server/scripts/entrypoint.py index d5f875d9..af23e1aa 100644 --- a/server/scripts/entrypoint.py +++ b/server/scripts/entrypoint.py @@ -2,11 +2,13 @@ import sys import importlib import time +import asyncio +import json def print_tool_spec(): # Add the directory containing the file to sys.path - module_dir, module_name = os.path.split('/npiai/tools/app.py') + module_dir, module_name = os.path.split('%s') module_name = os.path.splitext(module_name)[0] # Remove the .py extension if module_dir not in sys.path: @@ -15,9 +17,9 @@ def print_tool_spec(): # Now you can import the module using its name module = importlib.import_module(module_name) # Create an instance of the class - tool_class = getattr(module, 'GitHub') + tool_class = getattr(module, '%s') instance = tool_class() - print(instance.export()) + print(json.dumps(instance.export())) async def main(): @@ -39,4 +41,14 @@ async def main(): time.sleep(1000) if __name__ == '__main__': - print_tool_spec() + if len(sys.argv) > 1: + if sys.argv[1] == 'spec': + print_tool_spec() + elif sys.argv[1] == 'server': + asyncio.run(main()) + else: + print('Usage: python entrypoint.py spec|server') + sys.exit(1) + else: + print('Usage: python entrypoint.py spec|server') + sys.exit(1) diff --git a/server/service/builder.go b/server/service/builder.go index d67b9573..d6239042 100644 --- a/server/service/builder.go +++ b/server/service/builder.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/npi-ai/npi/server/api" "github.com/npi-ai/npi/server/db" @@ -46,6 +47,7 @@ type ToolDefine struct { } func (bs *BuilderService) Build(ctx context.Context, md model.ToolMetadata) (string, error) { + start := time.Now() workDir := filepath.Join(bs.rootDir, utils.GenerateRandomString(12, false, false)) if err := os.MkdirAll(workDir, os.ModePerm); err != nil { return "", api.ErrInternal. @@ -142,10 +144,14 @@ func (bs *BuilderService) Build(ctx context.Context, md model.ToolMetadata) (str WithMessage("Failed to upload target.tar.gz to S3").WithError(err) } + log.Info().Str("id", md.ID). + Str("duration", time.Now().Sub(start).String()). + Msg("target file has been uploaded to S3") + // 6. build docker image imageURI := fmt.Sprintf("%s/cloud/tools:%s", bs.dockerRegistry, md.ID) cmd = exec.Command("docker", "buildx", "build", "--platform", "linux/amd64", - "-t", imageURI, ".", "--push") + "-t", imageURI, ".", "--load") cmd.Dir = targetDir if output, err := cmd.CombinedOutput(); err != nil { println(string(output)) @@ -153,6 +159,10 @@ func (bs *BuilderService) Build(ctx context.Context, md model.ToolMetadata) (str WithMessage("Failed to build docker image").WithError(err) } + log.Info().Str("id", md.ID). + Str("duration", time.Now().Sub(start).String()). + Msg("docker image has been built successfully") + // 7. cleanup if err = os.RemoveAll(workDir); err != nil { log.Warn().Str("path", workDir).Msg("Failed to remove work directory") @@ -172,9 +182,9 @@ var ( entrypointTemplate = `import os import sys import importlib -import tokenize -import asyncio import time +import asyncio +import json def print_tool_spec(): @@ -190,7 +200,7 @@ def print_tool_spec(): # Create an instance of the class tool_class = getattr(module, '%s') instance = tool_class() - print(instance().export()) + print(json.dumps(instance.export())) async def main(): @@ -209,15 +219,22 @@ async def main(): async with instance as i: # await i.wait() TODO add this method to BaseApp class + print("Tool server started") time.sleep(1000) - if __name__ == '__main__': if len(sys.argv) > 1: - if sys.argv[1] == "spec": + if sys.argv[1] == 'spec': print_tool_spec() - elif sys.argv[1] == "main": + elif sys.argv[1] == 'server': asyncio.run(main()) + else: + print('Usage: python entrypoint.py spec|server') + sys.exit(1) + else: + print('Usage: python entrypoint.py spec|server') + sys.exit(1) + ` dockerfileTemplate = `FROM npiai/python:3.12 @@ -228,7 +245,7 @@ SHELL ["/bin/bash", "-c"] WORKDIR /npiai/tools RUN poetry install -ENTRYPOINT ["poetry", "run", "python", "/npiai/tools/main.py"] +ENTRYPOINT ["poetry", "run", "python", "main.py", "server"] ` poetryTemplate = `[tool.poetry] package-mode = false