@lota-sdk/core 0.1.34 → 0.1.36

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.
@@ -19,6 +19,7 @@ DEFINE FIELD IF NOT EXISTS lastCompactedMessageId ON TABLE workstream TYPE optio
19
19
  DEFINE FIELD IF NOT EXISTS nameGenerated ON TABLE workstream TYPE bool DEFAULT false;
20
20
  DEFINE FIELD IF NOT EXISTS isCompacting ON TABLE workstream TYPE bool DEFAULT false;
21
21
  DEFINE FIELD IF NOT EXISTS state ON TABLE workstream TYPE option<object> FLEXIBLE;
22
+ DEFINE FIELD IF NOT EXISTS members ON TABLE workstream TYPE option<array<string>> DEFAULT [];
22
23
  DEFINE FIELD IF NOT EXISTS turnCount ON TABLE workstream TYPE int DEFAULT 0;
23
24
 
24
25
  DEFINE INDEX IF NOT EXISTS workstreamOrgIdx ON TABLE workstream COLUMNS organizationId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -32,7 +32,7 @@
32
32
  "@chat-adapter/slack": "^4.23.0",
33
33
  "@chat-adapter/state-ioredis": "^4.23.0",
34
34
  "@logtape/logtape": "^2.0.5",
35
- "@lota-sdk/shared": "0.1.34",
35
+ "@lota-sdk/shared": "0.1.35",
36
36
  "@mendable/firecrawl-js": "^4.18.0",
37
37
  "@surrealdb/node": "^3.0.3",
38
38
  "ai": "^6.0.141",
@@ -11,12 +11,14 @@ function defaultGetAgentRuntimeConfig(): Record<string, never> {
11
11
  // Agent configuration — these are defaults that consumers override via createLotaRuntime config
12
12
  export let agentDisplayNames: Record<string, string> = {}
13
13
  export let agentShortDisplayNames: Record<string, string> = {}
14
+ export let agentDescriptions: Record<string, string> = {}
14
15
  export let agentRoster: readonly string[] = []
15
16
  export let leadAgentId = ''
16
17
  export let teamConsultParticipants: readonly string[] = []
17
18
 
18
19
  export interface CoreWorkstreamProfile {
19
20
  config: { coreType: string; agentId: string; title: string }
21
+ members: readonly string[]
20
22
  tools: readonly string[]
21
23
  skills: readonly string[]
22
24
  instructions: string
@@ -24,6 +26,7 @@ export interface CoreWorkstreamProfile {
24
26
 
25
27
  export let getCoreWorkstreamProfile: (coreType: string) => CoreWorkstreamProfile = (_coreType) => ({
26
28
  config: { coreType: _coreType, agentId: '', title: '' },
29
+ members: [],
27
30
  tools: [],
28
31
  skills: [],
29
32
  instructions: '',
@@ -34,6 +37,7 @@ export function configureAgents(config: {
34
37
  leadAgentId: string
35
38
  displayNames: Record<string, string>
36
39
  shortDisplayNames?: Record<string, string>
40
+ descriptions?: Record<string, string>
37
41
  teamConsultParticipants: readonly string[]
38
42
  getCoreWorkstreamProfile?: (coreType: string) => CoreWorkstreamProfile
39
43
  }): void {
@@ -45,6 +49,7 @@ export function configureAgents(config: {
45
49
  leadAgentId = config.leadAgentId
46
50
  agentDisplayNames = config.displayNames
47
51
  agentShortDisplayNames = config.shortDisplayNames ?? {}
52
+ agentDescriptions = config.descriptions ?? {}
48
53
  teamConsultParticipants = config.teamConsultParticipants
49
54
  if (config.getCoreWorkstreamProfile) {
50
55
  getCoreWorkstreamProfile = config.getCoreWorkstreamProfile
@@ -12,6 +12,7 @@ export {
12
12
  OPENROUTER_TEAM_AGENT_MODEL_ID,
13
13
  OPENROUTER_WEB_RESEARCH_MODEL_ID,
14
14
  OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS,
15
+ OPENROUTER_MANAGER_MODEL_ID,
15
16
  } from '@lota-sdk/shared'
16
17
 
17
18
  // Both aliases point to the same underlying model. Keep the names separate so
@@ -250,6 +250,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
250
250
  leadAgentId: runtimeConfig.agents.leadAgentId,
251
251
  displayNames: agentDisplayNames,
252
252
  shortDisplayNames: runtimeConfig.agents.shortDisplayNames,
253
+ descriptions: runtimeConfig.agents.descriptions,
253
254
  teamConsultParticipants: runtimeConfig.agents.teamConsultParticipants,
254
255
  getCoreWorkstreamProfile: runtimeConfig.agents.getCoreWorkstreamProfile,
255
256
  })
@@ -173,6 +173,9 @@ const agentsConfigSchema = z
173
173
  shortDisplayNames: z
174
174
  .custom<Record<string, string>>(isStringRecord, { error: 'agents.shortDisplayNames must be a string record' })
175
175
  .optional(),
176
+ descriptions: z
177
+ .custom<Record<string, string>>(isStringRecord, { error: 'agents.descriptions must be a string record' })
178
+ .optional(),
176
179
  teamConsultParticipants: z.array(z.string().trim().min(1)),
177
180
  getCoreWorkstreamProfile: z
178
181
  .custom<(coreType: string) => CoreWorkstreamProfile>(isFunction, {
@@ -336,14 +336,9 @@ class ExecutionPlanService {
336
336
  input: PlanDraft & { runId: string; reason: string }
337
337
  }): Promise<ExecutionPlanToolResultData> {
338
338
  const activeRun = await planRunService.getRunById(params.input.runId)
339
- if (
340
- recordIdToString(activeRun.workstreamId, TABLES.WORKSTREAM) !==
341
- recordIdToString(params.workstreamId, TABLES.WORKSTREAM)
342
- ) {
343
- throw new Error('Execution run belongs to a different workstream.')
344
- }
339
+ const resolvedWorkstreamId = activeRun.workstreamId
345
340
 
346
- const activeRuns = await planRunService.getActiveRunRecords(params.workstreamId)
341
+ const activeRuns = await planRunService.getActiveRunRecords(resolvedWorkstreamId)
347
342
  if (activeRuns.length === 0) {
348
343
  throw new Error('No active execution run exists for this workstream.')
349
344
  }
@@ -404,7 +399,7 @@ class ExecutionPlanService {
404
399
  .content(
405
400
  buildCompiledSpecCreateData({
406
401
  organizationId: params.organizationId,
407
- workstreamId: params.workstreamId,
402
+ workstreamId: resolvedWorkstreamId,
408
403
  leadAgentId: params.leadAgentId,
409
404
  compiled,
410
405
  version: supersededSpec.version + 1,
@@ -418,7 +413,7 @@ class ExecutionPlanService {
418
413
  runId,
419
414
  spec,
420
415
  organizationId: params.organizationId,
421
- workstreamId: params.workstreamId,
416
+ workstreamId: resolvedWorkstreamId,
422
417
  leadAgentId: params.leadAgentId,
423
418
  nodes: compiled.nodes,
424
419
  emittedEvents,
@@ -986,11 +986,6 @@ class PlanExecutorService {
986
986
  emittedBy: string
987
987
  }): Promise<ExecutionPlanToolResultData> {
988
988
  const run = await planRunService.getRunById(params.runId)
989
- if (
990
- recordIdToString(run.workstreamId, TABLES.WORKSTREAM) !== recordIdToString(params.workstreamId, TABLES.WORKSTREAM)
991
- ) {
992
- throw new Error('Execution run belongs to a different workstream.')
993
- }
994
989
 
995
990
  const spec = await planRunService.getPlanSpecById(run.planSpecId)
996
991
  const nodeSpecs = await planRunService.listNodeSpecs(spec.id)
@@ -1119,11 +1114,6 @@ class PlanExecutorService {
1119
1114
  failureClass: PlanFailureClass
1120
1115
  }): Promise<SerializableExecutionPlan> {
1121
1116
  const run = await planRunService.getRunById(params.runId)
1122
- if (
1123
- recordIdToString(run.workstreamId, TABLES.WORKSTREAM) !== recordIdToString(params.workstreamId, TABLES.WORKSTREAM)
1124
- ) {
1125
- throw new Error('Execution run belongs to a different workstream.')
1126
- }
1127
1117
 
1128
1118
  const spec = await planRunService.getPlanSpecById(run.planSpecId)
1129
1119
  const nodeSpec = await planRunService.getNodeSpecByNodeId(spec.id, params.nodeId)
@@ -1,9 +1,5 @@
1
1
  import {
2
2
  WORKSTREAM,
3
- baseChatMessageSchema,
4
- CONSULT_SPECIALIST_TOOL_NAME,
5
- CONSULT_TEAM_TOOL_NAME,
6
- ConsultSpecialistArgsSchema,
7
3
  dataPartsSchemas,
8
4
  messageMetadataSchema,
9
5
  PlanNodeResultSubmissionSchema,
@@ -11,7 +7,7 @@ import {
11
7
  toTimestamp,
12
8
  withMessageCreatedAt,
13
9
  } from '@lota-sdk/shared'
14
- import type { ChatMessage, ConsultSpecialistArgs, MessageMetadata, PlanNodeSpecRecord } from '@lota-sdk/shared'
10
+ import type { ChatMessage, MessageMetadata, PlanNodeSpecRecord } from '@lota-sdk/shared'
15
11
  import { convertToModelMessages, stepCountIs, tool as createTool, validateUIMessages } from 'ai'
16
12
  import type { PrepareStepFunction, StopCondition, ToolLoopAgent, ToolSet, UIMessageStreamWriter } from 'ai'
17
13
 
@@ -32,11 +28,7 @@ import { TABLES } from '../db/tables'
32
28
  import { enqueueContextCompaction } from '../queues/context-compaction.queue'
33
29
  import { enqueueWorkstreamTitleGeneration } from '../queues/title-generation.queue'
34
30
  import { OWNERSHIP_DISPATCH_BLOCKED_TOOL_NAMES } from '../runtime/agent-runtime-policy'
35
- import {
36
- buildSpecialistTaskMessage,
37
- createAgentMessageMetadata,
38
- createServerRunAbortController,
39
- } from '../runtime/agent-stream-helpers'
31
+ import { createAgentMessageMetadata, createServerRunAbortController } from '../runtime/agent-stream-helpers'
40
32
  import { hasApprovalRespondedParts } from '../runtime/approval-continuation'
41
33
  import { buildModelInputMessagesWithUploadMetadata, buildReadableUploadMetadataText } from '../runtime/chat-attachments'
42
34
  import { hasMessageContent } from '../runtime/chat-message'
@@ -47,7 +39,6 @@ import { createExecutionPlanInstructionSectionCache } from '../runtime/execution
47
39
  import { mergeInstructionSections } from '../runtime/instruction-sections'
48
40
  import { runPostTurnSideEffects } from '../runtime/post-turn-side-effects'
49
41
  import { getRuntimeAdapters, getToolProviders, getTurnHooks } from '../runtime/runtime-extensions'
50
- import { runSpecialistSession } from '../runtime/specialist-runner'
51
42
  import { finalizeTurnRun } from '../runtime/turn-lifecycle'
52
43
  import {
53
44
  asRecord,
@@ -67,7 +58,7 @@ import type { WorkstreamState } from '../runtime/workstream-state'
67
58
  import { assembleWorkstreamTurnContext } from '../runtime/workstream-turn-context'
68
59
  import { chatRunRegistry } from '../services/chat-run-registry.service'
69
60
  import type { NormalizedWorkstream, WorkstreamRecord } from '../services/workstream.types'
70
- import { createTeamThinkTool } from '../tools/team-think.tool'
61
+ import { triageWorkstreamMessage, checkForNextAgent } from '../system-agents/workstream-manager.agent'
71
62
  import { safeEnqueue } from '../utils/async'
72
63
  import { AppError } from '../utils/errors'
73
64
  import { attachmentService } from './attachment.service'
@@ -114,30 +105,6 @@ async function waitForWorkstreamCompactionIfNeeded(workstreamId: RecordIdRef): P
114
105
  })
115
106
  }
116
107
 
117
- function parseChatMessageCandidate(value: unknown): ChatMessage | undefined {
118
- const parsed = baseChatMessageSchema.safeParse(value)
119
- if (!parsed.success) return undefined
120
- return parsed.data as ChatMessage
121
- }
122
-
123
- function getChatMessageFromToolOutput(output: unknown): ChatMessage | undefined {
124
- const directCandidate = parseChatMessageCandidate(output)
125
- if (directCandidate) return directCandidate
126
-
127
- if (Array.isArray(output)) {
128
- for (let index = output.length - 1; index >= 0; index -= 1) {
129
- const candidate = getChatMessageFromToolOutput(output[index])
130
- if (candidate) return candidate
131
- }
132
- }
133
-
134
- if (output && typeof output === 'object' && 'message' in output) {
135
- return getChatMessageFromToolOutput((output as { message?: unknown }).message)
136
- }
137
-
138
- return undefined
139
- }
140
-
141
108
  class WorkstreamTurnError extends AppError {
142
109
  constructor(
143
110
  message: string,
@@ -924,107 +891,91 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
924
891
  metadataPatch: { trigger: 'plan-turn', planRunId: planTurn.runId, planNodeId: planTurn.nodeId },
925
892
  })
926
893
  } else {
927
- const consultSpecialistTool = createTool({
928
- description: 'Consult one specialist teammate for domain-specific guidance before replying to the user.',
929
- inputSchema: ConsultSpecialistArgsSchema,
930
- execute: async function* (
931
- { agentId, task }: ConsultSpecialistArgs,
932
- { abortSignal: toolAbortSignal }: { abortSignal?: AbortSignal },
933
- ) {
934
- const specialistTaskMessage = buildSpecialistTaskMessage({ agentId, task })
935
- const specialistAbortSignal = toolAbortSignal ?? runAbort.signal
936
- const { result: finalMessage, memoryBlock: nextMemoryBlock } = await runSpecialistSession({
937
- initialMemoryBlock: memoryBlock,
938
- buildTools: async ({ memoryBlock: currentMemoryBlock, onAppendMemoryBlock }) =>
939
- (await buildAgentTools(
940
- buildTurnToolParams({
941
- agentId,
942
- mode: 'fixedWorkstreamMode',
943
- memoryBlock: currentMemoryBlock,
944
- onAppendMemoryBlock,
945
- extraMessages: [specialistTaskMessage],
946
- includeExecutionPlanTools: false,
947
- }),
948
- )) as ToolSet,
949
- run: async ({ tools, memoryBlock: currentMemoryBlock }) =>
950
- await streamAgentResponse(
951
- { ...streamCtx, memoryBlock: currentMemoryBlock },
952
- {
953
- agentId,
954
- mode: 'fixedWorkstreamMode',
955
- messages: buildRunInputMessages([specialistTaskMessage]),
956
- tools: { ...tools, ...toolProviders },
957
- observer: createObserver(agentId),
958
- additionalInstructionSections: coreInstructionSections,
959
- includeExecutionPlanTools: false,
960
- abortSignal: specialistAbortSignal,
961
- },
962
- ),
963
- })
964
- const committedFinalMessage = withMessageCreatedAt(finalMessage, Date.now())
965
- memoryBlock = nextMemoryBlock
966
- yield committedFinalMessage
967
- return committedFinalMessage
968
- },
969
- toModelOutput: ({ output }) => {
970
- const result = getChatMessageFromToolOutput(output)
971
- const agentName =
972
- typeof result?.metadata?.agentName === 'string' && result.metadata.agentName.trim().length > 0
973
- ? result.metadata.agentName.trim()
974
- : 'Specialist'
975
- const summary = result ? extractMessageText(result).trim() : ''
976
- return {
977
- type: 'text',
978
- value: summary ? `${agentName}: ${summary}` : `${agentName} completed the requested task.`,
979
- }
980
- },
981
- })
982
-
983
- const teamThinkTool =
984
- workstream.mode === 'group'
985
- ? createTeamThinkTool({
986
- historyMessages: currentMessages,
987
- latestUserMessageId: referenceUserMessageId,
988
- orgId: orgRef,
989
- userId: userRef,
990
- workstreamId: workstreamRef,
991
- githubInstalled,
992
- availableUploads: listReadableUploads(),
993
- provideRepoTool: indexedRepoContext.provideRepoTool,
994
- defaultRepoSectionsByAgent: indexedRepoContext.defaultSectionsByAgent as never,
995
- systemWorkspaceDetails: promptContext.systemWorkspaceDetails,
996
- getPreSeededMemoriesSection,
997
- retrievedKnowledgeSection,
998
- additionalInstructionSections: mergeInstructionSections(
999
- coreInstructionSections,
1000
- hookInstructionSections,
1001
- ),
1002
- getAdditionalInstructionSections: getExecutionPlanInstructionSections,
1003
- context: buildContextResult,
1004
- toolProviders,
1005
- abortSignal: runAbort.signal,
1006
- })
1007
- : null
1008
-
1009
894
  if (workstream.mode === 'direct') {
1010
895
  if (!workstream.agentId) {
1011
896
  throw new WorkstreamTurnError('Direct workstreams require an assigned agent.', 400)
1012
897
  }
1013
898
  await runVisibleAgent({ agentId: workstream.agentId, mode: 'direct' })
1014
899
  } else {
1015
- await runVisibleAgent({
1016
- agentId: visibleWorkstreamAgentId ?? defaultLeadAgentId,
1017
- mode: 'workstreamMode',
1018
- skills: coreWorkstreamProfile?.skills ? [...coreWorkstreamProfile.skills] : undefined,
1019
- additionalInstructionSections: mergeInstructionSections(
1020
- coreInstructionSections,
1021
- hookInstructionSections,
1022
- ),
1023
- extraTools: {
1024
- [CONSULT_SPECIALIST_TOOL_NAME]: consultSpecialistTool,
1025
- ...(teamThinkTool ? { [CONSULT_TEAM_TOOL_NAME]: teamThinkTool } : {}),
1026
- },
900
+ // Multi-agent orchestration for group workstreams
901
+ const members =
902
+ workstream.members.length > 0 ? workstream.members : [visibleWorkstreamAgentId ?? defaultLeadAgentId]
903
+ const fallbackAgentId = coreWorkstreamProfile?.config.agentId ?? defaultLeadAgentId
904
+
905
+ const recentContext = currentMessages
906
+ .slice(-6)
907
+ .map((m) => `${m.role}: ${extractMessageText(m).slice(0, 200)}`)
908
+ .join('\n')
909
+
910
+ const triageResult = await triageWorkstreamMessage({
911
+ workstreamTitle: workstream.title,
912
+ members,
913
+ messageText,
914
+ recentContext,
1027
915
  })
916
+
917
+ const runGroupAgent = async (agentId: string, routingContext?: string) => {
918
+ const additionalSections = [...(coreInstructionSections ?? []), ...hookInstructionSections]
919
+ if (routingContext) {
920
+ additionalSections.push(`<routing-context>\n${routingContext}\n</routing-context>`)
921
+ }
922
+ // Multi-agent member protocol: be direct, focus on domain
923
+ additionalSections.push(
924
+ '<multi-agent-protocol>\nYou are responding as part of a multi-agent workstream. Focus on your domain expertise. Be direct and concise — another agent may follow up on different aspects.\n</multi-agent-protocol>',
925
+ )
926
+
927
+ return await runVisibleAgent({
928
+ agentId,
929
+ mode: 'workstreamMode',
930
+ skills: coreWorkstreamProfile?.skills ? [...coreWorkstreamProfile.skills] : undefined,
931
+ additionalInstructionSections: additionalSections,
932
+ })
933
+ }
934
+
935
+ if (!triageResult) {
936
+ // No specialist match — fallback to owner (core) or chief (non-core)
937
+ await runGroupAgent(fallbackAgentId)
938
+ } else {
939
+ // Run first routed agent
940
+ const respondedAgents: string[] = []
941
+ let lastResponse = await runGroupAgent(triageResult.agentId, triageResult.routingContext)
942
+ respondedAgents.push(triageResult.agentId)
943
+
944
+ // Check if more agents should respond (max 3 total)
945
+ while (respondedAgents.length < 3) {
946
+ const lastResponseText = extractMessageText(lastResponse).slice(0, 500)
947
+ const checkResult = await checkForNextAgent({
948
+ workstreamTitle: workstream.title,
949
+ members,
950
+ messageText,
951
+ respondedAgents,
952
+ lastResponseSummary: lastResponseText,
953
+ })
954
+
955
+ if (checkResult.done || !checkResult.agentId) break
956
+
957
+ // Insert hidden bridge message between agent turns
958
+ const bridgeMessage: ChatMessage = {
959
+ id: Bun.randomUUIDv7(),
960
+ role: 'user',
961
+ parts: [
962
+ {
963
+ type: 'text',
964
+ text: checkResult.routingContext ?? 'Please also provide your perspective on this topic.',
965
+ },
966
+ ],
967
+ metadata: { hidden: true, createdAt: Date.now() },
968
+ }
969
+ await workstreamMessageService.upsertMessages({
970
+ workstreamId: workstreamRef,
971
+ messages: [bridgeMessage],
972
+ })
973
+ currentMessages = upsertChatHistoryMessage(currentMessages, bridgeMessage)
974
+
975
+ lastResponse = await runGroupAgent(checkResult.agentId, checkResult.routingContext)
976
+ respondedAgents.push(checkResult.agentId)
977
+ }
978
+ }
1028
979
  }
1029
980
  }
1030
981
  } finally {
@@ -1,7 +1,7 @@
1
1
  import { WORKSTREAM, sdkWorkstreamStatusSchema } from '@lota-sdk/shared'
2
2
  import { BoundQuery, RecordId, StringRecordId, surql } from 'surrealdb'
3
3
 
4
- import { agentDisplayNames, getCoreWorkstreamProfile, isAgentName } from '../config/agent-defaults'
4
+ import { agentDisplayNames, agentRoster, getCoreWorkstreamProfile, isAgentName } from '../config/agent-defaults'
5
5
  import { serverLogger } from '../config/logger'
6
6
  import { getWorkstreamBootstrapConfig } from '../config/workstream-defaults'
7
7
  import { BaseService } from '../db/base.service'
@@ -152,7 +152,14 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
152
152
  async createWorkstream(
153
153
  userId: RecordIdRef,
154
154
  orgId: RecordIdRef,
155
- options?: { title?: string; mode?: string; agentId?: string; core?: boolean; coreType?: string },
155
+ options?: {
156
+ title?: string
157
+ mode?: string
158
+ agentId?: string
159
+ core?: boolean
160
+ coreType?: string
161
+ members?: string[]
162
+ },
156
163
  ): Promise<NormalizedWorkstream> {
157
164
  const mode = options?.mode ?? 'group'
158
165
  const directAgentId = options?.agentId
@@ -199,6 +206,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
199
206
  mode,
200
207
  core: false,
201
208
  agentId,
209
+ members: [agentId],
202
210
  title,
203
211
  status: 'regular',
204
212
  nameGenerated: true,
@@ -217,6 +225,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
217
225
  core: true,
218
226
  coreType: resolvedCoreType,
219
227
  agentId: coreProfile.config.agentId,
228
+ members: [...coreProfile.members],
220
229
  title,
221
230
  status: 'regular',
222
231
  nameGenerated: true,
@@ -229,6 +238,7 @@ class WorkstreamService extends BaseService<typeof WorkstreamSchema> {
229
238
  organizationId: orgId,
230
239
  mode,
231
240
  core: false,
241
+ members: options?.members ?? [...agentRoster],
232
242
  title,
233
243
  status: 'regular',
234
244
  nameGenerated: options?.title !== undefined && options.title !== WORKSTREAM.DEFAULT_TITLE,
@@ -10,3 +10,4 @@ export * from './researcher.agent'
10
10
  export * from './skill-extractor.agent'
11
11
  export * from './skill-manager.agent'
12
12
  export * from './title-generator.agent'
13
+ export * from './workstream-manager.agent'
@@ -0,0 +1,153 @@
1
+ import { Output, ToolLoopAgent } from 'ai'
2
+ import { z } from 'zod'
3
+
4
+ import { aiGatewayChatModel } from '../ai-gateway/ai-gateway'
5
+ import { buildAiGatewayDirectCacheHeaders } from '../ai-gateway/cache-headers'
6
+ import { agentDescriptions, agentDisplayNames } from '../config/agent-defaults'
7
+ import { OPENROUTER_MANAGER_MODEL_ID, OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS } from '../config/model-constants'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Schemas
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const TriageResultSchema = z.object({
14
+ agentId: z.string().describe('The agent ID that should respond next'),
15
+ routingContext: z.string().describe('Brief instruction for the selected agent about what to focus on'),
16
+ })
17
+
18
+ const CheckResultSchema = z.object({
19
+ done: z.boolean().describe('true if no more agents need to respond'),
20
+ agentId: z.string().optional().describe('Next agent ID if done is false'),
21
+ routingContext: z.string().optional().describe('Brief instruction for the next agent if done is false'),
22
+ })
23
+
24
+ export type ManagerTriageResult = z.infer<typeof TriageResultSchema>
25
+ export type ManagerCheckResult = z.infer<typeof CheckResultSchema>
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Prompt builders
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function buildMembersDescription(members: readonly string[]): string {
32
+ return members
33
+ .map((id) => {
34
+ const display = agentDisplayNames[id] ?? id
35
+ const desc = agentDescriptions[id] ?? ''
36
+ return `- **${display}** (id: \`${id}\`): ${desc}`
37
+ })
38
+ .join('\n')
39
+ }
40
+
41
+ const TRIAGE_SYSTEM_PROMPT = `You are a workstream message router. Your job is to decide which team member should respond to a user message.
42
+
43
+ <rules>
44
+ - Pick the single best-fit agent from the members list.
45
+ - Consider the agent's domain expertise against the user's question.
46
+ - If the message is clearly about a specific domain (marketing, tech, product, finance, etc.), route to the domain specialist.
47
+ - If multiple domains are equally relevant, pick the most relevant one — a follow-up check will decide if another agent should also respond.
48
+ - If no specialist clearly matches (general chat, greetings, coordination), respond with an empty agentId to signal fallback.
49
+ - Be decisive. Do not overthink.
50
+ </rules>
51
+
52
+ <output-format>
53
+ Respond with JSON only: { "agentId": "<agent-id>", "routingContext": "<1-sentence instruction>" }
54
+ If no specialist matches, respond: { "agentId": "", "routingContext": "" }
55
+ </output-format>`
56
+
57
+ const CHECK_SYSTEM_PROMPT = `You are a workstream conversation reviewer. After an agent has responded to a user message, you decide if another team member should also respond.
58
+
59
+ <rules>
60
+ - Only add another agent if the user's question has a clearly separate dimension that the previous agent(s) did not cover.
61
+ - Do NOT add agents just for acknowledgement or agreement.
62
+ - Do NOT repeat the same domain. If the CTO already covered tech, don't add another tech perspective.
63
+ - Most messages need only one agent. Multi-agent responses should be the exception, not the rule.
64
+ - Maximum 3 total agent responses per user message.
65
+ </rules>
66
+
67
+ <output-format>
68
+ Respond with JSON only: { "done": true } or { "done": false, "agentId": "<agent-id>", "routingContext": "<1-sentence instruction>" }
69
+ </output-format>`
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Agent functions
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function createManagerAgent(systemPrompt: string, schema: z.ZodSchema) {
76
+ return new ToolLoopAgent({
77
+ id: 'workstream-manager',
78
+ model: aiGatewayChatModel(OPENROUTER_MANAGER_MODEL_ID),
79
+ headers: buildAiGatewayDirectCacheHeaders('workstream-manager'),
80
+ providerOptions: OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS,
81
+ instructions: systemPrompt,
82
+ output: Output.object({ schema }),
83
+ maxOutputTokens: 256,
84
+ })
85
+ }
86
+
87
+ export async function triageWorkstreamMessage(params: {
88
+ workstreamTitle: string
89
+ members: readonly string[]
90
+ messageText: string
91
+ recentContext?: string
92
+ }): Promise<ManagerTriageResult | null> {
93
+ const membersDesc = buildMembersDescription(params.members)
94
+ const prompt = [
95
+ `Workstream: "${params.workstreamTitle}"`,
96
+ `\nMembers:\n${membersDesc}`,
97
+ params.recentContext ? `\nRecent context:\n${params.recentContext}` : '',
98
+ `\nUser message: "${params.messageText}"`,
99
+ ]
100
+ .filter(Boolean)
101
+ .join('\n')
102
+
103
+ const agent = createManagerAgent(TRIAGE_SYSTEM_PROMPT, TriageResultSchema)
104
+ const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 15_000 } })
105
+
106
+ const parsed = TriageResultSchema.safeParse(result.output)
107
+ if (!parsed.success) return null
108
+
109
+ // Empty agentId = no specialist match, fallback to owner/chief
110
+ if (!parsed.data.agentId) return null
111
+
112
+ // Validate agent is in members list
113
+ if (!params.members.includes(parsed.data.agentId)) return null
114
+
115
+ return parsed.data
116
+ }
117
+
118
+ export async function checkForNextAgent(params: {
119
+ workstreamTitle: string
120
+ members: readonly string[]
121
+ messageText: string
122
+ respondedAgents: string[]
123
+ lastResponseSummary: string
124
+ }): Promise<ManagerCheckResult> {
125
+ const remainingMembers = params.members.filter((id) => !params.respondedAgents.includes(id))
126
+ if (remainingMembers.length === 0) return { done: true }
127
+
128
+ const membersDesc = buildMembersDescription(remainingMembers)
129
+ const respondedList = params.respondedAgents.map((id) => agentDisplayNames[id] ?? id).join(', ')
130
+
131
+ const prompt = [
132
+ `Workstream: "${params.workstreamTitle}"`,
133
+ `\nRemaining members:\n${membersDesc}`,
134
+ `\nAlready responded: ${respondedList}`,
135
+ `\nUser message: "${params.messageText}"`,
136
+ `\nLast response summary: "${params.lastResponseSummary}"`,
137
+ ].join('\n')
138
+
139
+ const agent = createManagerAgent(CHECK_SYSTEM_PROMPT, CheckResultSchema)
140
+ const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 15_000 } })
141
+
142
+ const parsed = CheckResultSchema.safeParse(result.output)
143
+ if (!parsed.success) return { done: true }
144
+
145
+ if (parsed.data.done) return { done: true }
146
+
147
+ // Validate next agent is in remaining members
148
+ if (!parsed.data.agentId || !remainingMembers.includes(parsed.data.agentId)) {
149
+ return { done: true }
150
+ }
151
+
152
+ return parsed.data
153
+ }