Skip to content
Open
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
9 changes: 9 additions & 0 deletions convex/agents/taskExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,18 @@ You will be provided with a JSON context including \`workspaceId\`, \`userId\`,

## Conversational Rules
- **Thread Context:** Read the FULL history including \`[Norbot]:\` messages. If the user says "create a task for this", create it based on the thread above.
- **Thread-Reference Task Creation (IMPORTANT):** When a user says things like "add this task", "create a task for this", "füge diesen task hinzu", "erstelle einen task dafür", or similar phrases that reference "this" — the task content comes from the **thread context** (the messages above), NOT from the user's current message. The user's message may contain corrections or additional details (like correct dates, priorities, etc.) that should be incorporated. **Always create the task immediately** from the thread content.
- **URL Check:** If it's a BUG and a URL is missing, create the task anyway if the report is reasonable, then ask for the URL or steps to reproduce (ONE question). Only ask before creating if the report is too vague.
- **Attachments:** Pass any provided attachment metadata to \`createTask\`.

## Example: Thread-Reference Task Creation
Thread context:
<@U456>: The date display on the PDF is wrong, both on the title page and in the header. It should be 22.03. to 04.04.

User message: @norbot add this as a task. Correct would be 22 Mar - 4 Apr 2026

→ Create the task immediately! Title: "Fix date display on PDF title page and header". Description from thread context + user's correction. Do NOT ask "what task?" — the thread tells you.

## Example: Answering Questions
Thread context:
<@U123>: There's a bug
Expand Down
144 changes: 98 additions & 46 deletions convex/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,31 @@ export const handleAppMention = internalAction({
return;
}

// Handle stop command — stop following this thread
if (/^(stop|stopp|halt)$/i.test(cleanText)) {
const existingConvo = await ctx.runQuery(internal.slack.getAgentConversation, {
workspaceId: workspace._id,
slackChannelId: args.channelId,
slackThreadTs: args.threadTs,
});
if (existingConvo) {
await ctx.runMutation(internal.slack.upsertAgentConversation, {
workspaceId: workspace._id,
slackChannelId: args.channelId,
slackThreadTs: args.threadTs,
agentThreadId: existingConvo.agentThreadId,
status: "stopped",
});
}
await sendSlackMessage({
token: workspace.slackBotToken ?? "",
channelId: args.channelId,
threadTs: args.threadTs,
text: "Ok, I'll stop following this thread. Mention me again to resume.",
});
return;
}

// Download any attached files from Slack
let attachments: Array<{
storageId: string;
Expand Down Expand Up @@ -585,14 +610,29 @@ export const handleAppMention = internalAction({
};

// Build context for the agent with all required parameters for tools
const contextInfo = `Context (use these values when calling tools):
const contextInfo = `## Tool parameters
- source: ${JSON.stringify(sourceContext)}
- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${attachmentsInfo}${projectsInfo}${threadContext}

User message: ${cleanText}
- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${attachmentsInfo}${projectsInfo}
${threadContext}
## User message
${cleanText}

Original text for task creation: ${originalText}`;

// Save conversation BEFORE agent call so follow-ups work even if the agent fails
await ctx.runMutation(internal.slack.upsertAgentConversation, {
workspaceId: workspace._id,
slackChannelId: args.channelId,
slackThreadTs: args.threadTs,
agentThreadId: threadId,
status: "active",
originalText,
originalMessageTs,
originalAttachments: storedAttachments,
lastUserText: cleanText,
lastUserMessageTs: args.ts,
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await norbotAgent.generateText(ctx, { threadId }, {
messages: [{ role: "user" as const, content: contextInfo }],
Expand All @@ -605,31 +645,14 @@ Original text for task creation: ${originalText}`;
});

// Send the agent's response directly
// The agent handles everything: greetings, summaries, status updates, assignments, task creation
const responseText =
result.text ||
"I didn't quite understand that. Could you provide more details?\n• What were you trying to do?\n• What happened instead?\n• Any error messages?";
const responseText = result.text || getFallbackMessage();

await sendSlackMessage({
token: workspace.slackBotToken ?? "",
channelId: args.channelId,
threadTs: args.threadTs,
text: responseText,
});

// Save conversation for thread continuity (so we can respond to follow-ups)
await ctx.runMutation(internal.slack.upsertAgentConversation, {
workspaceId: workspace._id,
slackChannelId: args.channelId,
slackThreadTs: args.threadTs,
agentThreadId: threadId,
status: "active",
originalText,
originalMessageTs,
originalAttachments: storedAttachments,
lastUserText: cleanText,
lastUserMessageTs: args.ts,
});
} catch (error) {
console.error("Agent error:", error);
await sendSlackMessage({
Expand All @@ -654,6 +677,18 @@ export const handleThreadReply = internalAction({
threadTs: v.string(),
},
handler: async (ctx, args) => {
// Deduplication: Check if we already processed this event
const alreadyProcessed = await ctx.runQuery(internal.slack.isEventProcessed, {
eventTs: args.ts,
});
if (alreadyProcessed) return;

const marked = await ctx.runMutation(internal.slack.markEventProcessed, {
eventTs: args.ts,
eventType: "thread_reply",
});
if (!marked) return;

// Get workspace
const workspace = await ctx.runQuery(internal.slack.getWorkspaceBySlackTeam, {
slackTeamId: args.teamId,
Expand All @@ -668,8 +703,8 @@ export const handleThreadReply = internalAction({
slackThreadTs: args.threadTs,
});

if (conversation && conversation.status === "active") {
// Continue the agent conversation
if (conversation && conversation.status !== "stopped") {
// Continue the agent conversation (reuse thread if active, create new if not)
try {
// Check AI usage limits
const usageCheck = await ctx.runQuery(internal.ai.checkUsageInternal, {
Expand Down Expand Up @@ -773,19 +808,24 @@ export const handleThreadReply = internalAction({
}

// Build context for follow-up with full context
const contextInfo = `Context (use these values when calling tools):
const contextInfo = `## Tool parameters
- source: ${JSON.stringify(sourceContext)}
- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${attachmentsInfo}${projectsInfo}${threadContext}

User follow-up message: ${args.text}
- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${attachmentsInfo}${projectsInfo}
${threadContext}
## User follow-up message
${args.text}

Original text for task creation: ${originalText}`;

// Continue on the existing agent thread
// Reuse agent thread if active, otherwise create new one
const threadId = conversation.status === "active"
? conversation.agentThreadId
: (await norbotAgent.createThread(ctx, {})).threadId;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await norbotAgent.generateText(
ctx,
{ threadId: conversation.agentThreadId },
{ threadId },
{
messages: [{ role: "user" as const, content: contextInfo }],
maxSteps: 5,
Expand All @@ -798,7 +838,7 @@ Original text for task creation: ${originalText}`;
});

const responseText =
result.text || "I'm not sure how to help with that. Could you clarify?";
result.text || getFallbackMessage();

await sendSlackMessage({
token: workspace.slackBotToken ?? "",
Expand All @@ -812,7 +852,7 @@ Original text for task creation: ${originalText}`;
workspaceId: workspace._id,
slackChannelId: args.channelId,
slackThreadTs: args.threadTs,
agentThreadId: conversation.agentThreadId,
agentThreadId: threadId,
status: "active",
originalText,
originalMessageTs,
Expand Down Expand Up @@ -953,6 +993,11 @@ async function sendSlackMessage(params: {
return data;
}

// Fallback when agent returns empty response
function getFallbackMessage(): string {
return "I didn't quite understand that. Could you describe what you'd like me to do in a bit more detail?";
}

// Fetch all replies in a thread from Slack
interface SlackThreadMessage {
ts: string;
Expand Down Expand Up @@ -996,9 +1041,7 @@ function formatThreadForContext(
currentTs: string
): string {
// Filter out the current message, keep bot messages for conversation context
const relevantMessages = messages
.filter((m) => m.ts !== currentTs)
.slice(-15);
const relevantMessages = messages.filter((m) => m.ts !== currentTs);

if (relevantMessages.length === 0) {
return "";
Expand All @@ -1011,11 +1054,7 @@ function formatThreadForContext(
: `<@${m.user}>: ${m.text}`)
.join("\n");

// Cap at ~2000 chars to avoid token bloat
const truncated =
formatted.length > 2000 ? formatted.slice(-2000) + "\n[...truncated]" : formatted;

return `\n\nThread context (previous messages in this thread):\n${truncated}`;
return `\n## Thread context (previous messages in this thread — use this to understand what the user is referring to)\n${formatted}`;
}

// ===========================================
Expand Down Expand Up @@ -1457,7 +1496,7 @@ export const upsertAgentConversation = internalMutation({
slackChannelId: v.string(),
slackThreadTs: v.string(),
agentThreadId: v.string(),
status: v.union(v.literal("active"), v.literal("completed")),
status: v.union(v.literal("active"), v.literal("completed"), v.literal("stopped")),
originalText: v.optional(v.string()),
originalMessageTs: v.optional(v.string()),
originalAttachments: v.optional(
Expand Down Expand Up @@ -1689,6 +1728,18 @@ export const handleAssistantMessage = internalAction({
threadTs: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Deduplication: Check if we already processed this event
const alreadyProcessed = await ctx.runQuery(internal.slack.isEventProcessed, {
eventTs: args.ts,
});
if (alreadyProcessed) return;

const marked = await ctx.runMutation(internal.slack.markEventProcessed, {
eventTs: args.ts,
eventType: "assistant_message",
});
if (!marked) return;

const workspace = await ctx.runQuery(internal.slack.getWorkspaceBySlackTeam, {
slackTeamId: args.teamId,
});
Expand Down Expand Up @@ -1814,11 +1865,12 @@ export const handleAssistantMessage = internalAction({
channelProjectInfo = "\n- channelDefaultProject: none - Use keyword matching or ask which project";
}

const contextInfo = `Context (use these values when calling tools):
const contextInfo = `## Tool parameters
- source: ${JSON.stringify(sourceContext)}
- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${projectsInfo}${threadContext}

User message: ${args.text}
- channelName: ${channelMapping?.slackChannelName || "unknown"}${channelProjectInfo}${repoInfo}${projectsInfo}
${threadContext}
## User message
${args.text}

Original text for task creation: ${originalText}`;

Expand All @@ -1833,7 +1885,7 @@ Original text for task creation: ${originalText}`;
});

const responseText =
result.text || "I'm not sure how to help with that. Could you clarify?";
result.text || getFallbackMessage();

await sendSlackMessage({
token: workspace.slackBotToken ?? "",
Expand Down