@lota-sdk/core 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +6 -3
  2. package/src/ai/definitions.ts +1 -1
  3. package/src/ai/embedding-cache.ts +2 -4
  4. package/src/bifrost/cache-headers.ts +8 -0
  5. package/src/bifrost/index.ts +1 -0
  6. package/src/create-runtime.ts +26 -1
  7. package/src/db/memory-store.helpers.ts +1 -3
  8. package/src/db/schema-fingerprint.ts +1 -3
  9. package/src/queues/document-processor.queue.ts +2 -4
  10. package/src/queues/post-chat-memory.queue.ts +8 -2
  11. package/src/queues/recent-activity-title-refinement.queue.ts +1 -1
  12. package/src/queues/skill-extraction.queue.ts +1 -1
  13. package/src/queues/workstream-title-generation.queue.ts +1 -1
  14. package/src/redis/redis-lease-lock.ts +1 -2
  15. package/src/runtime/agent-runtime-policy.ts +3 -14
  16. package/src/runtime/context-compaction.ts +2 -4
  17. package/src/runtime/index.ts +1 -1
  18. package/src/runtime/runtime-config.ts +86 -2
  19. package/src/runtime/runtime-extensions.ts +0 -1
  20. package/src/runtime/social-chat.ts +752 -0
  21. package/src/runtime/team-consultation-orchestrator.ts +0 -4
  22. package/src/services/agent-executor.service.ts +0 -1
  23. package/src/services/document-chunk.service.ts +1 -3
  24. package/src/services/index.ts +1 -0
  25. package/src/services/memory.service.ts +7 -2
  26. package/src/services/recent-activity.service.ts +1 -3
  27. package/src/services/social-chat-history.service.ts +197 -0
  28. package/src/services/workstream-message.service.ts +1 -3
  29. package/src/services/workstream-turn-preparation.service.ts +0 -23
  30. package/src/system-agents/context-compaction.agent.ts +2 -0
  31. package/src/system-agents/delegated-agent-factory.ts +3 -0
  32. package/src/system-agents/memory-reranker.agent.ts +4 -2
  33. package/src/system-agents/memory.agent.ts +2 -0
  34. package/src/system-agents/recent-activity-title-refiner.agent.ts +2 -0
  35. package/src/system-agents/regular-chat-memory-digest.agent.ts +2 -0
  36. package/src/system-agents/skill-extractor.agent.ts +2 -0
  37. package/src/system-agents/skill-manager.agent.ts +2 -0
  38. package/src/system-agents/title-generator.agent.ts +2 -0
  39. package/src/tools/research-topic.tool.ts +2 -0
  40. package/src/tools/team-think.tool.ts +0 -3
  41. package/src/workers/regular-chat-memory-digest.helpers.ts +1 -1
  42. package/src/workers/regular-chat-memory-digest.runner.ts +43 -10
  43. package/src/workers/skill-extraction.runner.ts +25 -5
  44. package/src/workers/utils/repo-structure-extractor.ts +2 -2
  45. package/src/workers/utils/workstream-message-query.ts +3 -5
  46. package/src/runtime/workstream-routing-policy.ts +0 -267
@@ -0,0 +1,752 @@
1
+ import { createSlackAdapter } from '@chat-adapter/slack'
2
+ import { createIoRedisState } from '@chat-adapter/state-ioredis'
3
+ import {
4
+ buildSlackSocialReplyMarkdown,
5
+ CONSULT_SPECIALIST_TOOL_NAME,
6
+ ConsultSpecialistArgsSchema,
7
+ stripSlackToolExecutionNoticeMarkdown,
8
+ } from '@lota-sdk/shared'
9
+ import type { ChatMessage, ConsultSpecialistArgs } from '@lota-sdk/shared'
10
+ import { stepCountIs, tool as createTool } from 'ai'
11
+ import type { ToolLoopAgent, ToolSet } from 'ai'
12
+ import { Chat, ConsoleLogger } from 'chat'
13
+ import type { Message, Thread, WebhookOptions } from 'chat'
14
+ import type IORedis from 'ioredis'
15
+
16
+ import {
17
+ agentDisplayNames,
18
+ createAgent,
19
+ getAgentRuntimeConfig,
20
+ teamConsultParticipants,
21
+ } from '../config/agent-defaults'
22
+ import { aiLogger } from '../config/logger'
23
+ import { recordIdToString } from '../db/record-id'
24
+ import { TABLES } from '../db/tables'
25
+ import { enqueuePostChatMemory } from '../queues/post-chat-memory.queue'
26
+ import { enqueueRegularChatMemoryDigest } from '../queues/regular-chat-memory-digest.queue'
27
+ import { enqueueSkillExtraction } from '../queues/skill-extraction.queue'
28
+ import type {
29
+ BuildSocialChatAgentToolsParams,
30
+ LotaRuntimeSocialChatConfig,
31
+ LotaSocialChatResolvedContext,
32
+ } from '../runtime/runtime-config'
33
+ import { getRuntimeAdapters } from '../runtime/runtime-extensions'
34
+ import { learnedSkillService } from '../services/learned-skill.service'
35
+ import { memoryService } from '../services/memory.service'
36
+ import { socialChatHistoryService } from '../services/social-chat-history.service'
37
+ import { safeEnqueue } from '../utils/async'
38
+ import { buildAgentPromptContext } from './agent-prompt-context'
39
+ import { createServerRunAbortController } from './agent-stream-helpers'
40
+ import { buildAgentHistoryMessages, extractMessageText, toHistoryMessages } from './workstream-chat-helpers'
41
+
42
+ const DEFAULT_SOCIAL_CHAT_AGENT_ID = 'socialChat'
43
+ const DEFAULT_SOCIAL_CHAT_AGENT_DISPLAY_NAME = 'Lota'
44
+ const DEFAULT_SOCIAL_CHAT_STATE_PREFIX = 'lota:social:chat-sdk'
45
+ const DEFAULT_SOCIAL_CHAT_DEDUPE_TTL_MS = 15 * 60 * 1000
46
+ const PRESEEDED_MEMORY_LOOKUP_LIMIT = 3
47
+
48
+ type SocialChatAgent = ToolLoopAgent<never, ToolSet>
49
+
50
+ export interface LotaRuntimeSocialChat {
51
+ enabled: boolean
52
+ initialize(): Promise<void>
53
+ shutdown(): Promise<void>
54
+ webhooks: { slack(request: Request, options?: WebhookOptions): Promise<Response> }
55
+ }
56
+
57
+ interface SlackSocialMessageContext {
58
+ channelId: string
59
+ threadId: string
60
+ messageId: string
61
+ text: string
62
+ authorId?: string
63
+ authorName?: string
64
+ }
65
+
66
+ function createDisabledSocialChatRuntime(): LotaRuntimeSocialChat {
67
+ return {
68
+ enabled: false,
69
+ async initialize() {},
70
+ async shutdown() {},
71
+ webhooks: {
72
+ async slack() {
73
+ return new Response('Social chat is disabled.', { status: 404 })
74
+ },
75
+ },
76
+ }
77
+ }
78
+
79
+ function toOptionalTrimmedString(value: unknown): string | undefined {
80
+ if (typeof value !== 'string') return undefined
81
+ const normalized = value.trim()
82
+ return normalized.length > 0 ? normalized : undefined
83
+ }
84
+
85
+ function readSlackAuthorName(message: Message): string | undefined {
86
+ return (
87
+ toOptionalTrimmedString(message.author.fullName) ??
88
+ toOptionalTrimmedString(message.author.userName) ??
89
+ toOptionalTrimmedString(message.author.userId)
90
+ )
91
+ }
92
+
93
+ function buildSocialChatIdentitySection(agentDisplayName: string): string {
94
+ return [
95
+ '<social-chat-agent>',
96
+ `You are ${agentDisplayName}, the social chat agent for this workspace.`,
97
+ '- You respond inside Slack threads.',
98
+ '- Use the available tools, memories, learned skills, and workspace knowledge before guessing.',
99
+ '- If the answer may depend on prior workspace decisions, preferences, promises, history, or relationship context, check memory with the available memory tools before answering.',
100
+ '- If the user shares a URL or asks about a website/page, read it with fetchWebpage before answering. Do not summarize website contents from prior knowledge.',
101
+ '- When another roster agent is better suited for a question, consult that specialist and then synthesize the final answer yourself.',
102
+ '- Keep the final reply crisp and readable for Slack unless the user explicitly asks for depth.',
103
+ '</social-chat-agent>',
104
+ ].join('\n')
105
+ }
106
+
107
+ function buildThreadTranscript(
108
+ messages: Array<{
109
+ role: 'user' | 'assistant'
110
+ parts: Array<Record<string, unknown>>
111
+ metadata?: Record<string, unknown>
112
+ }>,
113
+ ): string {
114
+ const historyMessages = toHistoryMessages(messages)
115
+ if (historyMessages.length === 0) return 'No prior thread history.'
116
+
117
+ return historyMessages
118
+ .map((message) =>
119
+ message.role === 'user' ? `User: ${message.content}` : `${message.agentName ?? 'Assistant'}: ${message.content}`,
120
+ )
121
+ .join('\n\n')
122
+ }
123
+
124
+ function buildLeadAgentPrompt(params: {
125
+ agentDisplayName: string
126
+ channelId: string
127
+ threadId: string
128
+ transcript: string
129
+ latestUserMessage: string
130
+ latestAuthorName?: string
131
+ }): string {
132
+ return [
133
+ `Platform: Slack`,
134
+ `Channel ID: ${params.channelId}`,
135
+ `Thread ID: ${params.threadId}`,
136
+ `Current Slack author: ${params.latestAuthorName ?? 'Unknown'}`,
137
+ '',
138
+ `You are replying as ${params.agentDisplayName}.`,
139
+ '',
140
+ '<thread-transcript>',
141
+ params.transcript,
142
+ '</thread-transcript>',
143
+ '',
144
+ 'Latest user message:',
145
+ params.latestUserMessage,
146
+ ].join('\n')
147
+ }
148
+
149
+ function buildSpecialistPrompt(params: {
150
+ requesterName: string
151
+ agentName: string
152
+ task: string
153
+ transcript: string
154
+ }): string {
155
+ return [
156
+ `${params.requesterName} needs specialist help from ${params.agentName}.`,
157
+ '',
158
+ '<thread-transcript>',
159
+ params.transcript,
160
+ '</thread-transcript>',
161
+ '',
162
+ 'Specialist task:',
163
+ params.task,
164
+ '',
165
+ 'Answer only with the specialist guidance that should be used in the final Slack reply.',
166
+ ].join('\n')
167
+ }
168
+
169
+ function createAssistantMessage(params: { agentId: string; agentName: string; text: string }): ChatMessage {
170
+ return {
171
+ id: Bun.randomUUIDv7(),
172
+ role: 'assistant',
173
+ parts: [{ type: 'text', text: params.text }],
174
+ metadata: { agentId: params.agentId, agentName: params.agentName, createdAt: Date.now() },
175
+ }
176
+ }
177
+
178
+ function createCursorId(message: { workspaceId: string; threadId: string; messageId: string }): string {
179
+ return `social:slack:${message.workspaceId}:${message.threadId}:${message.messageId}`
180
+ }
181
+
182
+ function toSafeJobIdSegment(value: string): string {
183
+ return value.replace(/[^a-zA-Z0-9_-]/g, '_')
184
+ }
185
+
186
+ function createSocialMemoryDedupeKey(params: { workspaceId: string; threadId: string; messageId: string }): string {
187
+ return [
188
+ 'social-memory',
189
+ toSafeJobIdSegment(params.workspaceId),
190
+ toSafeJobIdSegment(params.threadId),
191
+ toSafeJobIdSegment(params.messageId),
192
+ ].join('-')
193
+ }
194
+
195
+ interface ExecutableToolDefinition {
196
+ execute: (...args: unknown[]) => Promise<unknown>
197
+ }
198
+
199
+ function withLoggedToolSet(
200
+ tools: ToolSet,
201
+ params: { agentId: string; channelId: string; threadId: string; executedToolNames: string[] },
202
+ ): ToolSet {
203
+ return Object.fromEntries(
204
+ Object.entries(tools).map(([toolName, toolDefinition]) => {
205
+ if (
206
+ typeof toolDefinition !== 'object' ||
207
+ !('execute' in toolDefinition) ||
208
+ typeof toolDefinition.execute !== 'function'
209
+ ) {
210
+ return [toolName, toolDefinition]
211
+ }
212
+
213
+ const executableTool = toolDefinition as typeof toolDefinition & ExecutableToolDefinition
214
+
215
+ return [
216
+ toolName,
217
+ {
218
+ ...toolDefinition,
219
+ execute: async (...args: unknown[]): Promise<unknown> => {
220
+ aiLogger.info`Slack social-chat tool start: agentId=${params.agentId}, tool=${toolName}, channelId=${params.channelId}, threadId=${params.threadId}`
221
+ try {
222
+ const result: unknown = await executableTool.execute(...args)
223
+ params.executedToolNames.push(toolName)
224
+ aiLogger.info`Slack social-chat tool finish: agentId=${params.agentId}, tool=${toolName}, channelId=${params.channelId}, threadId=${params.threadId}`
225
+ return result
226
+ } catch (error) {
227
+ aiLogger.warn`Slack social-chat tool failed: agentId=${params.agentId}, tool=${toolName}, channelId=${params.channelId}, threadId=${params.threadId}, error=${error}`
228
+ throw error
229
+ }
230
+ },
231
+ },
232
+ ]
233
+ }),
234
+ )
235
+ }
236
+
237
+ function normalizeSocialHistoryMessage(params: {
238
+ workspaceId: string
239
+ channelId: string
240
+ agentId: string
241
+ agentDisplayName: string
242
+ message: Message
243
+ textOverride?: string
244
+ }): {
245
+ source: 'social'
246
+ sourceId: string
247
+ platform: 'slack'
248
+ workspaceId: string
249
+ channelId: string
250
+ threadId: string
251
+ messageId: string
252
+ role: 'user' | 'assistant'
253
+ parts: Array<Record<string, unknown>>
254
+ metadata?: Record<string, unknown>
255
+ cursor: { createdAt: Date; id: string }
256
+ } | null {
257
+ const role: 'user' | 'assistant' = params.message.author.isMe ? 'assistant' : 'user'
258
+ const text = toOptionalTrimmedString(
259
+ role === 'assistant'
260
+ ? stripSlackToolExecutionNoticeMarkdown(params.textOverride ?? params.message.text)
261
+ : (params.textOverride ?? params.message.text),
262
+ )
263
+ const fileParts = params.message.attachments.map((attachment) => ({
264
+ type: 'file',
265
+ filename: attachment.name ?? 'attachment',
266
+ mediaType: attachment.mimeType ?? 'application/octet-stream',
267
+ sizeBytes: typeof attachment.size === 'number' ? attachment.size : null,
268
+ storageKey: attachment.url ?? 'external',
269
+ }))
270
+ const parts = [...(text ? [{ type: 'text', text }] : []), ...fileParts]
271
+ if (parts.length === 0) return null
272
+
273
+ const createdAt = params.message.metadata.dateSent
274
+ const metadata: Record<string, unknown> = {
275
+ platform: 'slack',
276
+ channelId: params.channelId,
277
+ threadId: params.message.threadId,
278
+ messageId: params.message.id,
279
+ authorId: params.message.author.userId,
280
+ authorName: readSlackAuthorName(params.message),
281
+ }
282
+ if (role === 'assistant') {
283
+ metadata.agentId = params.agentId
284
+ metadata.agentName = params.agentDisplayName
285
+ }
286
+
287
+ return {
288
+ source: 'social',
289
+ sourceId: `slack:${params.channelId}:${params.message.threadId}`,
290
+ platform: 'slack',
291
+ workspaceId: params.workspaceId,
292
+ channelId: params.channelId,
293
+ threadId: params.message.threadId,
294
+ messageId: params.message.id,
295
+ role,
296
+ parts,
297
+ metadata,
298
+ cursor: {
299
+ createdAt,
300
+ id: createCursorId({
301
+ workspaceId: params.workspaceId,
302
+ threadId: params.message.threadId,
303
+ messageId: params.message.id,
304
+ }),
305
+ },
306
+ }
307
+ }
308
+
309
+ async function collectThreadMessages(thread: Thread, incomingMessage: Message): Promise<Message[]> {
310
+ try {
311
+ const messages: Message[] = []
312
+ for await (const message of thread.allMessages) {
313
+ messages.push(message)
314
+ }
315
+ return messages.length > 0 ? messages : [incomingMessage]
316
+ } catch {
317
+ return thread.recentMessages.length > 0 ? [...thread.recentMessages] : [incomingMessage]
318
+ }
319
+ }
320
+
321
+ function buildBuildToolsParams(params: {
322
+ agentId: string
323
+ context: LotaSocialChatResolvedContext
324
+ workspaceIdString: string
325
+ userIdString: string
326
+ messageContext: SlackSocialMessageContext
327
+ memoryBlock: string
328
+ onAppendMemoryBlock: (value: string) => void
329
+ }): BuildSocialChatAgentToolsParams {
330
+ return {
331
+ agentId: params.agentId,
332
+ workspaceId: params.context.workspaceId,
333
+ workspaceIdString: params.workspaceIdString,
334
+ userId: params.context.userId,
335
+ userIdString: params.userIdString,
336
+ userName: params.context.userName,
337
+ platform: 'slack',
338
+ channelId: params.messageContext.channelId,
339
+ threadId: params.messageContext.threadId,
340
+ incomingMessageId: params.messageContext.messageId,
341
+ incomingText: params.messageContext.text,
342
+ memoryBlock: params.memoryBlock,
343
+ onAppendMemoryBlock: params.onAppendMemoryBlock,
344
+ context: null,
345
+ }
346
+ }
347
+
348
+ export function createSocialChatRuntime(params: {
349
+ redisClient: IORedis
350
+ socialChat?: LotaRuntimeSocialChatConfig
351
+ }): LotaRuntimeSocialChat {
352
+ const socialChatConfig = params.socialChat
353
+ const slackConfig = socialChatConfig?.slack
354
+ const slackEnabled = Boolean(slackConfig?.botToken?.trim() && slackConfig.signingSecret?.trim())
355
+
356
+ if (!socialChatConfig || !slackEnabled) {
357
+ return createDisabledSocialChatRuntime()
358
+ }
359
+
360
+ const socialAgentId = toOptionalTrimmedString(socialChatConfig.agentId) ?? DEFAULT_SOCIAL_CHAT_AGENT_ID
361
+ const socialAgentDisplayName =
362
+ toOptionalTrimmedString(socialChatConfig.agentDisplayName) ?? DEFAULT_SOCIAL_CHAT_AGENT_DISPLAY_NAME
363
+ const stateKeyPrefix =
364
+ toOptionalTrimmedString(socialChatConfig.stateRedisKeyPrefix) ?? DEFAULT_SOCIAL_CHAT_STATE_PREFIX
365
+ const chat = new Chat({
366
+ userName: toOptionalTrimmedString(slackConfig?.userName) ?? socialAgentDisplayName,
367
+ adapters: {
368
+ slack: createSlackAdapter({
369
+ botToken: slackConfig?.botToken,
370
+ signingSecret: slackConfig?.signingSecret,
371
+ userName: toOptionalTrimmedString(slackConfig?.userName) ?? socialAgentDisplayName,
372
+ }),
373
+ },
374
+ state: createIoRedisState({
375
+ client: params.redisClient,
376
+ keyPrefix: stateKeyPrefix,
377
+ logger: new ConsoleLogger('warn', 'lota-social-chat'),
378
+ }),
379
+ dedupeTtlMs: slackConfig?.dedupeTtlMs ?? DEFAULT_SOCIAL_CHAT_DEDUPE_TTL_MS,
380
+ })
381
+
382
+ const initialize = async () => {
383
+ await chat.initialize()
384
+ }
385
+
386
+ const shutdown = async () => {
387
+ await chat.shutdown()
388
+ }
389
+
390
+ const handleMessage = async (thread: Thread, incomingMessage: Message) => {
391
+ const rawSlackMessage = incomingMessage.raw as { channel?: unknown } | undefined
392
+ const channelId = toOptionalTrimmedString(rawSlackMessage?.channel) ?? thread.channelId
393
+ const messageContext: SlackSocialMessageContext = {
394
+ channelId,
395
+ threadId: thread.id,
396
+ messageId: incomingMessage.id,
397
+ text: incomingMessage.text.trim(),
398
+ authorId: toOptionalTrimmedString(incomingMessage.author.userId),
399
+ authorName: readSlackAuthorName(incomingMessage),
400
+ }
401
+ aiLogger.info`Slack social-chat message received: channelId=${messageContext.channelId}, threadId=${messageContext.threadId}, messageId=${messageContext.messageId}, author=${messageContext.authorName ?? 'unknown'}, textLength=${messageContext.text.length}`
402
+
403
+ const resolvedContext = await socialChatConfig.resolveContext({
404
+ platform: 'slack',
405
+ channelId: messageContext.channelId,
406
+ threadId: messageContext.threadId,
407
+ messageId: messageContext.messageId,
408
+ text: messageContext.text,
409
+ authorId: messageContext.authorId,
410
+ authorName: messageContext.authorName,
411
+ })
412
+ const workspaceIdString = recordIdToString(resolvedContext.workspaceId, TABLES.ORGANIZATION)
413
+ const userIdString = recordIdToString(resolvedContext.userId, TABLES.USER)
414
+ aiLogger.info`Slack social-chat context resolved: workspaceId=${workspaceIdString}, userId=${userIdString}`
415
+
416
+ const threadMessages = await collectThreadMessages(thread, incomingMessage)
417
+ const normalizedMessages = threadMessages
418
+ .map((message) =>
419
+ normalizeSocialHistoryMessage({
420
+ workspaceId: workspaceIdString,
421
+ channelId: messageContext.channelId,
422
+ agentId: socialAgentId,
423
+ agentDisplayName: socialAgentDisplayName,
424
+ message,
425
+ }),
426
+ )
427
+ .filter((message): message is NonNullable<typeof message> => message !== null)
428
+ await socialChatHistoryService.upsertMessages(normalizedMessages)
429
+
430
+ const historyBeforeReply = await socialChatHistoryService.listThreadMessages({
431
+ workspaceId: workspaceIdString,
432
+ threadId: messageContext.threadId,
433
+ })
434
+ const currentUserMessage =
435
+ historyBeforeReply.find((message) => message.messageId === incomingMessage.id) ?? historyBeforeReply.at(-1)
436
+ const priorHistory = currentUserMessage
437
+ ? historyBeforeReply.filter((message) => message.cursor.id !== currentUserMessage.cursor.id)
438
+ : historyBeforeReply
439
+
440
+ const workspaceProvider = getRuntimeAdapters().services?.workspaceProvider
441
+ const workspace = workspaceProvider ? await workspaceProvider.getWorkspace(resolvedContext.workspaceId) : {}
442
+ const lifecycleState = workspaceProvider ? await workspaceProvider.getLifecycleState?.(workspace) : undefined
443
+ const workspaceProfileState = workspaceProvider
444
+ ? await workspaceProvider.readProfileProjectionState?.(workspace)
445
+ : undefined
446
+ const [recentDomainEvents, promptSummary] = await Promise.all([
447
+ workspaceProvider?.listRecentDomainEvents?.(resolvedContext.workspaceId, 5) ??
448
+ Promise.resolve([] as Array<Record<string, unknown>>),
449
+ workspaceProvider?.buildPromptSummary
450
+ ? workspaceProvider.buildPromptSummary(resolvedContext.workspaceId).catch(() => undefined)
451
+ : Promise.resolve(undefined),
452
+ ])
453
+
454
+ const promptContext = buildAgentPromptContext({
455
+ workspaceName:
456
+ workspaceProfileState?.workspaceName ?? toOptionalTrimmedString((workspace as { name?: unknown }).name),
457
+ summaryBlock: workspaceProfileState?.summaryBlock,
458
+ structuredProfile: workspaceProfileState?.structuredProfile,
459
+ promptSummary,
460
+ userName: messageContext.authorName,
461
+ recentDomainEvents,
462
+ })
463
+ const retrievedKnowledgeSection =
464
+ lifecycleState?.bootstrapActive || messageContext.text.length === 0
465
+ ? undefined
466
+ : await workspaceProvider?.buildRetrievedKnowledgeSection?.({
467
+ workspaceId: workspaceIdString,
468
+ userId: userIdString,
469
+ query: messageContext.text,
470
+ })
471
+
472
+ const [preSeededMemoriesSection, learnedSkillsSection] = await Promise.all([
473
+ memoryService.getTopMemories({
474
+ orgId: workspaceIdString,
475
+ agentName: socialAgentId,
476
+ limit: PRESEEDED_MEMORY_LOOKUP_LIMIT,
477
+ }),
478
+ lifecycleState?.bootstrapActive
479
+ ? Promise.resolve(undefined)
480
+ : learnedSkillService
481
+ .retrieveForTurn({
482
+ orgId: workspaceIdString,
483
+ agentId: socialAgentId,
484
+ query: messageContext.text,
485
+ limit: 3,
486
+ minConfidence: 0.6,
487
+ })
488
+ .catch((error) => {
489
+ aiLogger.warn`Failed to retrieve learned skills for ${socialAgentId}: ${error}`
490
+ return undefined
491
+ }),
492
+ ])
493
+
494
+ let memoryBlock = ''
495
+ const runtimeConfig = getAgentRuntimeConfig({
496
+ agentId: socialAgentId,
497
+ workstreamMode: 'group',
498
+ mode: 'workstreamMode',
499
+ onboardingActive: lifecycleState?.bootstrapActive ?? false,
500
+ linearInstalled: false,
501
+ systemWorkspaceDetails: promptContext.systemWorkspaceDetails,
502
+ preSeededMemoriesSection,
503
+ retrievedKnowledgeSection,
504
+ learnedSkillsSection,
505
+ additionalInstructionSections: [buildSocialChatIdentitySection(socialAgentDisplayName)],
506
+ }) as Record<string, unknown>
507
+ const agentFactory = createAgent[runtimeConfig.id as string]
508
+ if (typeof agentFactory !== 'function') {
509
+ throw new Error(`Social chat agent factory is not configured for ${String(runtimeConfig.id)}`)
510
+ }
511
+
512
+ const consultParticipants = [
513
+ ...new Set(
514
+ (await socialChatConfig.getConsultParticipants?.({
515
+ workspaceId: resolvedContext.workspaceId,
516
+ workspaceIdString,
517
+ platform: 'slack',
518
+ })) ?? [...teamConsultParticipants],
519
+ ),
520
+ ].filter((agentId) => agentId !== socialAgentId)
521
+ const executedToolNames: string[] = []
522
+
523
+ const baseTools = withLoggedToolSet(
524
+ await socialChatConfig.buildAgentTools(
525
+ buildBuildToolsParams({
526
+ agentId: socialAgentId,
527
+ context: resolvedContext,
528
+ workspaceIdString,
529
+ userIdString,
530
+ messageContext,
531
+ memoryBlock,
532
+ onAppendMemoryBlock: (value: string) => {
533
+ memoryBlock = value
534
+ },
535
+ }),
536
+ ),
537
+ {
538
+ agentId: socialAgentId,
539
+ channelId: messageContext.channelId,
540
+ threadId: messageContext.threadId,
541
+ executedToolNames,
542
+ },
543
+ )
544
+
545
+ const transcript = buildThreadTranscript(historyBeforeReply)
546
+ const runAbort = createServerRunAbortController()
547
+
548
+ const consultSpecialistTool = createTool({
549
+ description: 'Consult one specialist teammate for targeted guidance before replying to the user.',
550
+ inputSchema: ConsultSpecialistArgsSchema,
551
+ execute: async ({ agentId, task }: ConsultSpecialistArgs) => {
552
+ if (!consultParticipants.includes(agentId)) {
553
+ throw new Error(`Agent "${agentId}" is not an allowed social-chat specialist.`)
554
+ }
555
+
556
+ let specialistMemoryBlock = ''
557
+ const specialistTools = withLoggedToolSet(
558
+ await socialChatConfig.buildAgentTools(
559
+ buildBuildToolsParams({
560
+ agentId,
561
+ context: resolvedContext,
562
+ workspaceIdString,
563
+ userIdString,
564
+ messageContext,
565
+ memoryBlock: specialistMemoryBlock,
566
+ onAppendMemoryBlock: (value: string) => {
567
+ specialistMemoryBlock = value
568
+ },
569
+ }),
570
+ ),
571
+ { agentId, channelId: messageContext.channelId, threadId: messageContext.threadId, executedToolNames },
572
+ )
573
+ const [specialistPreSeededMemories, specialistLearnedSkills] = await Promise.all([
574
+ memoryService.getTopMemories({
575
+ orgId: workspaceIdString,
576
+ agentName: agentId,
577
+ limit: PRESEEDED_MEMORY_LOOKUP_LIMIT,
578
+ }),
579
+ lifecycleState?.bootstrapActive
580
+ ? Promise.resolve(undefined)
581
+ : learnedSkillService
582
+ .retrieveForTurn({ orgId: workspaceIdString, agentId, query: task, limit: 3, minConfidence: 0.6 })
583
+ .catch((error) => {
584
+ aiLogger.warn`Failed to retrieve learned skills for ${agentId}: ${error}`
585
+ return undefined
586
+ }),
587
+ ])
588
+
589
+ const specialistRuntimeConfig = getAgentRuntimeConfig({
590
+ agentId,
591
+ workstreamMode: 'group',
592
+ mode: 'fixedWorkstreamMode',
593
+ onboardingActive: lifecycleState?.bootstrapActive ?? false,
594
+ linearInstalled: false,
595
+ systemWorkspaceDetails: promptContext.systemWorkspaceDetails,
596
+ preSeededMemoriesSection: specialistPreSeededMemories,
597
+ retrievedKnowledgeSection,
598
+ learnedSkillsSection: specialistLearnedSkills,
599
+ additionalInstructionSections: [
600
+ `You are supporting ${socialAgentDisplayName} in a Slack social-chat thread. Stay within your role.`,
601
+ ],
602
+ }) as Record<string, unknown>
603
+ const specialistFactory = createAgent[specialistRuntimeConfig.id as string]
604
+ if (typeof specialistFactory !== 'function') {
605
+ throw new Error(`Social specialist agent factory is not configured for ${String(specialistRuntimeConfig.id)}`)
606
+ }
607
+
608
+ const specialistAgent = specialistFactory({
609
+ mode: 'fixedWorkstreamMode',
610
+ tools: specialistTools,
611
+ extraInstructions: specialistRuntimeConfig.extraInstructions,
612
+ stopWhen: [stepCountIs(specialistRuntimeConfig.maxSteps as number)],
613
+ }) as SocialChatAgent
614
+ const specialistResponse = await specialistAgent.generate({
615
+ prompt: buildSpecialistPrompt({
616
+ requesterName: socialAgentDisplayName,
617
+ agentName: agentDisplayNames[agentId] ?? agentId,
618
+ task,
619
+ transcript,
620
+ }),
621
+ abortSignal: runAbort.signal,
622
+ })
623
+ const text = specialistResponse.text.trim()
624
+ if (!text) {
625
+ throw new Error(`Specialist ${agentId} returned an empty response.`)
626
+ }
627
+
628
+ return createAssistantMessage({ agentId, agentName: agentDisplayNames[agentId] ?? agentId, text })
629
+ },
630
+ toModelOutput: ({ output }) => {
631
+ const message = output
632
+ const agentName =
633
+ typeof message.metadata?.agentName === 'string' && message.metadata.agentName.trim().length > 0
634
+ ? message.metadata.agentName.trim()
635
+ : 'Specialist'
636
+ const summary = extractMessageText(message).trim()
637
+ return {
638
+ type: 'text',
639
+ value: summary ? `${agentName}: ${summary}` : `${agentName} completed the requested task.`,
640
+ }
641
+ },
642
+ })
643
+
644
+ const agent = agentFactory({
645
+ mode: 'workstreamMode',
646
+ tools: { ...baseTools, [CONSULT_SPECIALIST_TOOL_NAME]: consultSpecialistTool },
647
+ extraInstructions: runtimeConfig.extraInstructions,
648
+ stopWhen: [stepCountIs(runtimeConfig.maxSteps as number)],
649
+ }) as SocialChatAgent
650
+
651
+ await thread.startTyping('Thinking...').catch(() => undefined)
652
+ aiLogger.info`Slack social-chat generating reply: channelId=${messageContext.channelId}, threadId=${messageContext.threadId}`
653
+ const response = await agent.generate({
654
+ prompt: buildLeadAgentPrompt({
655
+ agentDisplayName: socialAgentDisplayName,
656
+ channelId: messageContext.channelId,
657
+ threadId: messageContext.threadId,
658
+ transcript,
659
+ latestUserMessage: messageContext.text,
660
+ latestAuthorName: messageContext.authorName,
661
+ }),
662
+ abortSignal: runAbort.signal,
663
+ })
664
+ const responseText = response.text.trim()
665
+ if (!responseText) {
666
+ throw new Error('Social chat agent returned an empty response.')
667
+ }
668
+
669
+ const replyMarkdown = buildSlackSocialReplyMarkdown({ replyMarkdown: responseText, executedToolNames })
670
+ const sentMessage = await thread.post({ markdown: replyMarkdown })
671
+ aiLogger.info`Slack social-chat reply posted: channelId=${messageContext.channelId}, threadId=${messageContext.threadId}, replyMessageId=${sentMessage.id}`
672
+ const normalizedResponse = normalizeSocialHistoryMessage({
673
+ workspaceId: workspaceIdString,
674
+ channelId: messageContext.channelId,
675
+ agentId: socialAgentId,
676
+ agentDisplayName: socialAgentDisplayName,
677
+ message: sentMessage,
678
+ textOverride: responseText,
679
+ })
680
+ if (normalizedResponse) {
681
+ await socialChatHistoryService.upsertMessages([normalizedResponse])
682
+ }
683
+
684
+ const priorHistoryMessages = toHistoryMessages(priorHistory)
685
+ const agentMessages = normalizedResponse ? buildAgentHistoryMessages([normalizedResponse]) : []
686
+ if (messageContext.text && agentMessages.length > 0) {
687
+ await safeEnqueue(
688
+ () =>
689
+ enqueuePostChatMemory(
690
+ {
691
+ orgId: workspaceIdString,
692
+ workstreamId: `social:slack:${messageContext.threadId}`,
693
+ sourceId: createCursorId({
694
+ workspaceId: workspaceIdString,
695
+ threadId: messageContext.threadId,
696
+ messageId: messageContext.messageId,
697
+ }),
698
+ userMessage: messageContext.text,
699
+ historyMessages: priorHistoryMessages,
700
+ agentMessages,
701
+ memoryBlock: memoryBlock.trim() ? memoryBlock : undefined,
702
+ source: 'social_chat',
703
+ sourceMetadata: {
704
+ platform: 'slack',
705
+ channelId: messageContext.channelId,
706
+ threadId: messageContext.threadId,
707
+ messageId: messageContext.messageId,
708
+ authorId: messageContext.authorId,
709
+ authorName: messageContext.authorName,
710
+ },
711
+ },
712
+ {
713
+ dedupeKey: createSocialMemoryDedupeKey({
714
+ workspaceId: workspaceIdString,
715
+ threadId: messageContext.threadId,
716
+ messageId: messageContext.messageId,
717
+ }),
718
+ },
719
+ ),
720
+ { operationName: 'social post-chat memory extraction enqueue' },
721
+ )
722
+ }
723
+
724
+ await safeEnqueue(() => enqueueRegularChatMemoryDigest({ orgId: workspaceIdString }), {
725
+ operationName: 'social regular chat memory digest enqueue',
726
+ })
727
+ await safeEnqueue(() => enqueueSkillExtraction({ orgId: workspaceIdString }), {
728
+ operationName: 'social skill extraction enqueue',
729
+ })
730
+ }
731
+
732
+ chat.onNewMention(async (thread, message) => {
733
+ aiLogger.info`Slack social-chat new mention received: threadId=${thread.id}, messageId=${message.id}`
734
+ await thread.subscribe()
735
+ await handleMessage(thread, message)
736
+ })
737
+ chat.onSubscribedMessage(async (thread, message) => {
738
+ aiLogger.info`Slack social-chat subscribed thread message received: threadId=${thread.id}, messageId=${message.id}`
739
+ await handleMessage(thread, message)
740
+ })
741
+
742
+ return {
743
+ enabled: true,
744
+ initialize,
745
+ shutdown,
746
+ webhooks: {
747
+ async slack(request: Request, options?: WebhookOptions) {
748
+ return chat.webhooks.slack(request, options)
749
+ },
750
+ },
751
+ }
752
+ }