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
26 changes: 24 additions & 2 deletions src/lib/bot/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getConversationContext,
formatConversationContextForPrompt,
} from '@/lib/bot/conversation-context';
import { buildPrSignature, getRequesterInfo } from '@/lib/bot/pr-signature';
import { updateBotRequest, linkBotRequestToSession } from '@/lib/bot/request-logging';
import spawnCloudAgentSession, {
spawnCloudAgentInputSchema,
Expand All @@ -35,7 +36,7 @@ import { ToolLoopAgent, generateText, stepCountIs, tool } from 'ai';
import type { StepResult, ToolSet } from 'ai';
import { Actions, Card, CardText, LinkButton, Section } from 'chat';
import { ThreadImpl } from 'chat';
import type { Author, Thread } from 'chat';
import type { Author, Message, Thread } from 'chat';

export type BotAgentContinuation = {
finalText: string;
Expand All @@ -47,6 +48,8 @@ export type BotAgentContinuation = {
type RunBotAgentParams = {
thread: Thread;
message: BotAgentMessageLike;
/** Full chat Message for PR signature (has `raw` for platform-specific fields). */
rawMessage?: Message;
platformIntegration: PlatformIntegration;
user: User;
botRequestId: string | undefined;
Expand Down Expand Up @@ -204,6 +207,24 @@ export async function runBotAgent(params: RunBotAgentParams): Promise<BotAgentCo
(params.platformIntegration.metadata as { model_slug?: string }).model_slug ??
DEFAULT_BOT_MODEL;
const owner = ownerFromIntegration(params.platformIntegration);
const chatPlatform = params.thread.id.split(':')[0];

// Build PR signature from requester info (display name + message permalink)
let prSignature: string | undefined;
if (params.rawMessage) {
try {
const requesterInfo = await getRequesterInfo(
params.thread,
params.rawMessage,
params.platformIntegration
);
if (requesterInfo) {
prSignature = buildPrSignature(requesterInfo);
}
} catch (error) {
console.warn('[KiloBot] Failed to build PR signature, continuing without it:', error);
}
}

const startedAt = Date.now();
const collectedSteps: BotRequestStep[] = [];
Expand Down Expand Up @@ -250,7 +271,8 @@ This tool returns an acknowledgement immediately. The final Cloud Agent result w
provider,
modelSlug,
});
}
},
{ prSignature, chatPlatform }
);

// Persist the session link synchronously so callbacks can
Expand Down
104 changes: 104 additions & 0 deletions src/lib/bot/pr-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { getAccessTokenFromInstallation } from '@/lib/integrations/slack-service';
import { getSlackMessagePermalink } from '@/lib/slack-bot/slack-utils';
import { WebClient } from '@slack/web-api';
import type { SlackEvent } from '@chat-adapter/slack';
import type { PlatformIntegration } from '@kilocode/db';
import type { Thread, Message } from 'chat';

type RequesterInfo = {
displayName: string;
messageLink?: string;
platform: string;
};

const PLATFORM_LINKS: Record<string, { label: string; url: string }> = {
slack: { label: 'Kilo for Slack', url: 'https://kilo.ai/features/slack-integration' },
discord: { label: 'Kilo for Discord', url: 'https://kilo.ai' },
};

const DEFAULT_PLATFORM_LINK = { label: 'Kilo', url: 'https://kilo.ai' };

/**
* Build the PR signature instruction to append to the Cloud Agent prompt.
* Instructs the agent to include a "Built for …" line at the end of any
* PR/MR description it creates.
*/
export function buildPrSignature(requesterInfo: RequesterInfo): string {
const requesterPart = requesterInfo.messageLink
? `[${requesterInfo.displayName}](${requesterInfo.messageLink})`
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Escape requester names before interpolating them into markdown

displayName comes from Slack/Discord user profiles and is user-controlled. Names containing markdown metacharacters like ], (, or ) will break the generated Built for [...]() link, and can also inject unintended markdown/instructions into the prompt sent to Cloud Agent. Escape the name before building the link, or render the linked case without reusing raw markdown syntax.

: requesterInfo.displayName;

const { label, url } = PLATFORM_LINKS[requesterInfo.platform] ?? DEFAULT_PLATFORM_LINK;

return `

---
**PR Signature to include in the PR description:**
When you create a pull request or merge request, include the following signature at the end of the PR/MR description:

Built for ${requesterPart} by [${label}](${url})`;
}

/**
* Gather requester info (display name + message link) for the PR signature.
* Platform-specific: uses the Slack API for permalinks, constructs Discord
* links from IDs, and degrades gracefully for unknown platforms.
*/
export async function getRequesterInfo(
thread: Thread,
message: Message,
platformIntegration: PlatformIntegration
): Promise<RequesterInfo | undefined> {
const platform = thread.id.split(':')[0];
const displayName = message.author.fullName || message.author.userName || message.author.userId;

switch (platform) {
case 'slack':
return getSlackRequesterInfo(message, platformIntegration, displayName);
case 'discord':
return getDiscordRequesterInfo(message, displayName);
default:
return { displayName, platform };
}
}

async function getSlackRequesterInfo(
message: Message,
platformIntegration: PlatformIntegration,
displayName: string
): Promise<RequesterInfo> {
const accessToken = getAccessTokenFromInstallation(platformIntegration);
if (!accessToken) {
return { displayName, platform: 'slack' };
}

const raw = (message as Message<SlackEvent>).raw;
const channelId =
typeof raw === 'object' && raw !== null && 'channel' in raw
? (raw as { channel?: string }).channel
: undefined;
const messageTs = message.id; // chat SDK uses Slack ts as the message ID

if (!channelId || !messageTs) {
return { displayName, platform: 'slack' };
}

const slackClient = new WebClient(accessToken);
const permalink = await getSlackMessagePermalink(slackClient, channelId, messageTs);

return { displayName, messageLink: permalink, platform: 'slack' };
}

function getDiscordRequesterInfo(message: Message, displayName: string): RequesterInfo {
const raw = message.raw as { guild_id?: string; channel_id?: string } | null;
const guildId = raw?.guild_id;
const channelId = raw?.channel_id;
const messageId = message.id;

const messageLink =
guildId && channelId && messageId
? `https://discord.com/channels/${guildId}/${channelId}/${messageId}`
: undefined;

return { displayName, messageLink, platform: 'discord' };
}
1 change: 1 addition & 0 deletions src/lib/bot/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function processMessage({
const result = await runBotAgent({
thread,
message,
rawMessage: message,
platformIntegration,
user,
botRequestId,
Expand Down
19 changes: 13 additions & 6 deletions src/lib/bot/tools/spawn-cloud-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,17 @@ export default async function spawnCloudAgentSession(
authToken: string,
ticketUserId: string,
botRequestId: string | undefined,
onSessionReady?: RunSessionInput['onSessionReady']
onSessionReady?: RunSessionInput['onSessionReady'],
options?: { prSignature?: string; chatPlatform?: string }
): Promise<SpawnCloudAgentResult> {
console.log('[SlackBot] spawnCloudAgentSession called with args:', JSON.stringify(args, null, 2));
console.log('[KiloBot] spawnCloudAgentSession called with args:', JSON.stringify(args, null, 2));

// Build platform-specific prepareInput and initiateInput
const kilocodeOrganizationId = platformIntegration.owned_by_organization_id || undefined;
let prepareInput: PrepareSessionInput;
let initiateInput: { githubToken?: string; kilocodeOrganizationId?: string };
const mode: AgentMode = args.mode;
const chatPlatform = options?.chatPlatform ?? 'slack';
const callbackTarget =
botRequestId && INTERNAL_API_SECRET
? {
Expand All @@ -104,14 +106,19 @@ export default async function spawnCloudAgentSession(
}

const isGitLab = !!args.gitlabProject;
const prompt =
let prompt =
mode === 'code'
? args.prompt +
(isGitLab
? '\n\nOpen a merge request with your changes and return the MR URL.'
: '\n\nOpen a pull request with your changes and return the PR URL.')
: args.prompt;

// Append PR/MR signature to the prompt if available
if (options?.prSignature) {
prompt += options.prSignature;
}

if (args.gitlabProject) {
// GitLab path: get token + instance URL, build clone URL, use gitUrl/gitToken
const gitlabToken =
Expand All @@ -135,7 +142,7 @@ export default async function spawnCloudAgentSession(

const isSelfHosted = !/^https?:\/\/(www\.)?gitlab\.com(\/|$)/i.test(instanceUrl);
console.log(
'[SlackBot] GitLab session - project:',
'[KiloBot] GitLab session - project:',
args.gitlabProject,
'instance:',
isSelfHosted ? 'self-hosted' : 'gitlab.com'
Expand All @@ -149,7 +156,7 @@ export default async function spawnCloudAgentSession(
gitToken: gitlabToken,
platform: 'gitlab',
kilocodeOrganizationId,
createdOnPlatform: 'slack',
createdOnPlatform: chatPlatform,
callbackTarget,
};
initiateInput = { kilocodeOrganizationId };
Expand All @@ -174,7 +181,7 @@ export default async function spawnCloudAgentSession(
model,
githubToken,
kilocodeOrganizationId,
createdOnPlatform: 'slack',
createdOnPlatform: chatPlatform,
callbackTarget,
};
initiateInput = { githubToken, kilocodeOrganizationId };
Expand Down
Loading