Skip to Content
Part 5: Chat AgentCh 21: Tool Dispatch

Chapter 20 covered the tool registry and intent classification. This chapter covers what happens after classification: dispatching tools, handling disambiguation, adaptive enrichment, and the three execution branches.

The Three Branches

After intent classification, the orchestrator takes one of three paths:

Classification ├── isConfirmation === true → Branch A: Execute pending action ├── isAction === true → Branch B: Propose or execute action └── default → Branch C: Data query

Branch A: Confirmation

When the user says “yes, send it” or “go ahead”, the classifier sets isConfirmation: true. The orchestrator looks up the pending_action from the last assistant message’s tool_calls:

// Walk backward through history to find the pending action let pendingAction = null; for (let i = history.length - 1; i >= 0; i--) { const msg = history[i]; if (msg.role === 'assistant' && msg.tool_calls?.pending_action) { pendingAction = msg.tool_calls.pending_action; break; } } if (pendingAction) { const result = await executeAction(userId, pendingAction); // Generate a response describing the result via 70B }

This is how Cosmo’s confirmation flow works. A prior message stored a pending_action (e.g., “send email to sarah@acme.com with subject X”), and the confirmation triggers execution.

Branch B: Action Requests

When the user says “send a Slack message to the team” or “create a task for Acme Corp”, the classifier sets isAction: true with actionParams.

Actions split into three sub-branches:

B1 — Report tools (generate_flash_report, generate_monthly_review, generate_board_deck): These run data tools first, feed results to the 70B model, and append a PDF download link.

B2 — Safe action tools (draft_email, log_touch, create_followup_task, add_to_pipeline, test_slack, etc.): These execute immediately without confirmation. They’re “safe” because they’re either reversible, low-stakes, or create drafts rather than sending things.

const SAFE_ACTION_TOOLS = [ 'check_slack_status', 'connect_slack', 'test_slack', 'generate_flash_report', 'generate_monthly_review', 'generate_board_deck', 'connect_google', 'add_to_pipeline', 'draft_email', 'log_touch', 'create_followup_task', ];

B3 — Confirmation-required actions (send_email, notify_rep, broadcast_team, push_hubspot_scores): These resolve entities first, run preflight checks, then store a pending_action and ask the user to confirm.

User: "Send an email to Sarah at Acme Corp about the pricing update" ↓ 1. resolveCompany("Acme Corp") → company UUID 2. resolveContact("Sarah", companyId) → contact with email 3. Preflight: verify email exists, HubSpot connected 4. Store pending_action in tool_calls 5. 70B generates: "I'll send an email to sarah@acme.com about the pricing update. Should I proceed?" ↓ User: "Yes" ↓ Branch A: executeAction() → sends via HubSpot API

Branch C: Data Queries (The Common Path)

Most messages are data queries. This is the richest branch.

Parallel Tool Execution

Data tools run in parallel — if the classifier selected three tools, all three fire simultaneously:

const dataTools = classification.tools.filter( (t): t is DataToolName => !ACTION_TOOL_NAMES.includes(t as ActionToolName) ); const toolResults: Record<string, unknown> = {}; const toolPromises = dataTools.map(async (toolName) => { const toolFn = chatTools[toolName]; if (toolFn) { const result = await toolFn(userId, icpProfileId, classification.searchTerm); toolResults[toolName] = result; } }); await Promise.all(toolPromises);

Three database queries running concurrently instead of sequentially — this is why Cosmo’s responses feel fast despite touching multiple data sources.

Entity Resolution and Disambiguation

When a tool involves a specific company (“tell me about Acme Corp”), the tool needs to find the right company. This happens through findCompanyMatch():

// src/infrastructure/chat/tools/shared.ts export async function findCompanyMatch(userId: string, searchTerm: string) { // UUID path: if searchTerm is a UUID, load directly if (UUID_REGEX.test(searchTerm)) { return loadCompanyById(userId, searchTerm); } // Name path: search by name const candidates = await resolveCompanyCandidates(userId, searchTerm, 5); if (candidates.length === 0) return null; if (candidates.length === 1) return candidates[0]; // Multiple matches: return disambiguation result return { type: 'disambiguation', message: `I found ${candidates.length} companies matching "${searchTerm}". Which one did you mean?`, candidates: candidates.map(c => ({ id: c.id, name: c.name, domain: c.domain, industry: c.industry, employeeCount: c.employeeCount, })), }; }

The Disambiguation UI Flow

When multiple companies match, the orchestrator detects the DisambiguationResult and returns early — before calling the LLM:

// Check for internal disambiguation for (const [toolName, result] of Object.entries(toolResults)) { if (isDisambiguationResult(result)) { // Build button_group with one button per candidate const actions = result.candidates.map(c => ({ type: 'action_button', actionTool: 'select_company', label: c.name, description: `${c.industry} • ${c.domain}`, params: { companyId: c.id, companyName: c.name }, })); // Return immediately — no LLM call needed return { content: result.message, tool_calls: { chat_actions: actions }, }; } }

The user sees buttons like:

I found 3 companies matching "Acme". Which one did you mean? [Acme Corp — SaaS • acme.com] [Acme Industries — Manufacturing • acme-ind.com] [Acme Labs — Biotech • acmelabs.io]

Clicking a button sends a select_company action back to the chat API, which re-runs the original query with the resolved company ID.

External Company Lookup

If a company isn’t in the database, Cosmo searches the web:

// tools/shared.ts export async function lookupExternalCompany(userId: string, searchTerm: string) { // 1. Check discovery_results cache (7-day TTL) const cached = await checkDiscoveryCache(userId, searchTerm); if (cached) return cached; // 2. Three parallel Serper searches const [profileResults, leadershipResults, disambigResults] = await Promise.all([ serperSearch(`${searchTerm} company profile`), serperSearch(`${searchTerm} leadership team`), serperSearch(`${searchTerm} company disambiguation`), ]); // 3. LLM checks for ambiguity const ambiguity = await detectExternalAmbiguity(searchTerm, allResults); // 4. If multiple distinct companies found, return external disambiguation if (ambiguity.isAmbiguous) { return { type: 'external_disambiguation', candidates: [...] }; } // 5. Single company: extract full profile, verify email, cache to discovery_results const profile = await extractCompanyProfile(searchTerm, allResults); return profile; }

This is how Cosmo can answer questions about companies that aren’t in your CRM — it enriches on the fly.

Adaptive Enrichment

Sometimes a tool returns thin results. Adaptive enrichment automatically runs fallback tools:

// If company detail returned but has few data points, enrich if (toolResults.get_company_detail && isCompanyThin(toolResults.get_company_detail)) { const fallbacks = ['get_intent_signals', 'find_contacts', 'get_deal_risk']; const enrichments = fallbacks.slice(0, 3).map(tool => chatTools[tool]?.(userId, icpProfileId, searchTerm) ); const enrichResults = await Promise.all(enrichments); // Merge enrichment results into toolResults }

Up to 3 fallback tools fire if the primary result is sparse. This ensures Cosmo always has enough context to generate a useful response.

Building the LLM Prompt

After tools execute, the orchestrator builds context for the 70B model:

// 1. Cosmo-level context (cached 1 hour) const cosmoContext = await buildCosmoContext(userId); // → pipeline snapshot, win profile, exploration state, recent activity // 2. Company-specific context (if a company tool fired) const companyContext = await buildCompanyContext(userId, companyId); // → industry patterns, ICP alignment, engagement timeline, similar companies, pending tasks // 3. Assemble the prompt const systemPrompt = `You are Cosmo, a B2B sales intelligence assistant. Context about the user's business: ${JSON.stringify(cosmoContext)} ${companyContext ? `Company context: ${JSON.stringify(companyContext)}` : ''}`; // 4. Tool results formatted with descriptions const toolContext = Object.entries(toolResults).map(([tool, result]) => `=== ${TOOL_DESCRIPTIONS[tool]} ===\n${JSON.stringify(result)}` ).join('\n\n');

The 70B model gets: system prompt (who you are) + conversation history (last 6 messages) + tool results (the data) + company context (the background). It synthesizes all of this into a natural language response.

Token Budget Management

History messages are truncated to manage token budgets:

// Last 2 messages: 2,000 chars each // Older messages: 500 chars each const truncatedHistory = history.slice(-6).map((msg, i) => ({ ...msg, content: msg.content.slice(0, i >= history.length - 2 ? 2000 : 500), }));

Recent messages get full context; older messages are summarized. This keeps the prompt within the 70B model’s context window while preserving conversational flow.

Company and Contact Annotation

After the LLM generates text, company and contact names are annotated with clickable markers:

const annotated = annotateCompanyNames(response, toolResults); const finalResponse = annotateContactNames(annotated, toolResults);

This transforms:

"Acme Corp's pipeline looks strong, and Sarah Chen has been very responsive."

Into:

"[[COMPANY:uuid-123:Acme Corp]]'s pipeline looks strong, and [[CONTACT:uuid-456:Sarah Chen]] has been very responsive."

The React UI parses these markers and renders them as clickable links that open the company detail panel or contact view.

Contextual Action Buttons

The final step injects action buttons based on what tools returned:

// If company data returned → offer "Add to Pipeline", "Draft Email" // If contacts returned → offer "Log Touch", "Create Task" // If stalled deals found → offer "Create Follow-up Tasks" // If risk analysis returned → offer "Notify Rep"

These buttons are stored in tool_calls.chat_actions and rendered as a row of action chips below the message. Clicking one triggers a new chat action — effectively a one-click shortcut to Cosmo’s action tools.

Key Takeaways

  1. Three execution branches handle confirmations, actions, and data queries with different flows for each.

  2. Parallel tool execution via Promise.all keeps response times fast even when multiple tools fire.

  3. Two-layer disambiguation handles both internal (database) and external (web search) company resolution with interactive buttons.

  4. Adaptive enrichment automatically fills gaps when primary tools return thin results.

  5. Company/contact annotations and contextual action buttons make every response interactive, not just text.

Next chapter: the Goldilocks Brain — how Cosmo generates strategic recommendations.

Last updated on