From bf78c5210f78a4ba2f633af634dffead5cbb885a Mon Sep 17 00:00:00 2001 From: Steve Hetzel Date: Thu, 30 Jan 2025 21:49:20 -0700 Subject: [PATCH] feat: remove v1 agent create and generate spec commands --- command-snapshot.json | 28 -- messages/agent.create-v2.md | 51 ---- messages/agent.create.md | 40 ++- messages/agent.generate.spec-v2.md | 121 --------- messages/agent.generate.spec.md | 96 ++++++- schemas/agent-create.json | 134 ++++++++- schemas/agent-create__v2.json | 145 ---------- schemas/agent-generate-spec.json | 60 ++++- schemas/agent-generate-spec__v2.json | 85 ------ src/commands/agent/create-v2.ts | 197 -------------- src/commands/agent/create.ts | 207 ++++++++++---- src/commands/agent/generate/spec-v2.ts | 313 --------------------- src/commands/agent/generate/spec.ts | 328 +++++++++++++++-------- test/commands/agent/generate/spec.nut.ts | 4 +- 14 files changed, 667 insertions(+), 1142 deletions(-) delete mode 100644 messages/agent.create-v2.md delete mode 100644 messages/agent.generate.spec-v2.md delete mode 100644 schemas/agent-create__v2.json delete mode 100644 schemas/agent-generate-spec__v2.json delete mode 100644 src/commands/agent/create-v2.ts delete mode 100644 src/commands/agent/generate/spec-v2.ts diff --git a/command-snapshot.json b/command-snapshot.json index beff256..12a663e 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -3,14 +3,6 @@ "alias": [], "command": "agent:create", "flagAliases": [], - "flagChars": ["f", "n", "o"], - "flags": ["api-version", "flags-dir", "json", "name", "spec", "target-org"], - "plugin": "@salesforce/plugin-agent" - }, - { - "alias": [], - "command": "agent:create-v2", - "flagAliases": [], "flagChars": ["o"], "flags": [ "agent-api-name", @@ -29,26 +21,6 @@ "alias": [], "command": "agent:generate:spec", "flagAliases": [], - "flagChars": ["d", "f", "o", "t"], - "flags": [ - "api-version", - "company-description", - "company-name", - "company-website", - "file-name", - "flags-dir", - "json", - "output-dir", - "role", - "target-org", - "type" - ], - "plugin": "@salesforce/plugin-agent" - }, - { - "alias": [], - "command": "agent:generate:spec-v2", - "flagAliases": [], "flagChars": ["o", "t"], "flags": [ "agent-user", diff --git a/messages/agent.create-v2.md b/messages/agent.create-v2.md deleted file mode 100644 index 9507c01..0000000 --- a/messages/agent.create-v2.md +++ /dev/null @@ -1,51 +0,0 @@ -# summary - -Create an agent in your org using a local agent spec file. - -# description - -Before you run this command, you must first generate an agent spec file by running the "agent generate spec" CLI command, which outputs a YAML file with the agent properties and list of AI-generated topics. Topics define the range of jobs the agent can handle. Then specify the generated agent spec file to this command using the --spec flag, along with the name (label) of the new agent using the --agent-name flag. - -When this command finishes, your org contains the new agent, which you can then edit in the Agent Builder UI. The new agent's topics are the same as the ones listed in the agent spec file. The agent might also have some AI-generated actions. This command also retrieves all the metadata files associated with the new agent to your local Salesforce DX project. - -Use the --preview flag to review what the agent looks like without actually saving it in your org. Rather, the command creates a JSON file with all the agent details in the current directory. - -To open the new agent in your org's Agent Builder UI, run this command: "sf org open agent --name ". - -# flags.spec.summary - -Path to an agent spec file. - -# flags.preview.summary - -Preview the agent without saving it in your org. - -# flags.agent-name.summary - -Name (label) of the new agent. - -# flags.agent-api-name.summary - -API name of the new agent; if not specified, the API name is derived from the agent name (label); the API name must not exist in the org. - -# flags.planner-id.summary - -An existing GenAiPlanner ID to associate with the agent. - -# error.missingRequiredFlags - -Missing required flags: %s - -# error.missingRequiredSpecProperties - -Missing required spec file properties: %s - -# examples - -- Create an agent called "ResortManager" in an org with alias "my-org" using the specified agent spec file: - - <%= config.bin %> <%= command.id %> --agent-name ResortManager --spec specs/resortManagerAgent.yaml --target-org my-org - -- Preview the creation of an agent called "ResortManager" and use your default org: - - <%= config.bin %> <%= command.id %> --agent-name ResortManager --spec specs/resortManagerAgent.yaml --preview diff --git a/messages/agent.create.md b/messages/agent.create.md index 806130e..9507c01 100644 --- a/messages/agent.create.md +++ b/messages/agent.create.md @@ -1,12 +1,14 @@ # summary -Create an agent in your org from a local agent spec file. +Create an agent in your org using a local agent spec file. # description -To generate an agent spec file, run the "agent generate spec" CLI command, which outputs a JSON file with the list of jobs and descriptions that the new agent can perform. Then specify this generated spec file to the --spec flag of this command, along with the name of the new agent. +Before you run this command, you must first generate an agent spec file by running the "agent generate spec" CLI command, which outputs a YAML file with the agent properties and list of AI-generated topics. Topics define the range of jobs the agent can handle. Then specify the generated agent spec file to this command using the --spec flag, along with the name (label) of the new agent using the --agent-name flag. -When this command finishes, your org contains the new agent, which you can then edit in the Agent Builder UI. The new agent already has a list of topics and actions that were automatically created from the list of jobs in the provided agent spec file. This command also retrieves all the metadata files associated with the new agent to your local DX project. +When this command finishes, your org contains the new agent, which you can then edit in the Agent Builder UI. The new agent's topics are the same as the ones listed in the agent spec file. The agent might also have some AI-generated actions. This command also retrieves all the metadata files associated with the new agent to your local Salesforce DX project. + +Use the --preview flag to review what the agent looks like without actually saving it in your org. Rather, the command creates a JSON file with all the agent details in the current directory. To open the new agent in your org's Agent Builder UI, run this command: "sf org open agent --name ". @@ -14,12 +16,36 @@ To open the new agent in your org's Agent Builder UI, run this command: "sf org Path to an agent spec file. -# flags.name.summary +# flags.preview.summary + +Preview the agent without saving it in your org. + +# flags.agent-name.summary + +Name (label) of the new agent. + +# flags.agent-api-name.summary + +API name of the new agent; if not specified, the API name is derived from the agent name (label); the API name must not exist in the org. + +# flags.planner-id.summary -API name of the new agent. +An existing GenAiPlanner ID to associate with the agent. + +# error.missingRequiredFlags + +Missing required flags: %s + +# error.missingRequiredSpecProperties + +Missing required spec file properties: %s # examples -- Create an agent called "CustomerSupportAgent" in an org with alias "my-org" using the specified agent spec file: +- Create an agent called "ResortManager" in an org with alias "my-org" using the specified agent spec file: + + <%= config.bin %> <%= command.id %> --agent-name ResortManager --spec specs/resortManagerAgent.yaml --target-org my-org + +- Preview the creation of an agent called "ResortManager" and use your default org: - <%= config.bin %> <%= command.id %> --name CustomerSupportAgent --spec ./config/agentSpec.json --target-org my-org + <%= config.bin %> <%= command.id %> --agent-name ResortManager --spec specs/resortManagerAgent.yaml --preview diff --git a/messages/agent.generate.spec-v2.md b/messages/agent.generate.spec-v2.md deleted file mode 100644 index 7057b26..0000000 --- a/messages/agent.generate.spec-v2.md +++ /dev/null @@ -1,121 +0,0 @@ -# summary - -Generate an agent spec, which is a YAML file that captures what an agent can do. - -# description - -Before you use Salesforce CLI to create an agent in your org, you must first generate an agent spec with this command. An agent spec is a YAML-formatted file that contains information about the agent, such as its role and company description, and then an AI-generated list of topics based on this information. Topics define the range of jobs your agent can handle. - -Use flags, such as --role and --company-description, to provide details about your company and the role that the agent plays in your company. If you prefer, you can also be prompted for the information. Upon command execution, the large language model (LLM) associated with your org uses the information you provided to generate a list of topics for the agent. Because the LLM uses the company and role information to generate the topics, we recommend that you provide accurate and specific details so the LLM generates the best and most relevant topics. Once generated, you can edit the spec file; for example, you can remove topics that don't apply to your agent or change the description of a particular topic. - -You can iterate the spec generation process by using the --spec flag to pass an existing agent spec file to this command, and then using the --role, --company-description, etc, flags to refine your agent properties. Iteratively improving the description of your agent allows the LLM to generate progressively better topics. - -You can also specify a custom prompt template that the agent uses, and ground the prompt template to add context and personalization to the agent's prompts. - -When your agent spec is ready, you then create the agent in your org by running the "agent create" CLI command and specifying the spec with the --spec flag. - -# flags.type.summary - -Type of agent to create. - -# flags.role.summary - -Role of the agent. - -# flags.company-name.summary - -Name of your company. - -# flags.company-description.summary - -Description of your company. - -# flags.company-website.summary - -Website URL of your company. - -# flags.output-file.summary - -Path for the generated YAML agent spec file; can be an absolute or relative path. - -# flags.max-topics.summary - -Maximum number of topics to generate in the agent spec; default is 10. - -# flags.max-topics.prompt - -Max number of topics to generate (1-30) - -# flags.prompt-template.summary - -API name of a customized prompt template to use instead of the default prompt template. - -# flags.grounding-context.summary - -Context information and personalization that's added to your prompts when using a custom prompt template. - -# flags.spec.summary - -Agent spec file, in YAML format, to use as input to the command. - -# flags.full-interview.summary - -Prompt for both required and optional flags. - -# flags.agent-user.summary - -Username of a user in your org to assign to your agent; determines what your agent can access and do. - -# flags.agent-user.prompt - -Username for agent - -# flags.enrich-logs.summary - -Adds agent conversation data to event logs so you can view all agent session activity in one place. - -# flags.enrich-logs.prompt - -Enrich event logs - -# flags.tone.summary - -Conversational style of the agent, such as how it expresses your brand personality in its messages through word choice, punctuation, and sentence structure. - -# flags.tone.prompt - -Agent conversation tone - -# flags.primary-language.summary - -Language the agent uses in conversations. - -# flags.no-prompt.summary - -Don't prompt the user to confirm spec file overwrite. - -# examples - -- Generate an agent spec in the default location and use flags to specify the agent properties, such as its role and your company details; use your default org: - - <%= config.bin %> <%= command.id %> --type customer --role "Field customer complaints and manage employee schedules." --company-name "Coral Cloud Resorts" --company-description "Provide customers with exceptional destination activities, unforgettable experiences, and reservation services." - -- Generate an agent spec by being prompted for the required agent properties and generate a maxiumum of 5 topics; write the generated file to the "specs/resortManagerSpec.yaml" file and use the org with alias "my-org": - - <%= config.bin %> <%= command.id %> --max-topics 5 --output-file specs/resortManagerAgent.yaml --target-org my-org - -- Specify an existing agent spec file called "specs/resortManagerAgent.yaml", and then overwrite it with a new version that contains newly AI-generated topics based on the updated role information passed in with the --role flag: - - <%= config.bin %> <%= command.id %> --spec specs/resortManagerAgent.yaml --output-file specs/resortManagerAgent.yaml --role "Field customer complaints, manage employee schedules, and ensure all resort operations are running smoothly" --target-org my-org - -# error.missingRequiredFlags - -Missing required flags: %s - -# confirmSpecOverwrite - -Confirm overwrite of spec file %s? - -# commandCanceled - -Command canceled by user confirmation. diff --git a/messages/agent.generate.spec.md b/messages/agent.generate.spec.md index b5e2442..7057b26 100644 --- a/messages/agent.generate.spec.md +++ b/messages/agent.generate.spec.md @@ -1,14 +1,18 @@ # summary -Generate an agent spec, which is the list of jobs that the agent performs. +Generate an agent spec, which is a YAML file that captures what an agent can do. # description -When using Salesforce CLI to create an agent in your org, the first step is to generate the local JSON-formatted agent spec file with this command. +Before you use Salesforce CLI to create an agent in your org, you must first generate an agent spec with this command. An agent spec is a YAML-formatted file that contains information about the agent, such as its role and company description, and then an AI-generated list of topics based on this information. Topics define the range of jobs your agent can handle. -An agent spec is a list of jobs and descriptions that capture what the agent can do. Use flags such as --role and --company-description to provide details about your company and the role that the agent plays in your company; you can also enter the information interactively if you prefer. When you then execute this command, the large language model (LLM) associated with your org uses the information to generate the list of jobs that the agent most likely performs. We recommend that you provide good details for --role, --company-description, etc, so that the LLM can generate the best and most relevant list of jobs and descriptions. Once generated, you can edit the spec file; for example, you can remove jobs that don't apply to your agent. +Use flags, such as --role and --company-description, to provide details about your company and the role that the agent plays in your company. If you prefer, you can also be prompted for the information. Upon command execution, the large language model (LLM) associated with your org uses the information you provided to generate a list of topics for the agent. Because the LLM uses the company and role information to generate the topics, we recommend that you provide accurate and specific details so the LLM generates the best and most relevant topics. Once generated, you can edit the spec file; for example, you can remove topics that don't apply to your agent or change the description of a particular topic. -When your agent spec is ready, you then create the agent in your org by specifying the agent spec file to the --job-spec flag of the "agent create" CLI command. +You can iterate the spec generation process by using the --spec flag to pass an existing agent spec file to this command, and then using the --role, --company-description, etc, flags to refine your agent properties. Iteratively improving the description of your agent allows the LLM to generate progressively better topics. + +You can also specify a custom prompt template that the agent uses, and ground the prompt template to add context and personalization to the agent's prompts. + +When your agent spec is ready, you then create the agent in your org by running the "agent create" CLI command and specifying the spec with the --spec flag. # flags.type.summary @@ -30,20 +34,88 @@ Description of your company. Website URL of your company. -# flags.output-dir.summary +# flags.output-file.summary + +Path for the generated YAML agent spec file; can be an absolute or relative path. + +# flags.max-topics.summary + +Maximum number of topics to generate in the agent spec; default is 10. + +# flags.max-topics.prompt + +Max number of topics to generate (1-30) + +# flags.prompt-template.summary + +API name of a customized prompt template to use instead of the default prompt template. + +# flags.grounding-context.summary + +Context information and personalization that's added to your prompts when using a custom prompt template. + +# flags.spec.summary + +Agent spec file, in YAML format, to use as input to the command. + +# flags.full-interview.summary + +Prompt for both required and optional flags. + +# flags.agent-user.summary + +Username of a user in your org to assign to your agent; determines what your agent can access and do. + +# flags.agent-user.prompt -Directory where the agent spec file is written; can be an absolute or relative path. +Username for agent -# flags.file-name.summary +# flags.enrich-logs.summary -Name of the generated agent spec file. +Adds agent conversation data to event logs so you can view all agent session activity in one place. + +# flags.enrich-logs.prompt + +Enrich event logs + +# flags.tone.summary + +Conversational style of the agent, such as how it expresses your brand personality in its messages through word choice, punctuation, and sentence structure. + +# flags.tone.prompt + +Agent conversation tone + +# flags.primary-language.summary + +Language the agent uses in conversations. + +# flags.no-prompt.summary + +Don't prompt the user to confirm spec file overwrite. # examples -- Create an agent spec for your default org in the default location and use flags to specify the agent's role and your company details: +- Generate an agent spec in the default location and use flags to specify the agent properties, such as its role and your company details; use your default org: + + <%= config.bin %> <%= command.id %> --type customer --role "Field customer complaints and manage employee schedules." --company-name "Coral Cloud Resorts" --company-description "Provide customers with exceptional destination activities, unforgettable experiences, and reservation services." + +- Generate an agent spec by being prompted for the required agent properties and generate a maxiumum of 5 topics; write the generated file to the "specs/resortManagerSpec.yaml" file and use the org with alias "my-org": + + <%= config.bin %> <%= command.id %> --max-topics 5 --output-file specs/resortManagerAgent.yaml --target-org my-org + +- Specify an existing agent spec file called "specs/resortManagerAgent.yaml", and then overwrite it with a new version that contains newly AI-generated topics based on the updated role information passed in with the --role flag: + + <%= config.bin %> <%= command.id %> --spec specs/resortManagerAgent.yaml --output-file specs/resortManagerAgent.yaml --role "Field customer complaints, manage employee schedules, and ensure all resort operations are running smoothly" --target-org my-org + +# error.missingRequiredFlags + +Missing required flags: %s + +# confirmSpecOverwrite - <%= config.bin %> <%= command.id %> --type customer --role "Assist users in navigating and managing bookings" --company-name "Coral Cloud" --company-description "Resort that manages guests and their reservations and experiences" +Confirm overwrite of spec file %s? -- Create an agent spec by being prompted for role and company details interactively; write the generated file to the "specs" directory and use the org with alias "my-org": +# commandCanceled - <%= config.bin %> <%= command.id %> --output-dir specs --target-org my-org +Command canceled by user confirmation. diff --git a/schemas/agent-create.json b/schemas/agent-create.json index 9196a7f..a6172a9 100644 --- a/schemas/agent-create.json +++ b/schemas/agent-create.json @@ -4,18 +4,142 @@ "definitions": { "AgentCreateResult": { "type": "object", + "additionalProperties": false, "properties": { + "previewFilePath": { + "type": "string" + }, "isSuccess": { "type": "boolean" }, "errorMessage": { "type": "string" + }, + "agentId": { + "type": "object", + "properties": { + "botId": { + "type": "string" + }, + "botVersionId": { + "type": "string" + }, + "plannerId": { + "type": "string" + } + }, + "required": ["botId", "botVersionId", "plannerId"], + "additionalProperties": false, + "description": "If the agent was created with saveAgent=true, these are the IDs that make up an agent; Bot, BotVersion, and GenAiPlanner metadata." + }, + "agentDefinition": { + "type": "object", + "properties": { + "agentDescription": { + "type": "string" + }, + "topics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "topic": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "actionName": { + "type": "string" + }, + "exampleOutput": { + "type": "string" + }, + "actionDescription": { + "type": "string" + }, + "inputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "inputName": { + "type": "string" + }, + "inputDataType": { + "type": "string" + }, + "inputDescription": { + "type": "string" + } + }, + "required": ["inputName", "inputDataType", "inputDescription"], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 1 + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "outputName": { + "type": "string" + }, + "outputDataType": { + "type": "string" + }, + "outputDescription": { + "type": "string" + } + }, + "required": ["outputName", "outputDataType", "outputDescription"], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 1 + } + }, + "required": ["actionName", "exampleOutput", "actionDescription", "inputs", "outputs"], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 1 + }, + "instructions": { + "type": "array", + "items": { + "type": "string" + } + }, + "classificationDescription": { + "type": "string" + } + }, + "required": ["scope", "topic", "actions", "instructions", "classificationDescription"], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 1 + }, + "sampleUtterances": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["agentDescription", "topics", "sampleUtterances"], + "additionalProperties": false } }, - "required": [ - "isSuccess" - ], - "additionalProperties": false + "required": ["agentDefinition", "isSuccess"] } } -} \ No newline at end of file +} diff --git a/schemas/agent-create__v2.json b/schemas/agent-create__v2.json deleted file mode 100644 index a6172a9..0000000 --- a/schemas/agent-create__v2.json +++ /dev/null @@ -1,145 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/AgentCreateResult", - "definitions": { - "AgentCreateResult": { - "type": "object", - "additionalProperties": false, - "properties": { - "previewFilePath": { - "type": "string" - }, - "isSuccess": { - "type": "boolean" - }, - "errorMessage": { - "type": "string" - }, - "agentId": { - "type": "object", - "properties": { - "botId": { - "type": "string" - }, - "botVersionId": { - "type": "string" - }, - "plannerId": { - "type": "string" - } - }, - "required": ["botId", "botVersionId", "plannerId"], - "additionalProperties": false, - "description": "If the agent was created with saveAgent=true, these are the IDs that make up an agent; Bot, BotVersion, and GenAiPlanner metadata." - }, - "agentDefinition": { - "type": "object", - "properties": { - "agentDescription": { - "type": "string" - }, - "topics": { - "type": "array", - "items": { - "type": "object", - "properties": { - "scope": { - "type": "string" - }, - "topic": { - "type": "string" - }, - "actions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "actionName": { - "type": "string" - }, - "exampleOutput": { - "type": "string" - }, - "actionDescription": { - "type": "string" - }, - "inputs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "inputName": { - "type": "string" - }, - "inputDataType": { - "type": "string" - }, - "inputDescription": { - "type": "string" - } - }, - "required": ["inputName", "inputDataType", "inputDescription"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1 - }, - "outputs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "outputName": { - "type": "string" - }, - "outputDataType": { - "type": "string" - }, - "outputDescription": { - "type": "string" - } - }, - "required": ["outputName", "outputDataType", "outputDescription"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1 - } - }, - "required": ["actionName", "exampleOutput", "actionDescription", "inputs", "outputs"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1 - }, - "instructions": { - "type": "array", - "items": { - "type": "string" - } - }, - "classificationDescription": { - "type": "string" - } - }, - "required": ["scope", "topic", "actions", "instructions", "classificationDescription"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1 - }, - "sampleUtterances": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["agentDescription", "topics", "sampleUtterances"], - "additionalProperties": false - } - }, - "required": ["agentDefinition", "isSuccess"] - } - } -} diff --git a/schemas/agent-generate-spec.json b/schemas/agent-generate-spec.json index 38d003f..31b1786 100644 --- a/schemas/agent-generate-spec.json +++ b/schemas/agent-generate-spec.json @@ -4,21 +4,69 @@ "definitions": { "AgentCreateSpecResult": { "type": "object", + "additionalProperties": false, "properties": { + "topics": { + "$ref": "#/definitions/DraftAgentTopics" + }, + "agentType": { + "type": "string", + "enum": ["customer", "internal"], + "description": "Internal type is copilots; used by customers' employees. Customer type is agents; used by customers' customers." + }, + "role": { + "type": "string" + }, + "companyName": { + "type": "string" + }, + "companyDescription": { + "type": "string" + }, + "companyWebsite": { + "type": "string" + }, + "maxNumOfTopics": { + "type": "number", + "description": "The maximum number of topics to create in the spec. Default is 10." + }, + "promptTemplateName": { + "type": "string", + "description": "Developer name of the prompt template." + }, + "groundingContext": { + "type": "string", + "description": "Context info to be used in customized prompt template" + }, "isSuccess": { "type": "boolean" }, "errorMessage": { "type": "string" }, - "jobSpec": { + "specPath": { "type": "string" } }, - "required": [ - "isSuccess" - ], - "additionalProperties": false + "required": ["agentType", "companyDescription", "companyName", "isSuccess", "role", "topics"] + }, + "DraftAgentTopics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["name", "description"], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 1 } } -} \ No newline at end of file +} diff --git a/schemas/agent-generate-spec__v2.json b/schemas/agent-generate-spec__v2.json deleted file mode 100644 index 2252b51..0000000 --- a/schemas/agent-generate-spec__v2.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/AgentCreateSpecResult", - "definitions": { - "AgentCreateSpecResult": { - "type": "object", - "additionalProperties": false, - "properties": { - "topics": { - "$ref": "#/definitions/DraftAgentTopics" - }, - "agentType": { - "type": "string", - "enum": [ - "customer", - "internal" - ], - "description": "Internal type is copilots; used by customers' employees. Customer type is agents; used by customers' customers." - }, - "role": { - "type": "string" - }, - "companyName": { - "type": "string" - }, - "companyDescription": { - "type": "string" - }, - "companyWebsite": { - "type": "string" - }, - "maxNumOfTopics": { - "type": "number", - "description": "The maximum number of topics to create in the spec. Default is 10." - }, - "promptTemplateName": { - "type": "string", - "description": "Developer name of the prompt template." - }, - "groundingContext": { - "type": "string", - "description": "Context info to be used in customized prompt template" - }, - "isSuccess": { - "type": "boolean" - }, - "errorMessage": { - "type": "string" - }, - "specPath": { - "type": "string" - } - }, - "required": [ - "agentType", - "companyDescription", - "companyName", - "isSuccess", - "role", - "topics" - ] - }, - "DraftAgentTopics": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - }, - "required": [ - "name", - "description" - ], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1 - } - } -} \ No newline at end of file diff --git a/src/commands/agent/create-v2.ts b/src/commands/agent/create-v2.ts deleted file mode 100644 index d52fefa..0000000 --- a/src/commands/agent/create-v2.ts +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import { resolve } from 'node:path'; -import { readFileSync, writeFileSync } from 'node:fs'; -import YAML from 'yaml'; -import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { Lifecycle, Messages } from '@salesforce/core'; -import { MultiStageOutput } from '@oclif/multi-stage-output'; -import { colorize } from '@oclif/core/ux'; -import { - Agent, - AgentJobSpecV2, - AgentCreateConfigV2, - AgentCreateLifecycleStagesV2, - AgentCreateResponseV2, - generateAgentApiName, -} from '@salesforce/agents'; -import { FlaggablePrompt, makeFlags, promptForFlag, validateAgentType } from '../../flags.js'; -import { AgentSpecFileContents } from './generate/spec-v2.js'; - -Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.create-v2'); - -// The JSON response returned by the command. -export type AgentCreateResult = AgentCreateResponseV2 & { - previewFilePath?: string; -}; - -const MSO_STAGES = { - parse: 'Parsing Agent spec', - preview: 'Creating Agent for preview', - create: 'Creating Agent in org', - retrieve: 'Retrieving Agent metadata', -}; - -const FLAGGABLE_PROMPTS = { - 'agent-name': { - message: messages.getMessage('flags.agent-name.summary'), - validate: (d: string): boolean | string => d.length > 0 || 'Agent Name cannot be empty', - required: true, - }, -} satisfies Record; - -export default class AgentCreateV2 extends SfCommand { - public static readonly summary = messages.getMessage('summary'); - public static readonly description = messages.getMessage('description'); - public static readonly examples = messages.getMessages('examples'); - public static readonly requiresProject = true; - public static state = 'beta'; - - public static readonly flags = { - 'target-org': Flags.requiredOrg(), - 'api-version': Flags.orgApiVersion(), - ...makeFlags(FLAGGABLE_PROMPTS), - spec: Flags.file({ - // char: 'f', - summary: messages.getMessage('flags.spec.summary'), - exists: true, - required: true, - }), - preview: Flags.boolean({ - summary: messages.getMessage('flags.preview.summary'), - }), - 'agent-api-name': Flags.string({ - summary: messages.getMessage('flags.agent-api-name.summary'), - }), - // This would be used as more of an agent update than create. - // Could possibly move to an `agent update` command. - 'planner-id': Flags.string({ - summary: messages.getMessage('flags.planner-id.summary'), - hidden: true, - }), - }; - - // eslint-disable-next-line complexity - public async run(): Promise { - const { flags } = await this.parse(AgentCreateV2); - - // throw error if --json is used and not all required flags are provided - if (this.jsonEnabled() && !flags['agent-name']) { - throw messages.createError('error.missingRequiredFlags', ['agent-name']); - } - - // Read the agent spec and validate - const inputSpec = YAML.parse(readFileSync(resolve(flags.spec), 'utf8')) as AgentSpecFileContents; - validateSpec(inputSpec); - - // If we don't have an agent name yet, prompt. - const agentName = flags['agent-name'] ?? (await promptForFlag(FLAGGABLE_PROMPTS['agent-name'])); - const agentApiName = flags['agent-api-name'] ?? generateAgentApiName(agentName); - - let title: string; - const stages = [MSO_STAGES.parse]; - if (flags.preview) { - title = `Previewing ${agentName} Creation`; - stages.push(MSO_STAGES.preview); - } else { - title = `Creating ${agentName} Agent`; - stages.push(MSO_STAGES.create); - stages.push(MSO_STAGES.retrieve); - } - - const mso = new MultiStageOutput({ jsonEnabled: this.jsonEnabled(), title, stages }); - mso.goto(MSO_STAGES.parse); - - // @ts-expect-error not using async method in callback - Lifecycle.getInstance().on(AgentCreateLifecycleStagesV2.Previewing, () => mso.goto(MSO_STAGES.preview)); - // @ts-expect-error not using async method in callback - Lifecycle.getInstance().on(AgentCreateLifecycleStagesV2.Creating, () => mso.goto(MSO_STAGES.create)); - // @ts-expect-error not using async method in callback - Lifecycle.getInstance().on(AgentCreateLifecycleStagesV2.Retrieving, () => mso.goto(MSO_STAGES.retrieve)); - - const connection = flags['target-org'].getConnection(flags['api-version']); - const agent = new Agent(connection, this.project!); - - const agentConfig: AgentCreateConfigV2 = { - agentType: inputSpec.agentType, - generationInfo: { - defaultInfo: { - role: inputSpec.role, - companyName: inputSpec.companyName, - companyDescription: inputSpec.companyDescription, - preDefinedTopics: inputSpec.topics, - }, - }, - generationSettings: {}, - }; - if (inputSpec?.companyWebsite) { - agentConfig.generationInfo.defaultInfo.companyWebsite = inputSpec?.companyWebsite; - } - if (!flags.preview) { - agentConfig.saveAgent = true; - agentConfig.agentSettings = { agentName, agentApiName }; - if (flags['planner-id']) { - agentConfig.agentSettings.plannerId = flags['planner-id']; - } - if (inputSpec?.agentUser) { - // TODO: query for the user ID from the username - agentConfig.agentSettings.userId = inputSpec.agentUser; - } - if (inputSpec?.enrichLogs) { - agentConfig.agentSettings.enrichLogs = inputSpec.enrichLogs; - } - if (inputSpec?.tone) { - agentConfig.agentSettings.tone = inputSpec.tone; - } - } - const response = await agent.createV2(agentConfig); - const result: AgentCreateResult = response; - - mso.stop(); - - if (response.isSuccess) { - if (!flags.preview) { - const orgUsername = flags['target-org'].getUsername() as string; - this.log(`Successfully created ${agentName} in ${orgUsername}.\n`); - this.log( - `Use ${colorize( - 'dim', - `sf org open agent --name ${agentApiName} -o ${orgUsername}` - )} to view the agent in the browser.` - ); - } else { - const previewFileName = `${agentApiName}_Preview_${new Date().toISOString()}.json`; - writeFileSync(previewFileName, JSON.stringify(response, null, 2)); - result.previewFilePath = resolve(previewFileName); - this.log(`Successfully created agent for preview. See ${previewFileName}\n`); - } - } else { - this.log(colorize('red', `Failed to create agent: ${response.errorMessage ?? ''}`)); - } - - return result; - } -} - -// The spec must define: agentType, role, companyName, companyDescription, and topics. -// Agent type must be 'customer' or 'internal'. -const validateSpec = (spec: Partial): void => { - const requiredSpecValues: Array<'agentType' | 'role' | 'companyName' | 'companyDescription' | 'topics'> = [ - 'agentType', - 'role', - 'companyName', - 'companyDescription', - 'topics', - ]; - const missingFlags = requiredSpecValues.filter((f) => !spec[f]); - if (missingFlags.length) { - throw messages.createError('error.missingRequiredSpecProperties', [missingFlags.join(', ')]); - } - - validateAgentType(spec.agentType, true); -}; diff --git a/src/commands/agent/create.ts b/src/commands/agent/create.ts index eda8390..c3a0831 100644 --- a/src/commands/agent/create.ts +++ b/src/commands/agent/create.ts @@ -4,22 +4,47 @@ * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ - -import * as fs from 'node:fs'; +import { resolve } from 'node:path'; +import { readFileSync, writeFileSync } from 'node:fs'; +import YAML from 'yaml'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { Lifecycle, Messages } from '@salesforce/core'; import { MultiStageOutput } from '@oclif/multi-stage-output'; import { colorize } from '@oclif/core/ux'; -import { Agent, AgentCreateConfig, AgentCreateLifecycleStages } from '@salesforce/agents'; +import { + Agent, + AgentJobSpecV2, + AgentCreateConfigV2, + AgentCreateLifecycleStagesV2, + AgentCreateResponseV2, + generateAgentApiName, +} from '@salesforce/agents'; +import { FlaggablePrompt, makeFlags, promptForFlag, validateAgentType } from '../../flags.js'; +import { AgentSpecFileContents } from './generate/spec.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.create'); -export type AgentCreateResult = { - isSuccess: boolean; - errorMessage?: string; +// The JSON response returned by the command. +export type AgentCreateResult = AgentCreateResponseV2 & { + previewFilePath?: string; +}; + +const MSO_STAGES = { + parse: 'Parsing Agent spec', + preview: 'Creating Agent for preview', + create: 'Creating Agent in org', + retrieve: 'Retrieving Agent metadata', }; +const FLAGGABLE_PROMPTS = { + 'agent-name': { + message: messages.getMessage('flags.agent-name.summary'), + validate: (d: string): boolean | string => d.length > 0 || 'Agent Name cannot be empty', + required: true, + }, +} satisfies Record; + export default class AgentCreate extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -30,67 +55,143 @@ export default class AgentCreate extends SfCommand { public static readonly flags = { 'target-org': Flags.requiredOrg(), 'api-version': Flags.orgApiVersion(), + ...makeFlags(FLAGGABLE_PROMPTS), spec: Flags.file({ - char: 'f', - required: true, + // char: 'f', summary: messages.getMessage('flags.spec.summary'), - }), - name: Flags.string({ - char: 'n', + exists: true, required: true, - summary: messages.getMessage('flags.name.summary'), + }), + preview: Flags.boolean({ + summary: messages.getMessage('flags.preview.summary'), + }), + 'agent-api-name': Flags.string({ + summary: messages.getMessage('flags.agent-api-name.summary'), + }), + // This would be used as more of an agent update than create. + // Could possibly move to an `agent update` command. + 'planner-id': Flags.string({ + summary: messages.getMessage('flags.planner-id.summary'), + hidden: true, }), }; + // eslint-disable-next-line complexity public async run(): Promise { const { flags } = await this.parse(AgentCreate); - const jsonParsingStage = `Parsing ${flags.spec}`; - const mso = new MultiStageOutput({ - jsonEnabled: this.jsonEnabled(), - title: `Creating ${flags.name} Agent`, - stages: [ - jsonParsingStage, - 'Generating local metadata', - 'Deploying metadata to org', - 'Creating Agent in org', - 'Retrieving Agent metadata', - ], - }); - - mso.goto(jsonParsingStage); - const agentConfig = { - ...(JSON.parse(fs.readFileSync(flags.spec, 'utf8')) as AgentCreateConfig), - name: flags.name, - }; + + // throw error if --json is used and not all required flags are provided + if (this.jsonEnabled() && !flags['agent-name']) { + throw messages.createError('error.missingRequiredFlags', ['agent-name']); + } + + // Read the agent spec and validate + const inputSpec = YAML.parse(readFileSync(resolve(flags.spec), 'utf8')) as AgentSpecFileContents; + validateSpec(inputSpec); + + // If we don't have an agent name yet, prompt. + const agentName = flags['agent-name'] ?? (await promptForFlag(FLAGGABLE_PROMPTS['agent-name'])); + const agentApiName = flags['agent-api-name'] ?? generateAgentApiName(agentName); + + let title: string; + const stages = [MSO_STAGES.parse]; + if (flags.preview) { + title = `Previewing ${agentName} Creation`; + stages.push(MSO_STAGES.preview); + } else { + title = `Creating ${agentName} Agent`; + stages.push(MSO_STAGES.create); + stages.push(MSO_STAGES.retrieve); + } + + const mso = new MultiStageOutput({ jsonEnabled: this.jsonEnabled(), title, stages }); + mso.goto(MSO_STAGES.parse); // @ts-expect-error not using async method in callback - Lifecycle.getInstance().on(AgentCreateLifecycleStages.CreatingLocally, () => mso.goto('Generating local metadata')); - Lifecycle.getInstance().on(AgentCreateLifecycleStages.DeployingMetadata, () => - // @ts-expect-error not using async method in callback - mso.goto('Deploying metadata to org') - ); + Lifecycle.getInstance().on(AgentCreateLifecycleStagesV2.Previewing, () => mso.goto(MSO_STAGES.preview)); // @ts-expect-error not using async method in callback - Lifecycle.getInstance().on(AgentCreateLifecycleStages.CreatingRemotely, () => mso.goto('Creating Agent in org')); - Lifecycle.getInstance().on(AgentCreateLifecycleStages.RetrievingMetadata, () => - // @ts-expect-error not using async method in callback - mso.goto('Retrieving Agent metadata') - ); + Lifecycle.getInstance().on(AgentCreateLifecycleStagesV2.Creating, () => mso.goto(MSO_STAGES.create)); + // @ts-expect-error not using async method in callback + Lifecycle.getInstance().on(AgentCreateLifecycleStagesV2.Retrieving, () => mso.goto(MSO_STAGES.retrieve)); + + const connection = flags['target-org'].getConnection(flags['api-version']); + const agent = new Agent(connection, this.project!); - const agent = new Agent(flags['target-org'].getConnection(flags['api-version']), this.project!); - const created = await agent.create(agentConfig); + const agentConfig: AgentCreateConfigV2 = { + agentType: inputSpec.agentType, + generationInfo: { + defaultInfo: { + role: inputSpec.role, + companyName: inputSpec.companyName, + companyDescription: inputSpec.companyDescription, + preDefinedTopics: inputSpec.topics, + }, + }, + generationSettings: {}, + }; + if (inputSpec?.companyWebsite) { + agentConfig.generationInfo.defaultInfo.companyWebsite = inputSpec?.companyWebsite; + } + if (!flags.preview) { + agentConfig.saveAgent = true; + agentConfig.agentSettings = { agentName, agentApiName }; + if (flags['planner-id']) { + agentConfig.agentSettings.plannerId = flags['planner-id']; + } + if (inputSpec?.agentUser) { + // TODO: query for the user ID from the username + agentConfig.agentSettings.userId = inputSpec.agentUser; + } + if (inputSpec?.enrichLogs) { + agentConfig.agentSettings.enrichLogs = inputSpec.enrichLogs; + } + if (inputSpec?.tone) { + agentConfig.agentSettings.tone = inputSpec.tone; + } + } + const response = await agent.createV2(agentConfig); + const result: AgentCreateResult = response; mso.stop(); - this.log( - created.isSuccess - ? colorize( - 'green', - `Successfully created ${flags.name} in ${flags['target-org'].getUsername() ?? 'the target org'}.` - ) - : colorize('red', `failed to create agent ${flags.name}: ${created.errorMessage ?? ''}`) - ); - this.log(`Use ${colorize('dim', `sf org open agent --name ${flags.name}`)} to view the agent in the browser.`); - - return { isSuccess: created.isSuccess }; + if (response.isSuccess) { + if (!flags.preview) { + const orgUsername = flags['target-org'].getUsername() as string; + this.log(`Successfully created ${agentName} in ${orgUsername}.\n`); + this.log( + `Use ${colorize( + 'dim', + `sf org open agent --name ${agentApiName} -o ${orgUsername}` + )} to view the agent in the browser.` + ); + } else { + const previewFileName = `${agentApiName}_Preview_${new Date().toISOString()}.json`; + writeFileSync(previewFileName, JSON.stringify(response, null, 2)); + result.previewFilePath = resolve(previewFileName); + this.log(`Successfully created agent for preview. See ${previewFileName}\n`); + } + } else { + this.log(colorize('red', `Failed to create agent: ${response.errorMessage ?? ''}`)); + } + + return result; } } + +// The spec must define: agentType, role, companyName, companyDescription, and topics. +// Agent type must be 'customer' or 'internal'. +const validateSpec = (spec: Partial): void => { + const requiredSpecValues: Array<'agentType' | 'role' | 'companyName' | 'companyDescription' | 'topics'> = [ + 'agentType', + 'role', + 'companyName', + 'companyDescription', + 'topics', + ]; + const missingFlags = requiredSpecValues.filter((f) => !spec[f]); + if (missingFlags.length) { + throw messages.createError('error.missingRequiredSpecProperties', [missingFlags.join(', ')]); + } + + validateAgentType(spec.agentType, true); +}; diff --git a/src/commands/agent/generate/spec-v2.ts b/src/commands/agent/generate/spec-v2.ts deleted file mode 100644 index c0935c8..0000000 --- a/src/commands/agent/generate/spec-v2.ts +++ /dev/null @@ -1,313 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import { join, resolve, dirname } from 'node:path'; -import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { SfCommand, Flags, prompts } from '@salesforce/sf-plugins-core'; -import { Messages } from '@salesforce/core'; -import YAML from 'yaml'; -import { Agent, AgentJobSpecCreateConfigV2, AgentJobSpecV2 } from '@salesforce/agents'; -import { FlaggablePrompt, makeFlags, promptForFlag, validateAgentType, validateMaxTopics } from '../../../flags.js'; - -Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.generate.spec-v2'); - -// The JSON response returned by the command. -export type AgentCreateSpecResult = { - isSuccess: boolean; - errorMessage?: string; - specPath?: string; // the location of the job spec file -} & AgentJobSpecV2; - -// Agent spec file schema -export type AgentSpecFileContents = AgentJobSpecV2 & { - agentUser?: string; - enrichLogs?: boolean; - tone?: 'casual' | 'formal' | 'neutral'; - primaryLanguage?: 'en_US'; -}; - -export const FLAGGABLE_PROMPTS = { - type: { - message: messages.getMessage('flags.type.summary'), - validate: (d: string): boolean | string => d.length > 0 || 'Type cannot be empty', - char: 't', - options: ['customer', 'internal'], - required: true, - }, - role: { - message: messages.getMessage('flags.role.summary'), - validate: (d: string): boolean | string => d.length > 0 || 'Role cannot be empty', - required: true, - }, - 'company-name': { - message: messages.getMessage('flags.company-name.summary'), - validate: (d: string): boolean | string => d.length > 0 || 'Company name cannot be empty', - required: true, - }, - 'company-description': { - message: messages.getMessage('flags.company-description.summary'), - validate: (d: string): boolean | string => d.length > 0 || 'Company description cannot be empty', - required: true, - }, - 'company-website': { - message: messages.getMessage('flags.company-website.summary'), - validate: (d: string): boolean | string => { - // Allow empty string - if (d.length === 0) return true; - - try { - const regExp = new RegExp('^(http|https)://', 'i'); - const companySite = regExp.test(d) ? d : `https://${d}`; - new URL(companySite); - d = companySite; - return true; - } catch (e) { - return 'Please enter a valid URL'; - } - }, - }, - 'max-topics': { - message: messages.getMessage('flags.max-topics.summary'), - promptMessage: messages.getMessage('flags.max-topics.prompt'), - validate: (): boolean | string => true, - // min: 1, - // max: 30, - }, - 'agent-user': { - message: messages.getMessage('flags.agent-user.summary'), - promptMessage: messages.getMessage('flags.agent-user.prompt'), - validate: (): boolean | string => true, - }, - 'enrich-logs': { - message: messages.getMessage('flags.enrich-logs.summary'), - promptMessage: messages.getMessage('flags.enrich-logs.prompt'), - validate: (): boolean | string => true, - options: ['true', 'false'], - default: 'false', - }, - tone: { - message: messages.getMessage('flags.tone.summary'), - promptMessage: messages.getMessage('flags.tone.prompt'), - validate: (): boolean | string => true, - options: ['formal', 'casual', 'neutral'], - default: 'casual', - }, - // 'primary-language': { - // message: messages.getMessage('flags.primary-language.summary'), - // validate: (): boolean | string => true, - // options: ['en_US'], - // default: 'en_US', - // }, -} satisfies Record; - -export default class AgentCreateSpecV2 extends SfCommand { - public static readonly summary = messages.getMessage('summary'); - public static readonly description = messages.getMessage('description'); - public static readonly examples = messages.getMessages('examples'); - public static state = 'beta'; - public static readonly requiresProject = true; - - public static readonly flags = { - 'target-org': Flags.requiredOrg(), - 'api-version': Flags.orgApiVersion(), - ...makeFlags(FLAGGABLE_PROMPTS), - // a spec file can be used as input. Allows iterative spec development. - spec: Flags.file({ - summary: messages.getMessage('flags.spec.summary'), - exists: true, - }), - 'output-file': Flags.file({ - summary: messages.getMessage('flags.output-file.summary'), - default: join('config', 'agentSpec.yaml'), - }), - 'full-interview': Flags.boolean({ - summary: messages.getMessage('flags.full-interview.summary'), - }), - 'prompt-template': Flags.string({ - summary: messages.getMessage('flags.prompt-template.summary'), - }), - 'grounding-context': Flags.string({ - summary: messages.getMessage('flags.grounding-context.summary'), - dependsOn: ['prompt-template'], - }), - 'no-prompt': Flags.boolean({ - summary: messages.getMessage('flags.no-prompt.summary'), - }), - }; - - // eslint-disable-next-line complexity - public async run(): Promise { - const { flags } = await this.parse(AgentCreateSpecV2); - - let outputFile: string; - try { - outputFile = await resolveOutputFile(flags['output-file'], flags['no-prompt']); - } catch (e) { - this.log(messages.getMessage('commandCanceled')); - // @ts-expect-error expected due to command cancelation. - return; - } - - // throw error if --json is used and not all required flags are provided - if (this.jsonEnabled()) { - const missingFlags = Object.entries(FLAGGABLE_PROMPTS) - .filter(([key, prompt]) => 'required' in prompt && prompt.required && !(key in flags)) - .map(([key]) => key); - - if (missingFlags.length) { - throw messages.createError('error.missingRequiredFlags', [missingFlags.join(', ')]); - } - } - - this.log(); - this.styledHeader('Agent Details'); - - // If spec is provided, read it first - let inputSpec: Partial = {}; - if (flags.spec) { - inputSpec = YAML.parse(readFileSync(resolve(flags.spec), 'utf8')) as AgentSpecFileContents; - } - - // Flags override inputSpec values. Prompt if neither is set. - const type = flags.type ?? validateAgentType(inputSpec?.agentType) ?? (await promptForFlag(FLAGGABLE_PROMPTS.type)); - const companyName = - flags['company-name'] ?? inputSpec?.companyName ?? (await promptForFlag(FLAGGABLE_PROMPTS['company-name'])); - const companyDescription = - flags['company-description'] ?? - inputSpec?.companyDescription ?? - (await promptForFlag(FLAGGABLE_PROMPTS['company-description'])); - const role = flags.role ?? inputSpec?.role ?? (await promptForFlag(FLAGGABLE_PROMPTS.role)); - - // full interview prompts - const companyWebsite = - flags['company-website'] ?? - inputSpec?.companyWebsite ?? - (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['company-website']) : undefined); - const maxNumOfTopics = - flags['max-topics'] ?? - validateMaxTopics(inputSpec?.maxNumOfTopics) ?? - (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['max-topics']) : 10); - const agentUser = - flags['agent-user'] ?? - inputSpec?.agentUser ?? - (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['agent-user']) : undefined); - let enrichLogs = - flags['enrich-logs'] ?? - inputSpec?.enrichLogs ?? - (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['enrich-logs']) : undefined); - enrichLogs = Boolean(enrichLogs === 'true' || enrichLogs === true); - const tone = - flags.tone ?? - inputSpec?.tone ?? - (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS.tone) : undefined); - // const primaryLanguage = - // flags['primary-language'] ?? - // inputSpec?.primaryLanguage ?? - // (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['primary-language']) : undefined); - - this.log(); - this.spinner.start('Creating agent spec'); - - const connection = flags['target-org'].getConnection(flags['api-version']); - const agent = new Agent(connection, this.project!); - const specConfig: AgentJobSpecCreateConfigV2 = { - agentType: type as 'customer' | 'internal', - companyName, - companyDescription, - role, - }; - if (companyWebsite) { - specConfig.companyWebsite = companyWebsite; - } - const promptTemplateName = flags['prompt-template'] ?? inputSpec?.promptTemplateName; - if (promptTemplateName) { - specConfig.promptTemplateName = promptTemplateName; - const groundingContext = flags['grounding-context'] ?? inputSpec?.groundingContext; - if (groundingContext) { - specConfig.groundingContext = groundingContext; - } - } - if (maxNumOfTopics) { - specConfig.maxNumOfTopics = Number(maxNumOfTopics); - } - // Should we log the specConfig being used? It's returned in the JSON and the generated spec. - // this.log(`${ansis.green(figures.tick)} ${ansis.bold(message)} ${ansis.cyan(valueFromFlag)}`); - const specResponse = await agent.createSpecV2(specConfig); - // @ts-expect-error Need better typing - const specFileContents = buildSpecFile(specResponse, { agentUser, enrichLogs, tone }); - - const outputFilePath = writeSpecFile(outputFile, specFileContents); - - this.spinner.stop(); - - this.log(`\nSaved agent spec: ${outputFilePath}`); - - return { ...{ isSuccess: true, specPath: outputFilePath }, ...specResponse, ...specFileContents }; - } -} - -// Builds spec file contents from the spec response and any additional flags -// in a specific order. -const buildSpecFile = ( - specResponse: AgentJobSpecV2, - extraProps: Partial -): AgentSpecFileContents => { - const propertyOrder = [ - 'agentType', - 'companyName', - 'companyDescription', - 'companyWebsite', - 'role', - 'maxNumOfTopics', - 'agentUser', - 'enrichLogs', - 'tone', - // 'primaryLanguage', - 'promptTemplateName', - 'groundingContext', - 'topics', - ]; - const specFileContents = {}; - propertyOrder.map((prop) => { - // @ts-expect-error need better typing of the array. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const val = specResponse[prop] ?? extraProps[prop]; - if (val != null || (typeof val === 'string' && val.length > 0)) { - // @ts-expect-error need better typing of the array. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - specFileContents[prop] = val; - } - }); - return specFileContents as AgentSpecFileContents; -}; - -const writeSpecFile = (outputFile: string, agentSpec: AgentJobSpecV2): string => { - // create the directory if not already created - const outputFilePath = resolve(outputFile); - mkdirSync(dirname(outputFilePath), { recursive: true }); - - // Write a yaml file with the returned job specs - writeFileSync(outputFilePath, YAML.stringify(agentSpec)); - - return outputFilePath; -}; - -const resolveOutputFile = async (outputFile: string, noPrompt = false): Promise => { - let resolvedOutputFile = resolve(outputFile); - if (!noPrompt) { - if (existsSync(resolvedOutputFile)) { - const message = messages.getMessage('confirmSpecOverwrite', [resolvedOutputFile]); - if (!(await prompts.confirm({ message }))) { - throw Error('NoOverwrite'); - } - } - } - if (!resolvedOutputFile.endsWith('.yaml')) { - resolvedOutputFile = `${resolvedOutputFile}.yaml`; - } - return resolvedOutputFile; -}; diff --git a/src/commands/agent/generate/spec.ts b/src/commands/agent/generate/spec.ts index 786db74..05feeba 100644 --- a/src/commands/agent/generate/spec.ts +++ b/src/commands/agent/generate/spec.ts @@ -4,42 +4,33 @@ * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { join } from 'node:path'; -import { writeFileSync } from 'node:fs'; -import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { Messages, SfProject } from '@salesforce/core'; -import { Interfaces } from '@oclif/core'; -import ansis from 'ansis'; -import { select, input as inquirerInput } from '@inquirer/prompts'; -import figures from '@inquirer/figures'; -import { Agent, AgentCreateConfig, SfAgent } from '@salesforce/agents'; -import { theme } from '../../../inquirer-theme.js'; +import { join, resolve, dirname } from 'node:path'; +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { SfCommand, Flags, prompts } from '@salesforce/sf-plugins-core'; +import { Messages } from '@salesforce/core'; +import YAML from 'yaml'; +import { Agent, AgentJobSpecCreateConfigV2, AgentJobSpecV2 } from '@salesforce/agents'; +import { FlaggablePrompt, makeFlags, promptForFlag, validateAgentType, validateMaxTopics } from '../../../flags.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.generate.spec'); +// The JSON response returned by the command. export type AgentCreateSpecResult = { isSuccess: boolean; errorMessage?: string; - jobSpec?: string; // the location of the job spec file - // We probably need more than this in the returned JSON like - // all the parameters used to generate the spec and the spec contents -}; + specPath?: string; // the location of the job spec file +} & AgentJobSpecV2; -type FlaggablePrompt = { - message: string; - options?: readonly string[] | string[]; - validate: (d: string) => boolean | string; - char?: Interfaces.AlphabetLowercase | Interfaces.AlphabetUppercase; - required?: boolean; +// Agent spec file schema +export type AgentSpecFileContents = AgentJobSpecV2 & { + agentUser?: string; + enrichLogs?: boolean; + tone?: 'casual' | 'formal' | 'neutral'; + primaryLanguage?: 'en_US'; }; -type FlagsOfPrompts> = Record< - keyof T, - Interfaces.OptionFlag ->; - -const FLAGGABLE_PROMPTS = { +export const FLAGGABLE_PROMPTS = { type: { message: messages.getMessage('flags.type.summary'), validate: (d: string): boolean | string => d.length > 0 || 'Type cannot be empty', @@ -69,39 +60,50 @@ const FLAGGABLE_PROMPTS = { if (d.length === 0) return true; try { - new URL(d); + const regExp = new RegExp('^(http|https)://', 'i'); + const companySite = regExp.test(d) ? d : `https://${d}`; + new URL(companySite); + d = companySite; return true; } catch (e) { return 'Please enter a valid URL'; } }, }, + 'max-topics': { + message: messages.getMessage('flags.max-topics.summary'), + promptMessage: messages.getMessage('flags.max-topics.prompt'), + validate: (): boolean | string => true, + // min: 1, + // max: 30, + }, + 'agent-user': { + message: messages.getMessage('flags.agent-user.summary'), + promptMessage: messages.getMessage('flags.agent-user.prompt'), + validate: (): boolean | string => true, + }, + 'enrich-logs': { + message: messages.getMessage('flags.enrich-logs.summary'), + promptMessage: messages.getMessage('flags.enrich-logs.prompt'), + validate: (): boolean | string => true, + options: ['true', 'false'], + default: 'false', + }, + tone: { + message: messages.getMessage('flags.tone.summary'), + promptMessage: messages.getMessage('flags.tone.prompt'), + validate: (): boolean | string => true, + options: ['formal', 'casual', 'neutral'], + default: 'casual', + }, + // 'primary-language': { + // message: messages.getMessage('flags.primary-language.summary'), + // validate: (): boolean | string => true, + // options: ['en_US'], + // default: 'en_US', + // }, } satisfies Record; -function validateInput(input: string, validate: (input: string) => boolean | string): never | string { - const result = validate(input); - if (typeof result === 'string') throw new Error(result); - return input; -} - -function makeFlags>(flaggablePrompts: T): FlagsOfPrompts { - return Object.fromEntries( - Object.entries(flaggablePrompts).map(([key, value]) => [ - key, - Flags.string({ - summary: value.message, - options: value.options, - char: value.char, - // eslint-disable-next-line @typescript-eslint/require-await - async parse(input) { - return validateInput(input, value.validate); - }, - // NOTE: we purposely omit the required property here because we want to allow the flag to be missing in interactive mode - }), - ]) - ) as FlagsOfPrompts; -} - export default class AgentCreateSpec extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -113,22 +115,43 @@ export default class AgentCreateSpec extends SfCommand { 'target-org': Flags.requiredOrg(), 'api-version': Flags.orgApiVersion(), ...makeFlags(FLAGGABLE_PROMPTS), - 'output-dir': Flags.directory({ - char: 'd', + // a spec file can be used as input. Allows iterative spec development. + spec: Flags.file({ + summary: messages.getMessage('flags.spec.summary'), exists: true, - summary: messages.getMessage('flags.output-dir.summary'), - default: 'config', }), - 'file-name': Flags.string({ - char: 'f', - summary: messages.getMessage('flags.file-name.summary'), - default: 'agentSpec.json', + 'output-file': Flags.file({ + summary: messages.getMessage('flags.output-file.summary'), + default: join('config', 'agentSpec.yaml'), + }), + 'full-interview': Flags.boolean({ + summary: messages.getMessage('flags.full-interview.summary'), + }), + 'prompt-template': Flags.string({ + summary: messages.getMessage('flags.prompt-template.summary'), + }), + 'grounding-context': Flags.string({ + summary: messages.getMessage('flags.grounding-context.summary'), + dependsOn: ['prompt-template'], + }), + 'no-prompt': Flags.boolean({ + summary: messages.getMessage('flags.no-prompt.summary'), }), }; + // eslint-disable-next-line complexity public async run(): Promise { const { flags } = await this.parse(AgentCreateSpec); + let outputFile: string; + try { + outputFile = await resolveOutputFile(flags['output-file'], flags['no-prompt']); + } catch (e) { + this.log(messages.getMessage('commandCanceled')); + // @ts-expect-error expected due to command cancelation. + return; + } + // throw error if --json is used and not all required flags are provided if (this.jsonEnabled()) { const missingFlags = Object.entries(FLAGGABLE_PROMPTS) @@ -136,84 +159,155 @@ export default class AgentCreateSpec extends SfCommand { .map(([key]) => key); if (missingFlags.length) { - throw new Error(`Missing required flags: ${missingFlags.join(', ')}`); + throw messages.createError('error.missingRequiredFlags', [missingFlags.join(', ')]); } } this.log(); this.styledHeader('Agent Details'); - const type = (await this.getFlagOrPrompt(flags.type, FLAGGABLE_PROMPTS.type)) as 'customer' | 'internal'; - const role = await this.getFlagOrPrompt(flags.role, FLAGGABLE_PROMPTS.role); - const companyName = await this.getFlagOrPrompt(flags['company-name'], FLAGGABLE_PROMPTS['company-name']); - const companyDescription = await this.getFlagOrPrompt( - flags['company-description'], - FLAGGABLE_PROMPTS['company-description'] - ); - const companyWebsite = await this.getFlagOrPrompt(flags['company-website'], FLAGGABLE_PROMPTS['company-website']); + + // If spec is provided, read it first + let inputSpec: Partial = {}; + if (flags.spec) { + inputSpec = YAML.parse(readFileSync(resolve(flags.spec), 'utf8')) as AgentSpecFileContents; + } + + // Flags override inputSpec values. Prompt if neither is set. + const type = flags.type ?? validateAgentType(inputSpec?.agentType) ?? (await promptForFlag(FLAGGABLE_PROMPTS.type)); + const companyName = + flags['company-name'] ?? inputSpec?.companyName ?? (await promptForFlag(FLAGGABLE_PROMPTS['company-name'])); + const companyDescription = + flags['company-description'] ?? + inputSpec?.companyDescription ?? + (await promptForFlag(FLAGGABLE_PROMPTS['company-description'])); + const role = flags.role ?? inputSpec?.role ?? (await promptForFlag(FLAGGABLE_PROMPTS.role)); + + // full interview prompts + const companyWebsite = + flags['company-website'] ?? + inputSpec?.companyWebsite ?? + (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['company-website']) : undefined); + const maxNumOfTopics = + flags['max-topics'] ?? + validateMaxTopics(inputSpec?.maxNumOfTopics) ?? + (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['max-topics']) : 10); + const agentUser = + flags['agent-user'] ?? + inputSpec?.agentUser ?? + (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['agent-user']) : undefined); + let enrichLogs = + flags['enrich-logs'] ?? + inputSpec?.enrichLogs ?? + (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['enrich-logs']) : undefined); + enrichLogs = Boolean(enrichLogs === 'true' || enrichLogs === true); + const tone = + flags.tone ?? + inputSpec?.tone ?? + (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS.tone) : undefined); + // const primaryLanguage = + // flags['primary-language'] ?? + // inputSpec?.primaryLanguage ?? + // (flags['full-interview'] ? await promptForFlag(FLAGGABLE_PROMPTS['primary-language']) : undefined); this.log(); this.spinner.start('Creating agent spec'); const connection = flags['target-org'].getConnection(flags['api-version']); - const agent = new Agent(connection, this.project as SfProject) as SfAgent; - const agentSpec = await agent.createSpec({ - name: flags['file-name'].split('.json')[0], - type, - role, + const agent = new Agent(connection, this.project!); + const specConfig: AgentJobSpecCreateConfigV2 = { + agentType: type as 'customer' | 'internal', companyName, companyDescription, - companyWebsite, - }); - - // Write a file with the returned job specs - const filePath = join(flags['output-dir'], flags['file-name']); - writeFileSync( - filePath, - JSON.stringify( - { type, role, companyName, companyDescription, companyWebsite, jobSpec: agentSpec } as AgentCreateConfig, - null, - 4 - ) - ); + role, + }; + if (companyWebsite) { + specConfig.companyWebsite = companyWebsite; + } + const promptTemplateName = flags['prompt-template'] ?? inputSpec?.promptTemplateName; + if (promptTemplateName) { + specConfig.promptTemplateName = promptTemplateName; + const groundingContext = flags['grounding-context'] ?? inputSpec?.groundingContext; + if (groundingContext) { + specConfig.groundingContext = groundingContext; + } + } + if (maxNumOfTopics) { + specConfig.maxNumOfTopics = Number(maxNumOfTopics); + } + // Should we log the specConfig being used? It's returned in the JSON and the generated spec. + // this.log(`${ansis.green(figures.tick)} ${ansis.bold(message)} ${ansis.cyan(valueFromFlag)}`); + const specResponse = await agent.createSpecV2(specConfig); + // @ts-expect-error Need better typing + const specFileContents = buildSpecFile(specResponse, { agentUser, enrichLogs, tone }); + + const outputFilePath = writeSpecFile(outputFile, specFileContents); this.spinner.stop(); - this.log(`\nSaved agent spec: ${filePath}`); + this.log(`\nSaved agent spec: ${outputFilePath}`); - return { - isSuccess: true, - jobSpec: filePath, - }; + return { ...{ isSuccess: true, specPath: outputFilePath }, ...specResponse, ...specFileContents }; } +} - /** - * Get a flag value or prompt the user for a value. - * - * Resolution order: - * - Flag value provided by the user - * - Prompt the user for a value - */ - public async getFlagOrPrompt(valueFromFlag: string | undefined, flagDef: FlaggablePrompt): Promise { - const message = flagDef.message.replace(/\.$/, ''); +// Builds spec file contents from the spec response and any additional flags +// in a specific order. +const buildSpecFile = ( + specResponse: AgentJobSpecV2, + extraProps: Partial +): AgentSpecFileContents => { + const propertyOrder = [ + 'agentType', + 'companyName', + 'companyDescription', + 'companyWebsite', + 'role', + 'maxNumOfTopics', + 'agentUser', + 'enrichLogs', + 'tone', + // 'primaryLanguage', + 'promptTemplateName', + 'groundingContext', + 'topics', + ]; + const specFileContents = {}; + propertyOrder.map((prop) => { + // @ts-expect-error need better typing of the array. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const val = specResponse[prop] ?? extraProps[prop]; + if (val != null || (typeof val === 'string' && val.length > 0)) { + // @ts-expect-error need better typing of the array. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + specFileContents[prop] = val; + } + }); + return specFileContents as AgentSpecFileContents; +}; - if (valueFromFlag) { - this.log(`${ansis.green(figures.tick)} ${ansis.bold(message)} ${ansis.cyan(valueFromFlag)}`); +const writeSpecFile = (outputFile: string, agentSpec: AgentJobSpecV2): string => { + // create the directory if not already created + const outputFilePath = resolve(outputFile); + mkdirSync(dirname(outputFilePath), { recursive: true }); - return valueFromFlag; - } + // Write a yaml file with the returned job specs + writeFileSync(outputFilePath, YAML.stringify(agentSpec)); - if (flagDef.options) { - return select({ - choices: flagDef.options.map((o) => ({ name: o, value: o })), - message, - theme, - }); - } + return outputFilePath; +}; - return inquirerInput({ - message, - validate: flagDef.validate, - theme, - }); +const resolveOutputFile = async (outputFile: string, noPrompt = false): Promise => { + let resolvedOutputFile = resolve(outputFile); + if (!noPrompt) { + if (existsSync(resolvedOutputFile)) { + const message = messages.getMessage('confirmSpecOverwrite', [resolvedOutputFile]); + if (!(await prompts.confirm({ message }))) { + throw Error('NoOverwrite'); + } + } } -} + if (!resolvedOutputFile.endsWith('.yaml')) { + resolvedOutputFile = `${resolvedOutputFile}.yaml`; + } + return resolvedOutputFile; +}; diff --git a/test/commands/agent/generate/spec.nut.ts b/test/commands/agent/generate/spec.nut.ts index 5bb3d5c..e56abbf 100644 --- a/test/commands/agent/generate/spec.nut.ts +++ b/test/commands/agent/generate/spec.nut.ts @@ -8,7 +8,7 @@ import { join, resolve } from 'node:path'; import { statSync } from 'node:fs'; import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; -import { AgentCreateSpecResult } from '../../../../src/commands/agent/generate/spec-v2.js'; +import { AgentCreateSpecResult } from '../../../../src/commands/agent/generate/spec.js'; describe('agent generate spec NUTs', () => { let session: TestSession; @@ -32,7 +32,7 @@ describe('agent generate spec NUTs', () => { const companyName = 'Test Company Name'; const companyDescription = 'Test Company Description'; const companyWebsite = 'https://test-company-website.org'; - const command = `agent generate spec-v2 ${targetOrg} --type ${type} --role "${role}" --company-name "${companyName}" --company-description "${companyDescription}" --company-website ${companyWebsite} --json`; + const command = `agent generate spec ${targetOrg} --type ${type} --role "${role}" --company-name "${companyName}" --company-description "${companyDescription}" --company-website ${companyWebsite} --json`; const output = execCmd(command, { ensureExitCode: 0, env: { ...process.env, SF_MOCK_DIR: mockDir },