Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions src/router/ackMessageGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* LLM-generated acknowledgment messages for webhook events.
*
* Makes a single-shot LLM call to a lightweight model (same as progress tracking)
* to produce a short, contextual ack message that reflects the actual request.
* Gracefully falls back to static INITIAL_MESSAGES on any failure.
*/

import { AgentBuilder, LLMist, type ModelSpec } from 'llmist';

import { INITIAL_MESSAGES } from '../config/agentMessages.js';
import { CUSTOM_MODELS } from '../config/customModels.js';
import { getOrgCredential, loadConfig } from '../config/provider.js';

// ---------------------------------------------------------------------------
// System prompt for ack message generation
// ---------------------------------------------------------------------------

const ACK_SYSTEM_PROMPT = `You write brief acknowledgment messages for CASCADE, an AI coding automation platform.
Given the agent type and request context, write a SHORT 1-sentence message confirming understanding of the request. Keep it under 25 words. Use markdown bold for the header. Start with an appropriate emoji. Do not mention implementation details — just confirm what you'll be working on.`;

// ---------------------------------------------------------------------------
// Context extractors — pull relevant snippets from webhook payloads
// ---------------------------------------------------------------------------

const MAX_CONTEXT_LENGTH = 500;

function truncate(text: string, maxLength: number = MAX_CONTEXT_LENGTH): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength)}…`;
}

/**
* Extract context from a Trello webhook payload.
* Pulls card name and optional comment text.
*/
export function extractTrelloContext(payload: unknown): string {
if (!payload || typeof payload !== 'object') return '';

const p = payload as Record<string, unknown>;
const action = p.action as Record<string, unknown> | undefined;
if (!action) return '';

const data = action.data as Record<string, unknown> | undefined;
if (!data) return '';

const parts: string[] = [];

const card = data.card as Record<string, unknown> | undefined;
if (card?.name) {
parts.push(`Card: ${card.name as string}`);
}

// Comment text (for commentCard actions)
const text = data.text as string | undefined;
if (text) {
parts.push(`Comment: ${text}`);
}

return truncate(parts.join('\n'));
}

/**
* Extract context from a GitHub webhook payload.
* Pulls PR title and optional comment/review body.
*/
export function extractGitHubContext(payload: unknown, eventType: string): string {
if (!payload || typeof payload !== 'object') return '';

const p = payload as Record<string, unknown>;
const parts: string[] = [];

const pr = p.pull_request as Record<string, unknown> | undefined;
if (pr?.title) {
parts.push(`PR: ${pr.title as string}`);
}

// Comment body (issue_comment or pull_request_review_comment)
if (eventType === 'issue_comment' || eventType === 'pull_request_review_comment') {
const comment = p.comment as Record<string, unknown> | undefined;
if (comment?.body) {
parts.push(`Comment: ${comment.body as string}`);
}
}

// Review body (pull_request_review)
if (eventType === 'pull_request_review') {
const review = p.review as Record<string, unknown> | undefined;
if (review?.body) {
parts.push(`Review: ${review.body as string}`);
}
}

return truncate(parts.join('\n'));
}

/**
* Extract context from a JIRA webhook payload.
* Pulls issue summary and optional comment body.
*/
export function extractJiraContext(payload: unknown): string {
if (!payload || typeof payload !== 'object') return '';

const p = payload as Record<string, unknown>;
const parts: string[] = [];

const issue = p.issue as Record<string, unknown> | undefined;
if (issue) {
const fields = issue.fields as Record<string, unknown> | undefined;
if (fields?.summary) {
parts.push(`Issue: ${fields.summary as string}`);
}
}

const comment = p.comment as Record<string, unknown> | undefined;
if (comment?.body) {
parts.push(`Comment: ${comment.body as string}`);
}

return truncate(parts.join('\n'));
}

// ---------------------------------------------------------------------------
// Core generator
// ---------------------------------------------------------------------------

const ACK_TIMEOUT_MS = 5_000;

const GENERIC_FALLBACK = '**⚙️ Working on it** — Processing your request...';

function getStaticFallback(agentType: string): string {
return INITIAL_MESSAGES[agentType] ?? GENERIC_FALLBACK;
}

/**
* Generate a contextual acknowledgment message using a lightweight LLM call.
*
* Falls back to static INITIAL_MESSAGES on any failure:
* - No progressModel configured
* - No OPENROUTER_API_KEY credential
* - Empty context snippet
* - LLM call failure (network, auth, etc.)
* - LLM call exceeds 5s timeout
* - LLM returns empty output
*/
export async function generateAckMessage(
agentType: string,
contextSnippet: string,
projectId: string,
): Promise<string> {
const fallback = getStaticFallback(agentType);

// No context to work with — use static message
if (!contextSnippet.trim()) {
return fallback;
}

let restoreEnv: (() => void) | undefined;

try {
// Load config to get progressModel
const config = await loadConfig();
const progressModel = config.defaults.progressModel;
if (!progressModel) {
return fallback;
}

// Resolve API key
const apiKey = await getOrgCredential(projectId, 'OPENROUTER_API_KEY');
if (!apiKey) {
return fallback;
}

// Temporarily inject API key into process.env (same pattern as llmEnv.ts)
const previousKey = process.env.OPENROUTER_API_KEY;
process.env.OPENROUTER_API_KEY = apiKey;
restoreEnv = () => {
if (previousKey === undefined) {
process.env.OPENROUTER_API_KEY = undefined;
} else {
process.env.OPENROUTER_API_KEY = previousKey;
}
};

// Single-shot LLM call with timeout
const llmPromise = callAckModel(progressModel, agentType, contextSnippet);
const timeoutPromise = new Promise<never>((_resolve, reject) => {
setTimeout(() => reject(new Error('Ack message generation timed out')), ACK_TIMEOUT_MS);
});

const result = await Promise.race([llmPromise, timeoutPromise]);

if (!result || !result.trim()) {
return fallback;
}

return result.trim();
} catch (err) {
console.warn('[Router] Ack message generation failed (using static fallback):', String(err));
return fallback;
} finally {
restoreEnv?.();
}
}

/**
* Make the actual single-shot LLM call to generate an ack message.
*/
async function callAckModel(
model: string,
agentType: string,
contextSnippet: string,
): Promise<string> {
const client = new LLMist({ customModels: CUSTOM_MODELS as ModelSpec[] });

const builder = new AgentBuilder(client)
.withModel(model)
.withTemperature(0)
.withSystem(ACK_SYSTEM_PROMPT)
.withMaxIterations(1)
.withGadgets();

const userPrompt = `Agent type: ${agentType}\n\nRequest context:\n${contextSnippet}`;
const agent = builder.ask(userPrompt);

const outputLines: string[] = [];
for await (const event of agent.run()) {
if (event.type === 'text' && event.content) {
outputLines.push(event.content);
}
}

return outputLines.join('\n').trim();
}
6 changes: 3 additions & 3 deletions src/router/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* and job queuing for GitHub webhook events.
*/

import { INITIAL_MESSAGES } from '../config/agentMessages.js';
import { findProjectByRepo } from '../config/provider.js';
import {
type PersonaIdentities,
Expand All @@ -14,6 +13,7 @@ import {
} from '../github/personas.js';
import type { TriggerRegistry } from '../triggers/registry.js';
import type { TriggerContext } from '../types/index.js';
import { extractGitHubContext, generateAckMessage } from './ackMessageGenerator.js';
import { postGitHubAck, resolveGitHubTokenForAck } from './acknowledgments.js';
import { loadProjectConfig } from './config.js';
import { extractPRNumber } from './notifications.js';
Expand Down Expand Up @@ -55,8 +55,8 @@ export async function tryPostGitHubAck(
const match = triggerRegistry.matchTrigger(ctx);
if (!match) return undefined;

const message = INITIAL_MESSAGES[match.agentType];
if (!message) return undefined;
const context = extractGitHubContext(payload, eventType);
const message = await generateAckMessage(match.agentType, context, fullProject.id);

const resolved = await resolveGitHubTokenForAck(repoFullName);
if (!resolved) return undefined;
Expand Down
6 changes: 3 additions & 3 deletions src/router/jira.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
* for JIRA webhook events.
*/

import { INITIAL_MESSAGES } from '../config/agentMessages.js';
import type { TriggerRegistry } from '../triggers/registry.js';
import type { ProjectConfig, TriggerContext } from '../types/index.js';
import { extractJiraContext, generateAckMessage } from './ackMessageGenerator.js';
import { postJiraAck, resolveJiraBotAccountId } from './acknowledgments.js';
import { type RouterProjectConfig, loadProjectConfig } from './config.js';
import { type CascadeJob, addJob } from './queue.js';
Expand Down Expand Up @@ -35,8 +35,8 @@ export async function tryPostJiraAck(
const match = triggerRegistry.matchTrigger(ctx);
if (!match) return undefined;

const message = INITIAL_MESSAGES[match.agentType];
if (!message) return undefined;
const context = extractJiraContext(payload);
const message = await generateAckMessage(match.agentType, context, projectId);

const commentId = await postJiraAck(projectId, issueKey, message);
return commentId ?? undefined;
Expand Down
6 changes: 3 additions & 3 deletions src/router/trello.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
* for Trello webhook events.
*/

import { INITIAL_MESSAGES } from '../config/agentMessages.js';
import type { TriggerRegistry } from '../triggers/registry.js';
import type { TriggerContext } from '../types/index.js';
import { extractTrelloContext, generateAckMessage } from './ackMessageGenerator.js';
import { postTrelloAck, resolveTrelloBotMemberId } from './acknowledgments.js';
import { type RouterProjectConfig, loadProjectConfig } from './config.js';
import { type CascadeJob, addJob } from './queue.js';
Expand Down Expand Up @@ -158,8 +158,8 @@ export async function tryPostTrelloAck(
const match = triggerRegistry.matchTrigger(ctx);
if (!match) return undefined;

const message = INITIAL_MESSAGES[match.agentType];
if (!message) return undefined;
const context = extractTrelloContext(payload);
const message = await generateAckMessage(match.agentType, context, projectId);

const commentId = await postTrelloAck(projectId, cardId, message);
return commentId ?? undefined;
Expand Down
Loading