diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 6ea5b54..71a4402 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -180,9 +180,9 @@ jobs: - name: 'Assign Manage Prompt Template permission set' run: sf org assign permset -n EinsteinGPTPromptTemplateManager - # Deploy source to scratch org - - name: 'Push source to scratch org' - run: sf project deploy start --dev-debug + # Deploy employee agent source to scratch org + - name: 'Deploy employee agent recipes' + run: sf project deploy start --source-dir force-app # Assign permission sets - name: 'Assign permission sets to default user' @@ -194,6 +194,25 @@ jobs: - name: 'Import sample data' run: sf data tree import -p ./data/data-plan.json + # Create agent user for service agent deployment + - name: 'Create agent user for service agent' + id: service-agent-user + run: | + agent_user=$(node scripts/setup-service-agent.js --target-org scratch-org) + echo "username=$agent_user" >> "$GITHUB_OUTPUT" + + # Assign base permission set to agent user + - name: 'Assign base permission set to agent user' + run: sf org assign permset -n Agent_Script_Recipes_Data --on-behalf-of "${{ steps.service-agent-user.outputs.username }}" -o scratch-org + + # Deploy service agent source to scratch org + - name: 'Deploy service agent recipes' + run: sf project deploy start --source-dir force-app-service + + # Assign service agent permission set (deployed with force-app-service) + - name: 'Assign service agent permission set to agent user' + run: sf org assign permset -n Customer_Service_Agent_Data --on-behalf-of "${{ steps.service-agent-user.outputs.username }}" -o scratch-org + # Run Apex tests in scratch org - name: 'Run Apex tests' run: sf apex test run -c -r human -d ./tests/apex -w 20 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5485f94..af757e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,9 +120,9 @@ jobs: - name: 'Assign Manage Prompt Template permission set' run: sf org assign permset -n EinsteinGPTPromptTemplateManager - # Deploy source to scratch org - - name: 'Push source to scratch org' - run: sf project deploy start --dev-debug + # Deploy employee agent source to scratch org + - name: 'Deploy employee agent recipes' + run: sf project deploy start --source-dir force-app # Assign permission sets - name: 'Assign permission sets to default user' @@ -134,6 +134,25 @@ jobs: - name: 'Import sample data' run: sf data tree import -p ./data/data-plan.json + # Create agent user for service agent deployment + - name: 'Create agent user for service agent' + id: service-agent-user + run: | + agent_user=$(node scripts/setup-service-agent.js --target-org scratch-org) + echo "username=$agent_user" >> "$GITHUB_OUTPUT" + + # Assign base permission set to agent user + - name: 'Assign base permission set to agent user' + run: sf org assign permset -n Agent_Script_Recipes_Data --on-behalf-of "${{ steps.service-agent-user.outputs.username }}" -o scratch-org + + # Deploy service agent source to scratch org + - name: 'Deploy service agent recipes' + run: sf project deploy start --source-dir force-app-service + + # Assign service agent permission set (deployed with force-app-service) + - name: 'Assign service agent permission set to agent user' + run: sf org assign permset -n Customer_Service_Agent_Data --on-behalf-of "${{ steps.service-agent-user.outputs.username }}" -o scratch-org + # Run Apex tests in scratch org - name: 'Run Apex tests' run: sf apex test run -c -r human -d ./tests/apex -w 20 diff --git a/.husky/pre-commit b/.husky/pre-commit index 2601a3b..581ec3c 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ +node scripts/clean-service-agent.js npm run precommit \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 5dc1f9f..6f4d73a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ Detailed rules and guidelines are in the `.airules/` directory. Read the relevan ## Project Layout -- **Agent scripts:** `force-app/**/aiAuthoringBundles/**/*.agent` -- **Apex services:** `force-app/**/classes/*.cls` +- **Employee Agent scripts:** `force-app/**/aiAuthoringBundles/**/*.agent` +- **Service Agent scripts:** `force-app-service/**/aiAuthoringBundles/**/*.agent` +- **Apex services:** `force-app/**/classes/*.cls` and `force-app-service/**/classes/*.cls` - **Recipes:** each subdirectory under `force-app/main/` is a self-contained recipe +- **Service Agent recipes:** `force-app-service/` contains service agent examples that require a `default_agent_user` diff --git a/README.md b/README.md index 339712b..e94b8aa 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,39 @@ If you don't have an org yet, you can sign up for a free [Developer Edition Org] sf data import tree --plan data/data-plan.json ``` +1. **(Service Agent recipes)** Install npm dependencies. This is required for the setup script that creates and configures the agent user: + + ```bash + npm install + ``` + +1. **(Service Agent recipes)** Create the agent user and prepare the service agent metadata for deployment. Service Agents (unlike Employee Agents) require a `default_agent_user` — an org-specific user that the agent runs as. This script creates that user and injects it into all `.agent` files under `force-app-service/`: + + ```bash + npm run setup:service-agent + ``` + + If deploying to a specific org (not your default), pass the target org alias: + + ```bash + npm run setup:service-agent -- --target-org my-org-alias + ``` + +1. **(Service Agent recipes)** Deploy the service agent metadata: + + ```bash + sf project deploy start --source-dir force-app-service + ``` + +1. **(Service Agent recipes)** Assign the required permission sets to the agent user so the service agent can access recipe data: + + ```bash + sf org assign permset -n Agent_Script_Recipes_Data --on-behalf-of + sf org assign permset -n Customer_Service_Agent_Data --on-behalf-of + ``` + + Replace `` with the username printed by the `setup:service-agent` script in the previous step. + 1. Open your org with the **Agentforce Studio** app displayed: ```bash @@ -87,7 +120,8 @@ If you don't have an org yet, you can sign up for a free [Developer Edition Org] > [!TIP] > **Agentforce Studio** can be reached from the App Launcher. From there, click **View All** then select the **Agentforce Studio** app. -**Post installation:** when working with the recipes, assign the **Agent Script Recipes Data** permission set to your agent user to avoid access issues. +> [!NOTE] +> **What is a Service Agent?** Unlike Employee Agents (which run as the logged-in user), Service Agents are external-facing agents that run under a dedicated agent user. This user is org-specific, which is why the `npm run setup:service-agent` step is needed to dynamically create and configure it before deployment. ## Optional Installation Instructions diff --git a/bin/install-scratch.bat b/bin/install-scratch.bat index 1259697..3f0ca46 100755 --- a/bin/install-scratch.bat +++ b/bin/install-scratch.bat @@ -23,8 +23,8 @@ cmd.exe /c sf org assign permset -n EinsteinGPTPromptTemplateManager call :checkForError @echo: -echo Pushing source... -cmd.exe /c sf project deploy start +echo Deploying employee agent recipes... +cmd.exe /c sf project deploy start --source-dir force-app call :checkForError @echo: @@ -36,11 +36,28 @@ cmd.exe /c sf org assign permset -n Agent_Script_Recipes_App call :checkForError @echo: +echo Importing sample data... +cmd.exe /c sf data tree import -p data/data-plan.json call :checkForError @echo: -echo Importing sample data... -cmd.exe /c sf data tree import -p data/data-plan.json +echo Creating agent user for service agent... +for /f "tokens=*" %%a in ('node scripts/setup-service-agent.js') do set AGENT_USER=%%a +call :checkForError +@echo: + +echo Assigning base permission set to agent user... +cmd.exe /c sf org assign permset -n Agent_Script_Recipes_Data --on-behalf-of %AGENT_USER% +call :checkForError +@echo: + +echo Deploying service agent recipes... +cmd.exe /c sf project deploy start --source-dir force-app-service +call :checkForError +@echo: + +echo Assigning service agent permission set... +cmd.exe /c sf org assign permset -n Customer_Service_Agent_Data --on-behalf-of %AGENT_USER% call :checkForError @echo: diff --git a/bin/install-scratch.sh b/bin/install-scratch.sh index 6ec98ab..7f557b7 100755 --- a/bin/install-scratch.sh +++ b/bin/install-scratch.sh @@ -22,8 +22,8 @@ echo "Assigning Manage Prompt Templates permission set..." sf org assign permset -n EinsteinGPTPromptTemplateManager && \ echo "" && \ -echo "Pushing source..." && \ -sf project deploy start && \ +echo "Deploying employee agent recipes..." && \ +sf project deploy start --source-dir force-app && \ echo "" && \ echo "Assigning Agent Script permission sets..." && \ @@ -35,6 +35,22 @@ echo "Importing sample data..." && \ sf data import tree --plan data/data-plan.json && \ echo "" && \ +echo "Creating agent user for service agent..." && \ +agent_user=$(node scripts/setup-service-agent.js) && \ +echo "" && \ + +echo "Assigning base permission set to agent user..." && \ +sf org assign permset -n Agent_Script_Recipes_Data --on-behalf-of "$agent_user" && \ +echo "" && \ + +echo "Deploying service agent recipes..." && \ +sf project deploy start --source-dir force-app-service && \ +echo "" && \ + +echo "Assigning service agent permission set..." && \ +sf org assign permset -n Customer_Service_Agent_Data --on-behalf-of "$agent_user" && \ +echo "" && \ + echo "Opening org..." && \ sf org open -p lightning/n/standard-AgentforceStudio && \ echo "" diff --git a/force-app-service/README.md b/force-app-service/README.md new file mode 100644 index 0000000..e3b67d5 --- /dev/null +++ b/force-app-service/README.md @@ -0,0 +1,22 @@ +# Service Agent Recipes + +This directory contains Agentforce **Service Agent** examples. Service Agents are external-facing agents that run under a dedicated agent user rather than the logged-in user. + +## Key Difference from Employee Agents + +Service Agents require a `default_agent_user` field in the agent config. This value is org-specific (it contains the org ID), so it cannot be committed to source control. Instead, a placeholder token (`__AGENT_USER_PLACEHOLDER__`) is used in the `.agent` files and replaced at deploy time. + +## Deployment + +See the root [README](../README.md#installing-the-app-using-a-developer-edition-org) for full deployment steps. The key additional steps for service agents are: + +1. `npm run setup:service-agent` — creates the agent user and replaces the placeholder +2. `sf project deploy start --source-dir force-app-service` — deploys the service agent metadata + +A pre-commit hook automatically restores the placeholder before any commit so org-specific values are never pushed to the repository. + +## Recipes + +| Recipe | Description | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| [CustomerServiceAgent](customerServiceAgent/) | Service agent that classifies issues, searches the knowledge base, and creates or escalates cases | diff --git a/force-app-service/customerServiceAgent/README.md b/force-app-service/customerServiceAgent/README.md new file mode 100644 index 0000000..d7a32ef --- /dev/null +++ b/force-app-service/customerServiceAgent/README.md @@ -0,0 +1,190 @@ +# CustomerServiceAgent + +## Overview + +This recipe demonstrates how to build an **Agentforce Service Agent** — an external-facing agent that runs under a dedicated agent user rather than the logged-in user. The key differentiator from Employee Agents is the `agent_type: "AgentforceServiceAgent"` and `default_agent_user` configuration, which requires an org-specific deployment pipeline. + +## Agent Flow + +```mermaid +%%{init: {'theme':'neutral'}}%% +graph TD + A[Start] --> B[start_agent: agent_router] + B --> C[Transition to triage] + C --> D[triage subagent] + D --> E{Customer ID known?} + E -->|Yes| F[fetch_customer] + E -->|No| G[Ask for issue] + F --> G + G --> H[classify_issue] + H --> I[search_knowledge_base] + I --> J[Transition to resolution] + J --> K[resolution subagent] + K --> L{KB article found?} + L -->|Yes| M[create_case] + L -->|No| N[escalate_case] +``` + +## Key Concepts + +- **Service Agent config**: `agent_type: "AgentforceServiceAgent"` and `default_agent_user` field +- **Placeholder-based deployment**: Source uses `__AGENT_USER_PLACEHOLDER__` replaced at deploy time +- **Subagent transitions**: Router → triage → resolution flow +- **Mixed action targets**: Flows (`flow://`) and Apex (`apex://`) in the same agent + +## How It Works + +### Service Agent Configuration + +Unlike Employee Agents, Service Agents require a dedicated user to run as: + +```agentscript +config: + developer_name: "CustomerService_Agent" + agent_label: "Customer Service Agent" + agent_type: "AgentforceServiceAgent" + default_agent_user: "__AGENT_USER_PLACEHOLDER__" +``` + +The placeholder is replaced by `npm run setup:service-agent` before deployment. + +### Triage Subagent + +Handles customer identification, issue classification, and knowledge base search: + +```agentscript +subagent triage: + actions: + fetch_customer: ... + target: "flow://FetchCustomer" + + classify_issue: ... + target: "apex://IssueClassifier" + + search_knowledge_base: ... + target: "flow://SearchKnowledgeBase" + + reasoning: + actions: + identify_customer: @actions.fetch_customer + available when @variables.customer_id and not @variables.customer_name + + classify_customer_issue: @actions.classify_issue + available when @variables.issue_description and not @variables.issue_type + + search_kb: @actions.search_knowledge_base + available when @variables.issue_type and not @variables.kb_article_found + transition to @subagent.resolution +``` + +### Resolution Subagent + +Routes to case creation or escalation based on KB results: + +```agentscript +subagent resolution: + reasoning: + actions: + create_support_case: @actions.create_case + available when @variables.kb_article_found + + escalate_to_specialist: @actions.escalate_case + available when not @variables.kb_article_found +``` + +## Try It Out + +### Example 1: Happy Path — KB Resolution + +```text +Agent: Hello! Welcome to Customer Service. How can I help you today? + +User: My account is CUST-12345. I can't reset my password. + +[fetch_customer → name="Customer", tier="Premium"] +[classify_issue → type="technical", priority="high"] +[search_knowledge_base → kb_article_found=True] +[transitions to resolution] + +Agent: I found a knowledge base article that may help resolve your technical issue. + Let me create a case to track this. + +[create_case → case_id="500xx...", case_number="00001234"] + +Agent: I've created case 00001234 for your password reset issue. + Here's the solution from our knowledge base: ... +``` + +**What to verify**: All three triage actions fire in sequence (`fetch_customer` → `classify_issue` → `search_knowledge_base`), then the agent transitions to the resolution subagent and creates a case. + +### Example 2: Escalation Path — No KB Match + +```text +Agent: Hello! Welcome to Customer Service. How can I help you today? + +User: I need a custom enterprise pricing adjustment. + +[classify_issue → type="billing", priority="high"] +[search_knowledge_base → kb_article_found=False (no match)] +[transitions to resolution] + +Agent: I couldn't find a matching solution in our knowledge base. + Let me escalate this to a specialist who can help. + +[escalate_case → escalated=True, specialist_assigned="Billing Support"] + +Agent: I've escalated your billing issue to our specialist team. + They will reach out to you shortly. +``` + +**What to verify**: After KB search returns no relevant articles, the agent escalates via `escalate_case` instead of creating a standard case. Note that `fetch_customer` is skipped because no customer ID was provided. + +### Example 3: Unknown Customer — Direct Classification + +```text +Agent: Hello! Welcome to Customer Service. How can I help you today? + +User: I need help with my billing. I was charged twice for last month's subscription. + +[classify_issue → type="billing", priority="medium"] +[search_knowledge_base → kb_article_found=False (no match)] +[transitions to resolution] + +Agent: I couldn't find a matching solution in our knowledge base. + Let me escalate this to a specialist. + +[escalate_case → escalated=True, specialist_assigned="Technical Support"] + +Agent: I am escalating your billing issue to a specialist who can review + your account and resolve the double charge. You will be contacted + soon with an update. +``` + +**What to verify**: `fetch_customer` is never called because no `customer_id` was provided. The agent proceeds directly to classification and escalation in a single turn. + +## Deployment + +This recipe requires additional setup compared to Employee Agent recipes: + +1. `npm run setup:service-agent` — creates the agent user and replaces the placeholder in all `.agent` files +2. `sf project deploy start --source-dir force-app-service` — deploys the metadata +3. `sf org assign permset -n Customer_Service_Agent_Data --on-behalf-of ` — grants service agent data access + +A pre-commit hook automatically restores the placeholder before commits. + +## What's Next + +- **MultiSubagentNavigation**: More complex multi-subagent routing patterns +- **OpenGateRouter**: Deterministic gate-based routing with authentication +- **ActionChaining**: Sequential action execution patterns + +## Stub Flow Behavior + +The included flows return hardcoded values so you can try the agent without real data: + +| Flow | Returns | +| --------------------- | ------------------------------------------------------------------------------ | +| `FetchCustomer` | `name="Customer"`, `email="test@example.com"`, `tier="Premium"` | +| `SearchKnowledgeBase` | If `issue_type="technical"`: articles + top_article. Otherwise: empty results. | +| `CreateCase` | `case_number="00001234"` | +| `EscalateCase` | `escalated=true`, `specialist_assigned="Technical Support"` | diff --git a/force-app-service/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.agent b/force-app-service/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.agent new file mode 100644 index 0000000..4926428 --- /dev/null +++ b/force-app-service/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.agent @@ -0,0 +1,190 @@ +# CustomerServiceAgent +# Service Agent recipe: demonstrates agent_type + default_agent_user config + +config: + developer_name: "CustomerService_Agent" + agent_label: "Customer Service Agent" + agent_type: "AgentforceServiceAgent" + default_agent_user: "__AGENT_USER_PLACEHOLDER__" + description: "Customer service agent that classifies issues, searches the knowledge base, and creates or escalates cases" + +variables: + customer_id: mutable string = "" + description: "Unique identifier for the customer" + customer_name: mutable string = "" + description: "Full name of the customer" + customer_tier: mutable string = "standard" + description: "Customer loyalty tier (standard, premium, enterprise)" + issue_description: mutable string = "" + description: "Detailed description of the issue provided by the customer" + issue_type: mutable string = "" + description: "Category of the customer's issue (billing, technical, account, product)" + issue_priority: mutable string = "medium" + description: "Priority level of the issue (low, medium, high, urgent)" + kb_article_found: mutable boolean = False + description: "Whether a relevant knowledge base article was found" + kb_article_content: mutable string = "" + description: "Content of the top knowledge base article found" + kb_search_complete: mutable boolean = False + description: "Whether the knowledge base search has been executed" + +system: + instructions: "You are a customer service agent. Identify the customer, classify their issue, search the knowledge base for solutions, and create or escalate cases as needed. Be empathetic and solution-oriented." + +start_agent agent_router: + description: "Welcome customers and route to triage" + + reasoning: + instructions:| + Select the tool that best matches the user's message and conversation history. If it's unclear, make your best guess. + actions: + begin_triage: @utils.transition to @subagent.triage + description: "Start the customer service triage process" + +# SUBAGENT 1: Triage +subagent triage: + description: "Identify the customer, classify their issue, and search the knowledge base" + + actions: + fetch_customer: + description: "Fetch customer information by their ID" + inputs: + customer_id: string + description: "The unique identifier of the customer" + is_required: True + outputs: + name: string + description: "Customer's full name" + email: string + description: "Customer's email address" + tier: string + description: "Customer tier level (standard, premium, enterprise)" + target: "flow://FetchCustomer" + + classify_issue: + description: "Classify the customer's issue type and priority" + inputs: + issueDescription: string + description: "The customer's description of their problem" + is_required: True + outputs: + issueType: string + description: "Classified issue type (billing, technical, account, product)" + priority: string + description: "Issue priority level (low, medium, high, urgent)" + target: "apex://IssueClassifier" + + search_knowledge_base: + description: "Search the knowledge base for relevant solutions" + inputs: + issue_type: string + description: "The classified type of issue to search for" + is_required: True + keywords: string + description: "Keywords from the issue description for search" + is_required: True + outputs: + articles: list[string] + description: "List of matching knowledge base articles" + top_article: string + description: "The most relevant article with solution content" + target: "flow://SearchKnowledgeBase" + + reasoning: + instructions:-> + if @variables.issue_description and @variables.customer_id and not @variables.customer_name: + run @actions.fetch_customer + with customer_id=@variables.customer_id + set @variables.customer_name = @outputs.name + set @variables.customer_tier = @outputs.tier + if @variables.issue_description and not @variables.issue_type: + run @actions.classify_issue + with issueDescription=@variables.issue_description + set @variables.issue_type = @outputs.issueType + set @variables.issue_priority = @outputs.priority + if @variables.issue_type and not @variables.kb_search_complete: + run @actions.search_knowledge_base + with issue_type=@variables.issue_type + with keywords=@variables.issue_description + set @variables.kb_article_content = @outputs.top_article + set @variables.kb_article_found = @outputs.top_article != "" + set @variables.kb_search_complete = True + + if @variables.customer_name: + | Hello {!@variables.customer_name}! How can I help you today? + if not @variables.customer_name: + | Hello! Welcome to Customer Service. How can I help you today? + + if not @variables.issue_description: + | You MUST call {!@actions.collect_info} immediately. Extract customer_id if mentioned (otherwise leave empty) and issue_description from the user's messages. Any mention of a problem or topic counts as an issue description — save it even if brief. Never ask clarifying questions before calling collect_info. + if @variables.kb_search_complete: + | Triage is complete. Inform the customer their issue has been identified and you are resolving it. + + actions: + collect_info: @utils.setVariables + description: "Save the customer ID and issue description from the conversation" + available when not @variables.issue_description + with customer_id=... + with issue_description=... + + after_reasoning: + if @variables.kb_search_complete: + transition to @subagent.resolution + +# SUBAGENT 2: Resolution +subagent resolution: + description: "Create a case or escalate based on knowledge base results" + + actions: + create_case: + description: "Create a support case for the customer" + inputs: + subject: string + description: "Brief subject line summarizing the case" + is_required: True + outputs: + case_number: string + description: "Human-readable case number for reference" + target: "flow://CreateCase" + + escalate_case: + description: "Escalate a complex issue to a specialist team" + inputs: + case_id: string + description: "The unique identifier of the case to escalate" + is_required: True + reason: string + description: "Reason for escalation" + is_required: True + specialist_team: string + description: "The specialist team to escalate to" + is_required: True + outputs: + escalated: boolean + description: "Whether the case was escalated successfully" + specialist_assigned: string + description: "Name of the assigned specialist" + target: "flow://EscalateCase" + + reasoning: + instructions:-> + if @variables.kb_article_found: + | I found a knowledge base article that may help resolve your {!@variables.issue_type} issue. + Share this solution with the customer: {!@variables.kb_article_content} + Also create a case to track this. + if not @variables.kb_article_found: + | I couldn't find a matching solution. Let me escalate this to a specialist. + + if @variables.customer_tier == "premium": + | (Priority handling for premium customer) + + actions: + create_support_case: @actions.create_case + available when @variables.kb_article_found + with subject="{!@variables.issue_type}: {!@variables.issue_description}" + + escalate_to_specialist: @actions.escalate_case + available when not @variables.kb_article_found + with case_id="" + with reason="No knowledge base solution found for {!@variables.issue_type} issue" + with specialist_team=@variables.issue_type diff --git a/force-app/future_recipes/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.bundle-meta.xml b/force-app-service/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.bundle-meta.xml similarity index 100% rename from force-app/future_recipes/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.bundle-meta.xml rename to force-app-service/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.bundle-meta.xml diff --git a/force-app-service/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls b/force-app-service/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls new file mode 100644 index 0000000..e999a3b --- /dev/null +++ b/force-app-service/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls @@ -0,0 +1,127 @@ +/** + * @description Test class for Customer Service Agent Flows + * Tests all flows used in the customer service agent recipe + */ +@IsTest +private class CustomerServiceAgentFlowTest { + @TestSetup + static void setup() { + // Create test contact with customer ID + Contact testContact = new Contact( + FirstName = 'Test', + LastName = 'Customer', + Email = 'test@example.com', + Customer_ID__c = 'CUST-12345', + Loyalty_Tier__c = 'Premium' + ); + insert testContact; + + // Create test account + Account testAccount = new Account(Name = 'Test Account'); + insert testAccount; + + testContact.AccountId = testAccount.Id; + update testContact; + } + + @IsTest + static void testFetchCustomerSuccess() { + // Set input variables for the flow + Map inputs = new Map(); + inputs.put('customer_id', 'CUST-12345'); + + // Run the flow + Test.startTest(); + Flow.Interview.FetchCustomer flowInterview = new Flow.Interview.FetchCustomer( + inputs + ); + flowInterview.start(); + Test.stopTest(); + + // Verify the expected outcome + String name = (String) flowInterview.getVariableValue('name'); + String email = (String) flowInterview.getVariableValue('email'); + String tier = (String) flowInterview.getVariableValue('tier'); + + Assert.areEqual('Customer', name, 'Customer name should match'); + Assert.areEqual('test@example.com', email, 'Email should match'); + Assert.areEqual('Premium', tier, 'Tier should match'); + } + + @IsTest + static void testCreateCaseSuccess() { + Map inputs = new Map(); + inputs.put('subject', 'Test Case Subject'); + + Test.startTest(); + Flow.Interview.CreateCase flowInterview = new Flow.Interview.CreateCase( + inputs + ); + flowInterview.start(); + Test.stopTest(); + + String caseNumber = (String) flowInterview.getVariableValue( + 'case_number' + ); + Assert.areEqual( + '00001234', + caseNumber, + 'Case number should be returned' + ); + } + + @IsTest + static void testSearchKnowledgeBase() { + // Set input variables for the flow + Map inputs = new Map(); + inputs.put('issue_type', 'Technical'); + inputs.put('keywords', 'password reset'); + + // Run the flow + Test.startTest(); + Flow.Interview.SearchKnowledgeBase flowInterview = new Flow.Interview.SearchKnowledgeBase( + inputs + ); + flowInterview.start(); + Test.stopTest(); + + // Verify the flow executes and returns articles + List articles = (List) flowInterview.getVariableValue( + 'articles' + ); + Assert.areNotEqual(null, articles, 'Articles should be returned'); + Assert.areEqual( + true, + articles.size() > 0, + 'At least one article should be returned' + ); + } + + @IsTest + static void testEscalateCase() { + Map inputs = new Map(); + inputs.put('case_id', '500000000000001'); + inputs.put('reason', 'Complex technical issue'); + inputs.put('specialist_team', 'Technical Support'); + + Test.startTest(); + Flow.Interview.EscalateCase flowInterview = new Flow.Interview.EscalateCase( + inputs + ); + flowInterview.start(); + Test.stopTest(); + + Boolean escalated = (Boolean) flowInterview.getVariableValue( + 'escalated' + ); + String specialistAssigned = (String) flowInterview.getVariableValue( + 'specialist_assigned' + ); + Assert.areEqual(true, escalated, 'Case should be escalated'); + Assert.areEqual( + 'Technical Support', + specialistAssigned, + 'Specialist team should be assigned' + ); + } +} diff --git a/force-app/future_recipes/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls-meta.xml b/force-app-service/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls-meta.xml similarity index 100% rename from force-app/future_recipes/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls-meta.xml rename to force-app-service/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls-meta.xml diff --git a/force-app-service/customerServiceAgent/classes/IssueClassifier.cls b/force-app-service/customerServiceAgent/classes/IssueClassifier.cls new file mode 100644 index 0000000..7d7f661 --- /dev/null +++ b/force-app-service/customerServiceAgent/classes/IssueClassifier.cls @@ -0,0 +1,73 @@ +/** + * @description Classifies issues based on description + */ +public with sharing class IssueClassifier { + /** + * @description Classifies the issue based on description + * @param requests List of IssueRequest objects + * @return List of IssueResult objects with classification details + */ + @InvocableMethod( + label='Classify Issue' + description='Classifies the issue based on description' + ) + public static List classify(List requests) { + List results = new List(); + for (IssueRequest req : requests) { + String description = req.issueDescription != null + ? req.issueDescription + : ''; + IssueResult res = new IssueResult(); + res.issueType = 'account'; + res.priority = 'medium'; + + if ( + description.containsIgnoreCase('urgent') || + description.containsIgnoreCase('critical') + ) { + res.priority = 'high'; + } + if ( + description.containsIgnoreCase('login') || + description.containsIgnoreCase('password') + ) { + res.issueType = 'technical'; + } + if ( + description.containsIgnoreCase('bill') || + description.containsIgnoreCase('charge') || + description.containsIgnoreCase('invoice') || + description.containsIgnoreCase('pricing') + ) { + res.issueType = 'billing'; + } + if ( + description.containsIgnoreCase('feature') || + description.containsIgnoreCase('product') || + description.containsIgnoreCase('update') + ) { + res.issueType = 'product'; + } + results.add(res); + } + return results; + } + + /** + * @description Input class for issue classification + */ + public class IssueRequest { + @InvocableVariable(required=true) + public String issueDescription; + } + + /** + * @description Result class for issue classification + */ + public class IssueResult { + @InvocableVariable + public String issueType; + @InvocableVariable + public String priority; + } +} diff --git a/force-app/future_recipes/customerServiceAgent/classes/IssueClassifier.cls-meta.xml b/force-app-service/customerServiceAgent/classes/IssueClassifier.cls-meta.xml similarity index 100% rename from force-app/future_recipes/customerServiceAgent/classes/IssueClassifier.cls-meta.xml rename to force-app-service/customerServiceAgent/classes/IssueClassifier.cls-meta.xml diff --git a/force-app-service/customerServiceAgent/classes/IssueClassifierTest.cls b/force-app-service/customerServiceAgent/classes/IssueClassifierTest.cls new file mode 100644 index 0000000..e28ad9c --- /dev/null +++ b/force-app-service/customerServiceAgent/classes/IssueClassifierTest.cls @@ -0,0 +1,193 @@ +/** + * @description Test class for IssueClassifier + * Tests the issue classification invocable method + */ +@IsTest +private class IssueClassifierTest { + @IsTest + static void testClassifyAccountIssue() { + IssueClassifier.IssueRequest req = new IssueClassifier.IssueRequest(); + req.issueDescription = 'I need help with my account'; + + Test.startTest(); + List results = IssueClassifier.classify( + new List{ req } + ); + Test.stopTest(); + + Assert.areEqual(1, results.size(), 'Should return one result'); + Assert.areEqual( + 'account', + results[0].issueType, + 'Default issue type should be account' + ); + Assert.areEqual( + 'medium', + results[0].priority, + 'Default priority should be medium' + ); + } + + @IsTest + static void testClassifyUrgentIssue() { + IssueClassifier.IssueRequest req = new IssueClassifier.IssueRequest(); + req.issueDescription = 'This is an urgent matter that needs immediate attention'; + + Test.startTest(); + List results = IssueClassifier.classify( + new List{ req } + ); + Test.stopTest(); + + Assert.areEqual( + 'high', + results[0].priority, + 'Priority should be high for urgent issue' + ); + Assert.areEqual( + 'account', + results[0].issueType, + 'Issue type should remain account' + ); + } + + @IsTest + static void testClassifyTechnicalIssue() { + IssueClassifier.IssueRequest req = new IssueClassifier.IssueRequest(); + req.issueDescription = 'I cannot login to my account'; + + Test.startTest(); + List results = IssueClassifier.classify( + new List{ req } + ); + Test.stopTest(); + + Assert.areEqual( + 'technical', + results[0].issueType, + 'Login issues should be technical' + ); + Assert.areEqual( + 'medium', + results[0].priority, + 'Priority should be medium' + ); + } + + @IsTest + static void testClassifyPasswordIssue() { + IssueClassifier.IssueRequest req = new IssueClassifier.IssueRequest(); + req.issueDescription = 'I forgot my password and need to reset it'; + + Test.startTest(); + List results = IssueClassifier.classify( + new List{ req } + ); + Test.stopTest(); + + Assert.areEqual( + 'technical', + results[0].issueType, + 'Password issues should be technical' + ); + } + + @IsTest + static void testClassifyBillingIssue() { + IssueClassifier.IssueRequest req = new IssueClassifier.IssueRequest(); + req.issueDescription = 'I was incorrectly charged on my bill'; + + Test.startTest(); + List results = IssueClassifier.classify( + new List{ req } + ); + Test.stopTest(); + + Assert.areEqual( + 'billing', + results[0].issueType, + 'Billing keywords should classify as billing' + ); + } + + @IsTest + static void testClassifyProductIssue() { + IssueClassifier.IssueRequest req = new IssueClassifier.IssueRequest(); + req.issueDescription = 'I have a question about a product feature'; + + Test.startTest(); + List results = IssueClassifier.classify( + new List{ req } + ); + Test.stopTest(); + + Assert.areEqual( + 'product', + results[0].issueType, + 'Product keywords should classify as product' + ); + } + + @IsTest + static void testClassifyNullDescription() { + IssueClassifier.IssueRequest req = new IssueClassifier.IssueRequest(); + req.issueDescription = null; + + Test.startTest(); + List results = IssueClassifier.classify( + new List{ req } + ); + Test.stopTest(); + + Assert.areEqual( + 'account', + results[0].issueType, + 'Null description should default to account' + ); + Assert.areEqual( + 'medium', + results[0].priority, + 'Null description should default to medium' + ); + } + + @IsTest + static void testClassifyBulkIssues() { + List requests = new List(); + + IssueClassifier.IssueRequest req1 = new IssueClassifier.IssueRequest(); + req1.issueDescription = 'General question about my account'; + requests.add(req1); + + IssueClassifier.IssueRequest req2 = new IssueClassifier.IssueRequest(); + req2.issueDescription = 'URGENT: System is down'; + requests.add(req2); + + IssueClassifier.IssueRequest req3 = new IssueClassifier.IssueRequest(); + req3.issueDescription = 'I cannot login'; + requests.add(req3); + + Test.startTest(); + List results = IssueClassifier.classify( + requests + ); + Test.stopTest(); + + Assert.areEqual(3, results.size(), 'Should return three results'); + Assert.areEqual( + 'account', + results[0].issueType, + 'First should be account' + ); + Assert.areEqual( + 'high', + results[1].priority, + 'Second should be high priority' + ); + Assert.areEqual( + 'technical', + results[2].issueType, + 'Third should be technical' + ); + } +} diff --git a/force-app/future_recipes/customerServiceAgent/classes/IssueClassifierTest.cls-meta.xml b/force-app-service/customerServiceAgent/classes/IssueClassifierTest.cls-meta.xml similarity index 100% rename from force-app/future_recipes/customerServiceAgent/classes/IssueClassifierTest.cls-meta.xml rename to force-app-service/customerServiceAgent/classes/IssueClassifierTest.cls-meta.xml diff --git a/force-app/future_recipes/customerServiceAgent/flows/SearchKnowledgeBase.flow-meta.xml b/force-app-service/customerServiceAgent/flows/CreateCase.flow-meta.xml similarity index 53% rename from force-app/future_recipes/customerServiceAgent/flows/SearchKnowledgeBase.flow-meta.xml rename to force-app-service/customerServiceAgent/flows/CreateCase.flow-meta.xml index 7c3ef32..3aa0647 100644 --- a/force-app/future_recipes/customerServiceAgent/flows/SearchKnowledgeBase.flow-meta.xml +++ b/force-app-service/customerServiceAgent/flows/CreateCase.flow-meta.xml @@ -1,64 +1,57 @@ - + 66.0 + false + + Assign_Output + + 176 + 134 + + case_number + Assign + + 00001234 + + + + Stub flow for Create Case Default - SearchKnowledgeBase {!$Flow.CurrentDateTime} - + Create Case {!$Flow.CurrentDateTime} + BuilderType LightningFlowBuilder + + CanvasMode + + AUTO_LAYOUT_CANVAS + + AutoLaunchedFlow 50 0 - Mock_Results + Assign_Output Active - - Mock_Results - - 176 - 134 - - articles - Add - - Article 1: How to Reset Password - - - - issue_type + case_number String false - true - false - - - keywords - String - false - true - false - - - articles - String - true false true - top_article + subject String false - false - true + true + false diff --git a/force-app/future_recipes/customerServiceAgent/flows/EscalateCase.flow-meta.xml b/force-app-service/customerServiceAgent/flows/EscalateCase.flow-meta.xml similarity index 65% rename from force-app/future_recipes/customerServiceAgent/flows/EscalateCase.flow-meta.xml rename to force-app-service/customerServiceAgent/flows/EscalateCase.flow-meta.xml index 14e3cea..32b4981 100644 --- a/force-app/future_recipes/customerServiceAgent/flows/EscalateCase.flow-meta.xml +++ b/force-app-service/customerServiceAgent/flows/EscalateCase.flow-meta.xml @@ -1,59 +1,12 @@ - + 66.0 - Default - EscalateCase {!$Flow.CurrentDateTime} - - - BuilderType - - LightningFlowBuilder - - - AutoLaunchedFlow - - 50 - 0 - - Update_Case_Escalation - - - Active - - Update_Case_Escalation - - 176 - 134 - - Assign_Result - - and - - Id - EqualTo - - case_id - - - - Status - - Escalated - - - - Escalation_Reason__c - - reason - - - Case - + false Assign_Result - 176 - 242 + 0 + 0 escalated Assign @@ -65,10 +18,35 @@ specialist_assigned Assign - specialist_team + Technical Support + Stub flow for Escalate Case + Default + EscalateCase {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + AutoLaunchedFlow + + 0 + 0 + + Assign_Result + + + Active case_id String @@ -77,31 +55,31 @@ false - reason - String + escalated + Boolean false - true - false + false + true - specialist_team + reason String false true false - escalated - Boolean + specialist_assigned + String false false true - specialist_assigned + specialist_team String false - false - true + true + false diff --git a/force-app/future_recipes/customerServiceAgent/flows/FetchCustomer.flow-meta.xml b/force-app-service/customerServiceAgent/flows/FetchCustomer.flow-meta.xml similarity index 59% rename from force-app/future_recipes/customerServiceAgent/flows/FetchCustomer.flow-meta.xml rename to force-app-service/customerServiceAgent/flows/FetchCustomer.flow-meta.xml index d650c0c..4772786 100644 --- a/force-app/future_recipes/customerServiceAgent/flows/FetchCustomer.flow-meta.xml +++ b/force-app-service/customerServiceAgent/flows/FetchCustomer.flow-meta.xml @@ -1,6 +1,35 @@ - + 66.0 + false + + Mock_Customer + + 0 + 0 + + name + Assign + + Customer + + + + email + Assign + + test@example.com + + + + tier + Assign + + Premium + + + + Stub flow for Fetch Customer Default FetchCustomer {!$Flow.CurrentDateTime} @@ -10,44 +39,21 @@ LightningFlowBuilder + + CanvasMode + + AUTO_LAYOUT_CANVAS + + AutoLaunchedFlow - 50 + 0 0 - Get_Contact + Mock_Customer Active - - Get_Contact - - 176 - 134 - false - and - - Customer_ID__c - EqualTo - - customer_id - - - Contact - - name - LastName - - - email - Email - - - tier - Loyalty_Tier__c - - customer_id String @@ -56,14 +62,14 @@ false - name + email String false false true - email + name String false false diff --git a/force-app-service/customerServiceAgent/flows/SearchKnowledgeBase.flow-meta.xml b/force-app-service/customerServiceAgent/flows/SearchKnowledgeBase.flow-meta.xml new file mode 100644 index 0000000..c9b8598 --- /dev/null +++ b/force-app-service/customerServiceAgent/flows/SearchKnowledgeBase.flow-meta.xml @@ -0,0 +1,119 @@ + + + 66.0 + false + + Check_Issue_Type + + 0 + 0 + + No_Results + + No Match + + Is_Technical + and + + issue_type + EqualTo + + technical + + + + Mock_Results + + + + + + Mock_Results + + 0 + 0 + + articles + Add + + Article 1: How to Reset Password + + + + top_article + Assign + + How to Reset Password: Go to Settings > Security > Reset Password. Click the reset link sent to your email. + + + + + No_Results + + 0 + 0 + + top_article + Assign + + + + + + Stub flow for Search Knowledge Base — returns articles only for technical issues + Default + SearchKnowledgeBase {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + + CanvasMode + + AUTO_LAYOUT_CANVAS + + + AutoLaunchedFlow + + 0 + 0 + + Check_Issue_Type + + + Active + + articles + String + true + false + true + + + issue_type + String + false + true + false + + + keywords + String + false + true + false + + + top_article + String + false + false + true + + diff --git a/force-app/future_recipes/customerServiceAgent/objects/Case/fields/Escalation_Reason__c.field-meta.xml b/force-app-service/customerServiceAgent/objects/Case/fields/Escalation_Reason__c.field-meta.xml similarity index 100% rename from force-app/future_recipes/customerServiceAgent/objects/Case/fields/Escalation_Reason__c.field-meta.xml rename to force-app-service/customerServiceAgent/objects/Case/fields/Escalation_Reason__c.field-meta.xml diff --git a/force-app/future_recipes/customerServiceAgent/objects/Contact/fields/Customer_ID__c.field-meta.xml b/force-app-service/customerServiceAgent/objects/Contact/fields/Customer_ID__c.field-meta.xml similarity index 100% rename from force-app/future_recipes/customerServiceAgent/objects/Contact/fields/Customer_ID__c.field-meta.xml rename to force-app-service/customerServiceAgent/objects/Contact/fields/Customer_ID__c.field-meta.xml diff --git a/force-app/future_recipes/customerServiceAgent/objects/Contact/fields/Loyalty_Tier__c.field-meta.xml b/force-app-service/customerServiceAgent/objects/Contact/fields/Loyalty_Tier__c.field-meta.xml similarity index 100% rename from force-app/future_recipes/customerServiceAgent/objects/Contact/fields/Loyalty_Tier__c.field-meta.xml rename to force-app-service/customerServiceAgent/objects/Contact/fields/Loyalty_Tier__c.field-meta.xml diff --git a/force-app-service/customerServiceAgent/permissionsets/Customer_Service_Agent_Data.permissionset-meta.xml b/force-app-service/customerServiceAgent/permissionsets/Customer_Service_Agent_Data.permissionset-meta.xml new file mode 100644 index 0000000..2342571 --- /dev/null +++ b/force-app-service/customerServiceAgent/permissionsets/Customer_Service_Agent_Data.permissionset-meta.xml @@ -0,0 +1,26 @@ + + + + IssueClassifier + true + + Grants access to custom fields and classes used by the Customer Service Agent recipe. + + true + Case.Escalation_Reason__c + true + + + true + Contact.Customer_ID__c + true + + + true + Contact.Loyalty_Tier__c + true + + false + + diff --git a/force-app/future_recipes/customerServiceAgent/README.md b/force-app/future_recipes/customerServiceAgent/README.md deleted file mode 100644 index 918044d..0000000 --- a/force-app/future_recipes/customerServiceAgent/README.md +++ /dev/null @@ -1,240 +0,0 @@ -# CustomerServiceAgent - -## Overview - -A **complete real-world customer service agent** demonstrating a production-ready implementation. This comprehensive example combines issue classification, knowledge base search, case management, escalation patterns, and customer satisfaction surveys. - -## Agent Flow - -```mermaid -%%{init: {'theme':'neutral'}}%% -graph TD - A[Customer Arrives] --> B[Subagent: Triage] - B --> C[Fetch Customer & Classify Issue] - C --> D[Subagent: Resolution] - D --> E[Search Knowledge Base] - E --> F{KB Article Found?} - F -->|Yes| G[Subagent: Solution Presentation] - F -->|No| H[Subagent: Escalation] - G --> I[Present Solution] - I --> J{Resolved?} - J -->|Yes| K[Subagent: Closure] - J -->|No| H - H --> L[Escalate to Specialist] - L --> K - K --> M[Send Satisfaction Survey] - M --> N[End] -``` - -## Key Concepts - -- **Issue Classification**: Automatically categorize customer issues and assign priority -- **Knowledge Base Integration**: Dynamic search for solutions based on issue description -- **Case Management**: Create and update support cases throughout the lifecycle -- **Escalation Patterns**: Seamlessly transfer complex issues to human specialists -- **Customer Satisfaction**: Integrated feedback collection upon closure - -## How It Works - -### Subagent: Triage - -The entry point for the conversation. It identifies the customer and classifies their issue. - -```agentscript -subagent triage: - actions: - fetch_customer: - description: "Fetch customer information" - # ... - classify_issue: - description: "Classify customer issue type" - # ... - - reasoning: - actions: - # Classify the issue - classify_customer_issue: @actions.classify_issue - available when @variables.issue_description and not @variables.issue_type - # ... - transition to @subagent.resolution -``` - -### Subagent: Resolution - -Attempts to find a solution using the Knowledge Base and initiates case tracking. - -```agentscript -subagent resolution: - actions: - search_knowledge_base: - description: "Search knowledge base for solutions" - # ... - create_case: - description: "Create support case" - # ... - - reasoning: - actions: - # Search knowledge base - find_solution: @actions.search_knowledge_base - available when @variables.issue_type and not @variables.kb_article_found - # ... - - # If KB solution found, present it - present_solution: @utils.transition to @subagent.solution_presentation - available when @variables.kb_article_found - - # If no KB solution, escalate - escalate_to_specialist: @utils.transition to @subagent.escalation - available when not @variables.kb_article_found -``` - -### Subagent: Solution Presentation - -Presents the found Knowledge Base article to the user and asks for confirmation. - -```agentscript -subagent solution_presentation: - reasoning: - instructions:-> - | I found a solution for your {!@variables.issue_type} issue! - {!@variables.kb_article_content} - Does this resolve your issue? - - actions: - mark_resolved: @actions.update_case - # ... - transition to @subagent.closure - - need_more_help: @utils.transition to @subagent.escalation -``` - -### Subagent: Escalation - -Handles scenarios where the bot cannot resolve the issue or the user requests a human. - -```agentscript -subagent escalation: - reasoning: - instructions:-> - | This issue requires specialist attention. - if @variables.issue_priority == "high": - | Due to the high priority, I'm escalating this immediately. - - actions: - escalate_issue: @actions.escalate_case - with specialist_team=@variables.issue_type - # ... - transition to @subagent.closure -``` - -## Key Code Snippets - -### Variables for Complete Workflow - -```agentscript -variables: - # Customer information - customer_id: mutable string = "" - description: "Unique identifier for the customer" - customer_name: mutable string = "" - description: "Full name of the customer" - customer_email: mutable string = "" - description: "Email address of the customer" - customer_tier: mutable string = "standard" - description: "Customer loyalty tier (standard, premium, enterprise)" - - # Issue tracking - issue_type: mutable string = "" - description: "Category of the customer's issue (e.g., billing, technical)" - issue_description: mutable string = "" - description: "Detailed description of the issue provided by the customer" - issue_priority: mutable string = "medium" - description: "Priority level of the issue (low, medium, high, urgent)" - issue_resolved: mutable boolean = False - description: "Flag indicating if the issue has been resolved" - - # Case management - case_id: mutable string = "" - description: "Salesforce Case ID associated with the interaction" - case_status: mutable string = "new" - description: "Current status of the case (new, in_progress, resolved, escalated, closed)" - - # Knowledge base - kb_article_found: mutable boolean = False - description: "Flag indicating if a relevant KB article was found" - kb_article_content: mutable string = "" - description: "Content of the found KB article" - - # Escalation - requires_escalation: mutable boolean = False - description: "Flag indicating if the issue needs escalation to a human agent" - escalation_reason: mutable string = "" - description: "Reason for escalation" - - # Resolution tracking - resolution_steps: mutable object = {} - description: "List of steps taken to resolve the issue" - customer_satisfied: mutable boolean = False - description: "Flag indicating customer satisfaction with the resolution" -``` - -## Try It Out - -### Example: Successful KB Resolution - -```text -Agent: Welcome to Customer Service! Please describe the issue you're experiencing. - -User: I can't reset my password. - -[Agent classifies issue as "Account Access"] -[Agent searches KB and finds article] -[Agent creates Case] - -Agent: I found a solution for your Account Access issue! - [KB Article Content: Go to settings > security > reset password...] - Does this resolve your issue? - -User: Yes, thanks. - -[Agent updates Case to "Resolved"] - -Agent: ✅ Great! Your issue has been resolved. - Case ID: 500xx000000abc1 - I'm sending a satisfaction survey to your email. -``` - -### Example: Escalation Flow - -```text -Agent: Welcome to Customer Service! Please describe the issue you're experiencing. - -User: I have a complex billing error that needs a manager. - -[Agent classifies issue as "Billing", Priority "High"] -[Agent searches KB... No direct solution found or User rejects KB] - -Agent: This issue requires specialist attention. - Due to the high priority, I'm escalating this immediately. - Creating escalation... - -[Agent escalates case to "Billing" team] - -Agent: Your case (500xx000000abc2) has been escalated to our specialist team. - They will contact you within 4 hours. -``` - -## Features Demonstrated - -1. **Context Engineering**: Hydrating customer data (`before_reasoning`) before interaction. -2. **Chaining Actions**: Combining KB search and Case creation in a single logical step. -3. **Conditional Transitions**: Routing between subagents based on variables (`kb_article_found`, `issue_resolved`). -4. **Variable Descriptions**: providing semantic meaning to variables for better planner performance. - -## Best Practices Demonstrated - -✅ **Context Engineering** - Hydrating data before instructions run. -✅ **Subagent Separation** - Distinct subagents for Triage, Resolution, and Escalation. -✅ **Variable Descriptions** - Clear descriptions helping the LLM understand variable purpose. -✅ **Deterministic Transitions** - Explicit `@utils.transition` calls for state management. diff --git a/force-app/future_recipes/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.agent b/force-app/future_recipes/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.agent deleted file mode 100644 index d760883..0000000 --- a/force-app/future_recipes/customerServiceAgent/aiAuthoringBundles/CustomerServiceAgent/CustomerServiceAgent.agent +++ /dev/null @@ -1,492 +0,0 @@ -# CustomerServiceAgent -# Complete real-world customer service agent - -config: - developer_name: "CustomerService_Agent" - agent_label: "Customer Service Agent" - agent_type: "AgentforceEmployeeAgent" - description: "Complete customer service agent with issue classification, resolution workflows, and escalation" - -variables: - # Customer information - customer_id: mutable string = "" - description: "Unique identifier for the customer" - customer_name: mutable string = "" - description: "Full name of the customer" - customer_email: mutable string = "" - description: "Email address of the customer" - customer_tier: mutable string = "standard" - description: "Customer loyalty tier (standard, premium, enterprise)" - - # Issue tracking - issue_type: mutable string = "" - description: "Category of the customer's issue (e.g., billing, technical)" - issue_description: mutable string = "" - description: "Detailed description of the issue provided by the customer" - issue_priority: mutable string = "medium" - description: "Priority level of the issue (low, medium, high, urgent)" - issue_resolved: mutable boolean = False - description: "Flag indicating if the issue has been resolved" - - # Case management - case_id: mutable string = "" - description: "Salesforce Case ID associated with the interaction" - case_status: mutable string = "new" - description: "Current status of the case (new, in_progress, resolved, escalated, closed)" - - # Knowledge base - kb_article_found: mutable boolean = False - description: "Flag indicating if a relevant KB article was found" - kb_article_content: mutable string = "" - description: "Content of the found KB article" - - # Escalation - requires_escalation: mutable boolean = False - description: "Flag indicating if the issue needs escalation to a human agent" - escalation_reason: mutable string = "" - description: "Reason for escalation" - - # Resolution tracking - resolution_steps: mutable object = {} - description: "List of steps taken to resolve the issue" - customer_satisfied: mutable boolean = False - description: "Flag indicating customer satisfaction with the resolution" - -system: - messages: - welcome: "Welcome to Customer Service! I'm here to help resolve your issue quickly." - error: "I encountered an issue processing your request. Please try again or contact support." - - instructions: "You are a customer service agent. Classify issues, provide solutions from the knowledge base, create cases, and escalate when needed. Be empathetic, professional, and solution-oriented." - -start_agent agent_router: - description: "Welcome customers and begin issue triage and resolution" - - reasoning: - instructions:| - Select the tool that best matches the user's message and conversation history. If it's unclear, make your best guess. - actions: - begin_triage: @utils.transition to @subagent.triage - description: "Start the customer service triage process" - -# SUBAGENT 1: Initial Triage -subagent triage: - description: "Initial customer interaction and issue triage" - - actions: - fetch_customer: - description: "Fetch customer information" - inputs: - customer_id: string - description: "The unique identifier of the customer to fetch information for" - is_required: True - outputs: - name: string - description: "Customer's full name" - email: string - description: "Customer's email address for communication" - tier: string - description: "Customer tier level (standard, premium, enterprise) determining service priority" - target: "flow://FetchCustomer" - - classify_issue: - description: "Classify customer issue type" - inputs: - issue_description: string - description: "The customer's description of their issue or problem" - is_required: True - outputs: - issue_type: string - description: "Classified issue type (e.g., billing, technical, account, product)" - priority: string - description: "Issue priority level (low, medium, high, urgent) based on classification" - suggested_kb_articles: list[string] - description: "List of relevant knowledge base article IDs that may help resolve the issue" - target: "apex://IssueClassifier" - - search_knowledge_base: - description: "Search knowledge base for solutions" - inputs: - issue_type: string - description: "The classified type of issue to search solutions for" - is_required: True - keywords: string - description: "Keywords extracted from the issue description for refined search" - is_required: True - outputs: - articles: list[string] - description: "List of knowledge base article objects matching the search criteria" - top_article: object - description: "The most relevant knowledge base article object with solution content" - target: "flow://SearchKnowledgeBase" - - create_case: - description: "Create support case" - inputs: - customer_id: string - description: "The unique identifier of the customer the case is for" - is_required: True - subject: string - description: "Brief subject line summarizing the case" - is_required: True - case_description: string - description: "Detailed description of the customer's issue" - is_required: True - priority: string - description: "Priority level of the case (low, medium, high, urgent)" - is_required: True - issue_type: string - description: "The classified type of issue (billing, technical, account, product)" - is_required: True - outputs: - case_id: string - description: "Unique identifier assigned to the newly created case" - case_number: string - description: "Human-readable case number for customer reference" - target: "flow://CreateCase" - - update_case: - description: "Update case status" - inputs: - case_id: string - description: "The unique identifier of the case to update" - is_required: True - status: string - description: "New status for the case (new, in_progress, resolved, escalated, closed)" - is_required: True - notes: string - description: "Notes or comments about the case update" - is_required: False - outputs: - updated: boolean - description: "Indicates whether the case was updated successfully" - target: "flow://UpdateCase" - - escalate_case: - description: "Escalate case to specialist" - inputs: - case_id: string - description: "The unique identifier of the case to escalate" - is_required: True - reason: string - description: "Reason for escalation (e.g., complex issue, customer request, policy exception)" - is_required: True - specialist_team: string - description: "The specialist team to escalate to based on issue type" - is_required: True - outputs: - escalated: boolean - description: "Indicates whether the case was escalated successfully" - specialist_assigned: string - description: "Name or ID of the specialist assigned to handle the escalated case" - target: "flow://EscalateCase" - - send_satisfaction_survey: - description: "Send customer satisfaction survey" - inputs: - customer_email: string - description: "Email address to send the satisfaction survey to" - is_required: True - case_id: string - description: "The case ID to associate with the survey response" - is_required: True - outputs: - sent: boolean - description: "Indicates whether the satisfaction survey was sent successfully" - target: "flow://SendSatisfactionSurvey" - - - # Load customer data if available - before_reasoning: - if @variables.customer_id and not @variables.customer_name: - run @actions.fetch_customer - with customer_id=@variables.customer_id - set @variables.customer_name = @outputs.name - set @variables.customer_email = @outputs.email - set @variables.customer_tier = @outputs.tier - - reasoning: - instructions:-> - if @variables.customer_name: - | Hello {!@variables.customer_name}! 👋 - if not @variables.customer_name: - | Hello! Welcome to Customer Service. 👋 - - | I'm here to help resolve your issue quickly. - - if not @variables.issue_description: - | Please describe the issue you're experiencing. - if @variables.issue_description and not @variables.issue_type: - | Let me classify your issue... - - actions: - # Classify the issue - classify_customer_issue: @actions.classify_issue - available when @variables.issue_description and not @variables.issue_type - with issue_description=@variables.issue_description - set @variables.issue_type = @outputs.issue_type - set @variables.issue_priority = @outputs.priority - # Transition to resolution workflow - transition to @subagent.resolution - -# SUBAGENT 2: Resolution Workflow -subagent resolution: - description: "Attempt to resolve issue using knowledge base" - - actions: - search_knowledge_base: - description: "Search knowledge base for solutions" - inputs: - issue_type: string - description: "The classified type of issue to search solutions for" - is_required: True - keywords: string - description: "Keywords extracted from the issue description for refined search" - is_required: True - outputs: - articles: list[string] - description: "List of knowledge base article objects matching the search criteria" - top_article: object - description: "The most relevant knowledge base article object with solution content" - target: "flow://SearchKnowledgeBase" - - create_case: - description: "Create support case" - inputs: - customer_id: string - description: "The unique identifier of the customer the case is for" - is_required: True - subject: string - description: "Brief subject line summarizing the case" - is_required: True - case_description: string - description: "Detailed description of the customer's issue" - is_required: True - priority: string - description: "Priority level of the case (low, medium, high, urgent)" - is_required: True - issue_type: string - description: "The classified type of issue (billing, technical, account, product)" - is_required: True - outputs: - case_id: string - description: "Unique identifier assigned to the newly created case" - case_number: string - description: "Human-readable case number for customer reference" - target: "flow://CreateCase" - - update_case: - description: "Update case status" - inputs: - case_id: string - description: "The unique identifier of the case to update" - is_required: True - status: string - description: "New status for the case (new, in_progress, resolved, escalated, closed)" - is_required: True - notes: string - description: "Notes or comments about the case update" - is_required: False - outputs: - updated: boolean - description: "Indicates whether the case was updated successfully" - target: "flow://UpdateCase" - - reasoning: - instructions:-> - | Issue Classification: - - Type: {!@variables.issue_type} - - Priority: {!@variables.issue_priority} - - if @variables.customer_tier == "premium": - | (Premium customer - prioritizing resolution) - - | Searching knowledge base for solutions... - - actions: - # Search knowledge base - find_solution: @actions.search_knowledge_base - available when @variables.issue_type and not @variables.kb_article_found - with issue_type=@variables.issue_type - with keywords=@variables.issue_description - set @variables.kb_article_found = True - set @variables.kb_article_content = @outputs.top_article - - # Create case for tracking - run @actions.create_case - with customer_id=@variables.customer_id - with subject="{!@variables.issue_type}: {!@variables.issue_description}" - with case_description=@variables.issue_description - with priority=@variables.issue_priority - with issue_type=@variables.issue_type - set @variables.case_id = @outputs.case_id - set @variables.case_status = "in_progress" - - # If KB solution found, present it - present_solution: @utils.transition to @subagent.solution_presentation - available when @variables.kb_article_found - - # If no KB solution, escalate - escalate_to_specialist: @utils.transition to @subagent.escalation - available when not @variables.kb_article_found - -# SUBAGENT 3: Solution Presentation -subagent solution_presentation: - description: "Present solution and verify resolution" - - actions: - update_case: - description: "Update case status" - inputs: - case_id: string - description: "The unique identifier of the case to update" - is_required: True - status: string - description: "New status for the case (new, in_progress, resolved, escalated, closed)" - is_required: True - notes: string - description: "Notes or comments about the case update" - is_required: False - outputs: - updated: boolean - description: "Indicates whether the case was updated successfully" - target: "flow://UpdateCase" - - reasoning: - instructions:-> - | I found a solution for your {!@variables.issue_type} issue! - - {!@variables.kb_article_content} - - Does this resolve your issue? - - actions: - # Mark resolved - mark_resolved: @actions.update_case - with case_id=@variables.case_id - with status="resolved" - with notes="Resolved using KB article" - set @variables.issue_resolved = True - set @variables.case_status = "resolved" - # Transition to closure - transition to @subagent.closure - - # Need more help - escalate - need_more_help: @utils.transition to @subagent.escalation - -# SUBAGENT 4: Escalation -subagent escalation: - description: "Escalate complex issues to specialists" - - actions: - escalate_case: - description: "Escalate case to specialist" - inputs: - case_id: string - description: "The unique identifier of the case to escalate" - is_required: True - reason: string - description: "Reason for escalation (e.g., complex issue, customer request, policy exception)" - is_required: True - specialist_team: string - description: "The specialist team to escalate to based on issue type" - is_required: True - outputs: - escalated: boolean - description: "Indicates whether the case was escalated successfully" - specialist_assigned: string - description: "Name or ID of the specialist assigned to handle the escalated case" - target: "flow://EscalateCase" - - update_case: - description: "Update case status" - inputs: - case_id: string - description: "The unique identifier of the case to update" - is_required: True - status: string - description: "New status for the case (new, in_progress, resolved, escalated, closed)" - is_required: True - notes: string - description: "Notes or comments about the case update" - is_required: False - outputs: - updated: boolean - description: "Indicates whether the case was updated successfully" - target: "flow://UpdateCase" - - reasoning: - instructions:-> - | This issue requires specialist attention. - - if @variables.issue_priority == "high" or @variables.issue_priority == "urgent": - | Due to the high priority, I'm escalating this immediately. - if @variables.customer_tier == "premium": - | As a premium customer, connecting you with a senior specialist. - - | Creating escalation... - - actions: - escalate_issue: @actions.escalate_case - with case_id=@variables.case_id - with reason=@variables.escalation_reason - with specialist_team=@variables.issue_type - set @variables.requires_escalation = True - - # Update case - run @actions.update_case - with case_id=@variables.case_id - with status="escalated" - with notes="Escalated to {!@variables.issue_type} team" - set @variables.case_status = "escalated" - - # Transition to closure - transition to @subagent.closure - -# SUBAGENT 5: Closure -subagent closure: - description: "Close conversation and collect feedback" - - actions: - send_satisfaction_survey: - description: "Send customer satisfaction survey" - inputs: - customer_email: string - description: "Email address to send the satisfaction survey to" - is_required: True - case_id: string - description: "The case ID to associate with the survey response" - is_required: True - outputs: - sent: boolean - description: "Indicates whether the satisfaction survey was sent successfully" - target: "flow://SendSatisfactionSurvey" - - reasoning: - instructions:-> - if @variables.issue_resolved: - | ✅ Great! Your issue has been resolved. - Case ID: {!@variables.case_id} - - I'm sending a satisfaction survey to {!@variables.customer_email}. - if @variables.requires_escalation: - | Your case ({!@variables.case_id}) has been escalated to our specialist team. - They will contact you within: - if @variables.issue_priority == "urgent": - | - 1 hour - if @variables.issue_priority == "high": - | - 4 hours - if @variables.issue_priority == "medium": - | - 24 hours - - | Is there anything else I can help you with? - - actions: - # Send satisfaction survey - send_survey: @actions.send_satisfaction_survey - available when @variables.issue_resolved - with customer_email=@variables.customer_email - with case_id=@variables.case_id - - # Start new issue - new_issue: @utils.transition to @subagent.triage diff --git a/force-app/future_recipes/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls b/force-app/future_recipes/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls deleted file mode 100644 index 6138acf..0000000 --- a/force-app/future_recipes/customerServiceAgent/classes/CustomerServiceAgentFlowTest.cls +++ /dev/null @@ -1,266 +0,0 @@ -/** - * @description Test class for Customer Service Agent Flows - * Tests all flows used in the customer service agent recipe - */ -@IsTest -private class CustomerServiceAgentFlowTest { - @TestSetup - static void setup() { - // Create test contact with customer ID - Contact testContact = new Contact( - FirstName = 'Test', - LastName = 'Customer', - Email = 'test@example.com', - Customer_ID__c = 'CUST-12345', - Loyalty_Tier__c = 'Premium' - ); - insert testContact; - - // Create test account - Account testAccount = new Account(Name = 'Test Account'); - insert testAccount; - - testContact.AccountId = testAccount.Id; - update testContact; - } - - @IsTest - static void testFetchCustomerSuccess() { - // Set input variables for the flow - Map inputs = new Map(); - inputs.put('customer_id', 'CUST-12345'); - - // Run the flow - Test.startTest(); - Flow.Interview.FetchCustomer flowInterview = new Flow.Interview.FetchCustomer( - inputs - ); - flowInterview.start(); - Test.stopTest(); - - // Verify the expected outcome - String name = (String) flowInterview.getVariableValue('name'); - String email = (String) flowInterview.getVariableValue('email'); - String tier = (String) flowInterview.getVariableValue('tier'); - - Assert.areEqual('Customer', name, 'Customer name should match'); - Assert.areEqual('test@example.com', email, 'Email should match'); - Assert.areEqual('Premium', tier, 'Tier should match'); - } - - @IsTest - static void testCreateCaseSuccess() { - // Set input variables for the flow - Map inputs = new Map(); - inputs.put('customer_id', 'CUST-12345'); - inputs.put('subject', 'Test Case Subject'); - inputs.put('case_description', 'Test Case Description'); - inputs.put('priority', 'High'); - inputs.put('issue_type', 'Technical'); - - // Run the flow - Test.startTest(); - Flow.Interview.CreateCase flowInterview = new Flow.Interview.CreateCase( - inputs - ); - flowInterview.start(); - Test.stopTest(); - - // Verify the expected outcome - String caseId = (String) flowInterview.getVariableValue('case_id'); - Assert.areNotEqual(null, caseId, 'Case ID should be returned'); - - // Verify case was created - Case createdCase = [ - SELECT Id, Subject, Description, Priority, Type, ContactId - FROM Case - WHERE Id = :caseId - ]; - Assert.areEqual( - 'Test Case Subject', - createdCase.Subject, - 'Subject should match' - ); - Assert.areEqual( - 'Test Case Description', - createdCase.Description, - 'Description should match' - ); - Assert.areEqual('High', createdCase.Priority, 'Priority should match'); - Assert.areEqual('Technical', createdCase.Type, 'Type should match'); - } - - @IsTest - static void testUpdateCaseSuccess() { - // Create a test case first - Contact testContact = [ - SELECT Id - FROM Contact - WHERE Customer_ID__c = 'CUST-12345' - LIMIT 1 - ]; - Case testCase = new Case( - Subject = 'Original Subject', - Description = 'Original Description', - ContactId = testContact.Id, - Status = 'New' - ); - insert testCase; - - // Set input variables for the flow - Map inputs = new Map(); - inputs.put('case_id', testCase.Id); - inputs.put('status', 'In Progress'); - inputs.put('notes', 'Updated notes'); - - // Run the flow - Test.startTest(); - Flow.Interview.UpdateCase flowInterview = new Flow.Interview.UpdateCase( - inputs - ); - flowInterview.start(); - Test.stopTest(); - - // Verify the expected outcome - Boolean updated = (Boolean) flowInterview.getVariableValue('updated'); - Assert.areEqual(true, updated, 'Update should be successful'); - - // Verify case was updated - Case updatedCase = [ - SELECT Id, Status - FROM Case - WHERE Id = :testCase.Id - ]; - Assert.areEqual( - 'In Progress', - updatedCase.Status, - 'Status should be updated' - ); - } - - @IsTest - static void testSearchKnowledgeBase() { - // Set input variables for the flow - Map inputs = new Map(); - inputs.put('issue_type', 'Technical'); - inputs.put('keywords', 'password reset'); - - // Run the flow - Test.startTest(); - Flow.Interview.SearchKnowledgeBase flowInterview = new Flow.Interview.SearchKnowledgeBase( - inputs - ); - flowInterview.start(); - Test.stopTest(); - - // Verify the flow executes and returns articles - List articles = (List) flowInterview.getVariableValue( - 'articles' - ); - Assert.areNotEqual(null, articles, 'Articles should be returned'); - Assert.areEqual( - true, - articles.size() > 0, - 'At least one article should be returned' - ); - } - - @IsTest - static void testEscalateCase() { - // Create a test case first - Contact testContact = [ - SELECT Id - FROM Contact - WHERE Customer_ID__c = 'CUST-12345' - LIMIT 1 - ]; - Case testCase = new Case( - Subject = 'Test Case', - ContactId = testContact.Id, - Status = 'New' - ); - insert testCase; - - // Set input variables for the flow - Map inputs = new Map(); - inputs.put('case_id', testCase.Id); - inputs.put('reason', 'Complex technical issue'); - inputs.put('specialist_team', 'Technical Support'); - - // Run the flow - Test.startTest(); - Flow.Interview.EscalateCase flowInterview = new Flow.Interview.EscalateCase( - inputs - ); - flowInterview.start(); - Test.stopTest(); - - // Verify the expected outcome - Boolean escalated = (Boolean) flowInterview.getVariableValue( - 'escalated' - ); - String specialistAssigned = (String) flowInterview.getVariableValue( - 'specialist_assigned' - ); - Assert.areEqual(true, escalated, 'Case should be escalated'); - Assert.areEqual( - 'Technical Support', - specialistAssigned, - 'Specialist team should be assigned' - ); - - // Verify case was updated - Case escalatedCase = [ - SELECT Id, Status - FROM Case - WHERE Id = :testCase.Id - ]; - Assert.areEqual( - 'Escalated', - escalatedCase.Status, - 'Status should be Escalated' - ); - } - - @IsTest - static void testSendSatisfactionSurvey() { - // Create a test case first - Contact testContact = [ - SELECT Id - FROM Contact - WHERE Customer_ID__c = 'CUST-12345' - LIMIT 1 - ]; - Case testCase = new Case( - Subject = 'Test Case', - ContactId = testContact.Id, - Status = 'Closed' - ); - insert testCase; - - // Set input variables for the flow - Map inputs = new Map(); - inputs.put('case_id', testCase.Id); - inputs.put('customer_email', 'test@example.com'); - - // Run the flow - Test.startTest(); - Flow.Interview.SendSatisfactionSurvey flowInterview = new Flow.Interview.SendSatisfactionSurvey( - inputs - ); - flowInterview.start(); - Test.stopTest(); - - // Verify the expected outcome - Boolean sent = (Boolean) flowInterview.getVariableValue('sent'); - Assert.areEqual(true, sent, 'Survey should be sent'); - - // Verify survey log was created - List surveyLogs = [ - SELECT Id - FROM ASR_Survey_Log__c - WHERE Case__c = :testCase.Id - ]; - Assert.areEqual(1, surveyLogs.size(), 'Survey log should be created'); - } -} diff --git a/force-app/future_recipes/customerServiceAgent/classes/IssueClassifier.cls b/force-app/future_recipes/customerServiceAgent/classes/IssueClassifier.cls deleted file mode 100644 index 1bffd50..0000000 --- a/force-app/future_recipes/customerServiceAgent/classes/IssueClassifier.cls +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @description Classifies issues based on description - */ -public with sharing class IssueClassifier { - /** - * @description Classifies the issue based on description - * @param issueDescriptions List of issue descriptions to classify - * @return List of IssueResult objects with classification details - */ - @InvocableMethod( - label='Classify Issue' - description='Classifies the issue based on description' - ) - public static List classify(List issueDescriptions) { - List results = new List(); - for (String description : issueDescriptions) { - IssueResult res = new IssueResult(); - res.issueType = 'General'; - res.priority = 'Medium'; - res.suggestedKbArticles = new List{ - 'Article 1', - 'Article 2' - }; - - if ( - description.containsIgnoreCase('urgent') || - description.containsIgnoreCase('critical') - ) { - res.priority = 'High'; - } - if ( - description.containsIgnoreCase('login') || - description.containsIgnoreCase('password') - ) { - res.issueType = 'Login Issue'; - } - results.add(res); - } - return results; - } - - /** - * @description Result class for issue classification - */ - public class IssueResult { - @InvocableVariable - public String issueType; - @InvocableVariable - public String priority; - @InvocableVariable - public List suggestedKbArticles; - } -} diff --git a/force-app/future_recipes/customerServiceAgent/classes/IssueClassifierTest.cls b/force-app/future_recipes/customerServiceAgent/classes/IssueClassifierTest.cls deleted file mode 100644 index 0898926..0000000 --- a/force-app/future_recipes/customerServiceAgent/classes/IssueClassifierTest.cls +++ /dev/null @@ -1,277 +0,0 @@ -/** - * @description Test class for IssueClassifier - * Tests the issue classification invocable method - */ -@IsTest -private class IssueClassifierTest { - @IsTest - static void testClassifyGeneralIssue() { - // Test with general issue description - List descriptions = new List{ - 'I need help with my account' - }; - - // Execute test - Test.startTest(); - List results = IssueClassifier.classify( - descriptions - ); - Test.stopTest(); - - // Verify results - Assert.areEqual(1, results.size(), 'Should return one result'); - Assert.areEqual( - 'General', - results[0].issueType, - 'Issue type should be General' - ); - Assert.areEqual( - 'Medium', - results[0].priority, - 'Priority should be Medium' - ); - Assert.areNotEqual( - null, - results[0].suggestedKbArticles, - 'KB articles should be provided' - ); - Assert.areEqual( - 2, - results[0].suggestedKbArticles.size(), - 'Should have two KB articles' - ); - } - - @IsTest - static void testClassifyUrgentIssue() { - // Test with urgent keyword - List descriptions = new List{ - 'This is an urgent matter that needs immediate attention' - }; - - // Execute test - Test.startTest(); - List results = IssueClassifier.classify( - descriptions - ); - Test.stopTest(); - - // Verify priority is set to High - Assert.areEqual(1, results.size(), 'Should return one result'); - Assert.areEqual( - 'High', - results[0].priority, - 'Priority should be High for urgent issue' - ); - Assert.areEqual( - 'General', - results[0].issueType, - 'Issue type should remain General' - ); - } - - @IsTest - static void testClassifyCriticalIssue() { - // Test with critical keyword - List descriptions = new List{ - 'This is a critical system failure' - }; - - // Execute test - Test.startTest(); - List results = IssueClassifier.classify( - descriptions - ); - Test.stopTest(); - - // Verify priority is set to High - Assert.areEqual(1, results.size(), 'Should return one result'); - Assert.areEqual( - 'High', - results[0].priority, - 'Priority should be High for critical issue' - ); - } - - @IsTest - static void testClassifyLoginIssue() { - // Test with login keyword - List descriptions = new List{ - 'I cannot login to my account' - }; - - // Execute test - Test.startTest(); - List results = IssueClassifier.classify( - descriptions - ); - Test.stopTest(); - - // Verify issue type is Login Issue - Assert.areEqual(1, results.size(), 'Should return one result'); - Assert.areEqual( - 'Login Issue', - results[0].issueType, - 'Issue type should be Login Issue' - ); - Assert.areEqual( - 'Medium', - results[0].priority, - 'Priority should be Medium' - ); - } - - @IsTest - static void testClassifyPasswordIssue() { - // Test with password keyword - List descriptions = new List{ - 'I forgot my password and need to reset it' - }; - - // Execute test - Test.startTest(); - List results = IssueClassifier.classify( - descriptions - ); - Test.stopTest(); - - // Verify issue type is Login Issue - Assert.areEqual(1, results.size(), 'Should return one result'); - Assert.areEqual( - 'Login Issue', - results[0].issueType, - 'Issue type should be Login Issue' - ); - } - - @IsTest - static void testClassifyUrgentLoginIssue() { - // Test with both urgent and login keywords - List descriptions = new List{ - 'This is an urgent login problem that needs immediate attention' - }; - - // Execute test - Test.startTest(); - List results = IssueClassifier.classify( - descriptions - ); - Test.stopTest(); - - // Verify both conditions are met - Assert.areEqual(1, results.size(), 'Should return one result'); - Assert.areEqual( - 'Login Issue', - results[0].issueType, - 'Issue type should be Login Issue' - ); - Assert.areEqual( - 'High', - results[0].priority, - 'Priority should be High for urgent issue' - ); - } - - @IsTest - static void testClassifyBulkIssues() { - // Test bulk processing with multiple descriptions - List descriptions = new List{ - 'General question about my account', - 'URGENT: System is down', - 'I cannot login', - 'Critical security breach detected', - 'Password reset needed' - }; - - // Execute test - Test.startTest(); - List results = IssueClassifier.classify( - descriptions - ); - Test.stopTest(); - - // Verify all issues were classified - Assert.areEqual(5, results.size(), 'Should return five results'); - - // Verify first issue (General) - Assert.areEqual( - 'General', - results[0].issueType, - 'First issue should be General' - ); - Assert.areEqual( - 'Medium', - results[0].priority, - 'First issue priority should be Medium' - ); - - // Verify second issue (Urgent) - Assert.areEqual( - 'High', - results[1].priority, - 'Second issue priority should be High' - ); - - // Verify third issue (Login) - Assert.areEqual( - 'Login Issue', - results[2].issueType, - 'Third issue should be Login Issue' - ); - - // Verify fourth issue (Critical) - Assert.areEqual( - 'High', - results[3].priority, - 'Fourth issue priority should be High' - ); - - // Verify fifth issue (Password) - Assert.areEqual( - 'Login Issue', - results[4].issueType, - 'Fifth issue should be Login Issue' - ); - } - - @IsTest - static void testClassifyCaseInsensitive() { - // Test case-insensitive matching - List descriptions = new List{ - 'URGENT issue', - 'CRITICAL problem', - 'LOGIN error', - 'PASSWORD reset' - }; - - // Execute test - Test.startTest(); - List results = IssueClassifier.classify( - descriptions - ); - Test.stopTest(); - - // Verify case-insensitive matching works - Assert.areEqual(4, results.size(), 'Should return four results'); - Assert.areEqual( - 'High', - results[0].priority, - 'URGENT should be detected' - ); - Assert.areEqual( - 'High', - results[1].priority, - 'CRITICAL should be detected' - ); - Assert.areEqual( - 'Login Issue', - results[2].issueType, - 'LOGIN should be detected' - ); - Assert.areEqual( - 'Login Issue', - results[3].issueType, - 'PASSWORD should be detected' - ); - } -} diff --git a/force-app/future_recipes/customerServiceAgent/flows/CreateCase.flow-meta.xml b/force-app/future_recipes/customerServiceAgent/flows/CreateCase.flow-meta.xml deleted file mode 100644 index 6c125ee..0000000 --- a/force-app/future_recipes/customerServiceAgent/flows/CreateCase.flow-meta.xml +++ /dev/null @@ -1,131 +0,0 @@ - - - 66.0 - Default - CreateCase {!$Flow.CurrentDateTime} - - - BuilderType - - LightningFlowBuilder - - - AutoLaunchedFlow - - 50 - 0 - - Get_Contact - - - Active - - Get_Contact - - 176 - 134 - false - - Create_Case - - and - - Customer_ID__c - EqualTo - - customer_id - - - true - Contact - true - - - Create_Case - - 176 - 242 - case_id - - Subject - - subject - - - - Description - - case_description - - - - Priority - - priority - - - - Type - - issue_type - - - - ContactId - - Get_Contact.Id - - - Case - - - customer_id - String - false - true - false - - - subject - String - false - true - false - - - case_description - String - false - true - false - - - priority - String - false - true - false - - - issue_type - String - false - true - false - - - case_id - String - false - false - true - - - case_number - String - false - false - true - - diff --git a/force-app/future_recipes/customerServiceAgent/flows/SendSatisfactionSurvey.flow-meta.xml b/force-app/future_recipes/customerServiceAgent/flows/SendSatisfactionSurvey.flow-meta.xml deleted file mode 100644 index c9c48bb..0000000 --- a/force-app/future_recipes/customerServiceAgent/flows/SendSatisfactionSurvey.flow-meta.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - 66.0 - Default - SendSatisfactionSurvey {!$Flow.CurrentDateTime} - - - BuilderType - - LightningFlowBuilder - - - AutoLaunchedFlow - - 50 - 0 - - Create_Survey_Log - - - Active - - Create_Survey_Log - - 176 - 134 - - Assign_Result - - - Case__c - - case_id - - - - Sent_Date__c - - $Flow.CurrentDateTime - - - ASR_Survey_Log__c - - - Assign_Result - - 176 - 242 - - sent - Assign - - true - - - - - customer_email - String - false - true - false - - - case_id - String - false - true - false - - - sent - Boolean - false - false - true - - diff --git a/force-app/future_recipes/customerServiceAgent/flows/UpdateCase.flow-meta.xml b/force-app/future_recipes/customerServiceAgent/flows/UpdateCase.flow-meta.xml deleted file mode 100644 index fc8ee53..0000000 --- a/force-app/future_recipes/customerServiceAgent/flows/UpdateCase.flow-meta.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - 66.0 - Default - UpdateCase {!$Flow.CurrentDateTime} - - - BuilderType - - LightningFlowBuilder - - - AutoLaunchedFlow - - 50 - 0 - - Update_Case_Record - - - Active - - Update_Case_Record - - 176 - 134 - - Assign_Result - - and - - Id - EqualTo - - case_id - - - - Status - - status - - - Case - - - Assign_Result - - 176 - 242 - - updated - Assign - - true - - - - - case_id - String - false - true - false - - - status - String - false - true - false - - - notes - String - false - true - false - - - updated - Boolean - false - false - true - - diff --git a/force-app/future_recipes/customerServiceAgent/objects/Survey_Log__c/Survey_Log__c.object-meta.xml b/force-app/future_recipes/customerServiceAgent/objects/Survey_Log__c/Survey_Log__c.object-meta.xml deleted file mode 100644 index dc7c22d..0000000 --- a/force-app/future_recipes/customerServiceAgent/objects/Survey_Log__c/Survey_Log__c.object-meta.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - Deployed - Survey Log for Customer Service Agent - - Survey Logs - ReadWrite - - - AutoNumber - SL-{0000} - - diff --git a/force-app/future_recipes/customerServiceAgent/objects/Survey_Log__c/fields/Case__c.field-meta.xml b/force-app/future_recipes/customerServiceAgent/objects/Survey_Log__c/fields/Case__c.field-meta.xml deleted file mode 100644 index 8d2884c..0000000 --- a/force-app/future_recipes/customerServiceAgent/objects/Survey_Log__c/fields/Case__c.field-meta.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - Case__c - - Lookup - Case - Survey Logs - Survey_Logs - diff --git a/force-app/future_recipes/customerServiceAgent/objects/Survey_Log__c/fields/Sent_Date__c.field-meta.xml b/force-app/future_recipes/customerServiceAgent/objects/Survey_Log__c/fields/Sent_Date__c.field-meta.xml deleted file mode 100644 index 8c94faa..0000000 --- a/force-app/future_recipes/customerServiceAgent/objects/Survey_Log__c/fields/Sent_Date__c.field-meta.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - Sent_Date__c - - DateTime - diff --git a/package.json b/package.json index dd06df3..9a15c27 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "prettier:verify": "prettier --check \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", "prepare": "husky || true", "precommit": "lint-staged", - "validate:agents": "node bin/validate-agents.js" + "validate:agents": "node scripts/validate-agents.js", + "setup:service-agent": "node scripts/setup-service-agent.js", + "clean:service-agent": "node scripts/clean-service-agent.js" }, "lint-staged": { "**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [ diff --git a/scripts/clean-service-agent.js b/scripts/clean-service-agent.js new file mode 100644 index 0000000..33d7e10 --- /dev/null +++ b/scripts/clean-service-agent.js @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const SERVICE_AGENT_DIR = path.resolve(__dirname, '..', 'force-app-service'); +const PLACEHOLDER = '__AGENT_USER_PLACEHOLDER__'; +const AGENT_USER_REGEX = + /default_agent_user:\s*"(?!__AGENT_USER_PLACEHOLDER__).+"/g; +const REPLACEMENT = 'default_agent_user: "__AGENT_USER_PLACEHOLDER__"'; + +function findAgentFiles(dir) { + const results = []; + + function walk(currentDir) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.name.endsWith('.agent')) { + results.push(fullPath); + } + } + } + + walk(dir); + return results; +} + +if (!fs.existsSync(SERVICE_AGENT_DIR)) { + process.exit(0); +} + +const agentFiles = findAgentFiles(SERVICE_AGENT_DIR); +let restoredCount = 0; + +for (const filePath of agentFiles) { + let content = fs.readFileSync(filePath, 'utf8'); + + if (AGENT_USER_REGEX.test(content)) { + content = content.replace(AGENT_USER_REGEX, REPLACEMENT); + fs.writeFileSync(filePath, content, 'utf8'); + execSync(`git add "${filePath}"`); + const relativePath = path.relative(SERVICE_AGENT_DIR, filePath); + console.log(`Restored placeholder in: ${relativePath}`); + restoredCount++; + } +} + +if (restoredCount > 0) { + console.log( + `\nRestored ${PLACEHOLDER} in ${restoredCount} file(s) before commit.` + ); +} diff --git a/scripts/setup-service-agent.js b/scripts/setup-service-agent.js new file mode 100644 index 0000000..fa6845e --- /dev/null +++ b/scripts/setup-service-agent.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node + +'use strict'; + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const SERVICE_AGENT_DIR = path.resolve(__dirname, '..', 'force-app-service'); +const PLACEHOLDER = '__AGENT_USER_PLACEHOLDER__'; + +function parseArgs() { + const args = process.argv.slice(2); + let targetOrg = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--target-org' && args[i + 1]) { + targetOrg = args[i + 1]; + i++; + } + } + + return { targetOrg }; +} + +function findAgentFiles(dir) { + const results = []; + + function walk(currentDir) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.name.endsWith('.agent')) { + results.push(fullPath); + } + } + } + + walk(dir); + return results; +} + +function log(msg) { + process.stderr.write(msg + '\n'); +} + +function createAgentUser(targetOrg) { + const orgFlag = targetOrg ? ` -o ${targetOrg}` : ''; + const cmd = `sf org create agent-user${orgFlag} --json`; + + log(`Running: ${cmd}`); + + try { + const output = execSync(cmd, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + const result = JSON.parse(output); + + if (result.status !== 0) { + console.error('Failed to create agent user:', result.message); + process.exit(1); + } + + return result.result.username; + } catch (error) { + console.error('Error creating agent user:', error.message); + process.exit(1); + } +} + +function replacePlaceholders(username) { + if (!fs.existsSync(SERVICE_AGENT_DIR)) { + console.error( + `Service agent directory not found: ${SERVICE_AGENT_DIR}` + ); + process.exit(1); + } + + const agentFiles = findAgentFiles(SERVICE_AGENT_DIR); + + if (agentFiles.length === 0) { + console.error('No .agent files found in force-app-service/'); + process.exit(1); + } + + let replacedCount = 0; + + for (const filePath of agentFiles) { + let content = fs.readFileSync(filePath, 'utf8'); + + if (content.includes(PLACEHOLDER)) { + content = content.replace(PLACEHOLDER, username); + fs.writeFileSync(filePath, content, 'utf8'); + log(`Updated: ${path.relative(SERVICE_AGENT_DIR, filePath)}`); + replacedCount++; + } + } + + if (replacedCount === 0) { + log( + `Warning: No files contained the placeholder "${PLACEHOLDER}". They may have already been replaced.` + ); + } else { + log( + `\nReplaced placeholder in ${replacedCount} file(s) with agent user: ${username}` + ); + log( + '\nReminder: Do not commit the modified agent file(s). The pre-commit hook will restore the placeholder automatically.' + ); + } +} + +const { targetOrg } = parseArgs(); +const username = createAgentUser(targetOrg); +replacePlaceholders(username); + +process.stdout.write(username); diff --git a/bin/validate-agents.js b/scripts/validate-agents.js similarity index 99% rename from bin/validate-agents.js rename to scripts/validate-agents.js index d5dcb45..0d9e351 100644 --- a/bin/validate-agents.js +++ b/scripts/validate-agents.js @@ -4,7 +4,7 @@ // // Usage: // npm run validate:agents -// node bin/validate-agents.js [--files path1 path2 ...] +// node scripts/validate-agents.js [--files path1 path2 ...] 'use strict'; diff --git a/sfdx-project.json b/sfdx-project.json index e02b252..eec1619 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -6,6 +6,10 @@ "package": "AgentScriptRecipes", "versionName": "Spring '26", "versionNumber": "66.0.0.NEXT" + }, + { + "path": "force-app-service", + "default": false } ], "name": "agent-script-recipes",