@lota-sdk/core 0.1.35 → 0.1.37

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.35",
3
+ "version": "0.1.37",
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.35",
35
+ "@lota-sdk/shared": "0.1.37",
36
36
  "@mendable/firecrawl-js": "^4.18.0",
37
37
  "@surrealdb/node": "^3.0.3",
38
38
  "ai": "^6.0.141",
@@ -11,12 +11,15 @@ 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> = {}
15
+ export let managerModelId: string | undefined = undefined
14
16
  export let agentRoster: readonly string[] = []
15
17
  export let leadAgentId = ''
16
18
  export let teamConsultParticipants: readonly string[] = []
17
19
 
18
20
  export interface CoreWorkstreamProfile {
19
21
  config: { coreType: string; agentId: string; title: string }
22
+ members: readonly string[]
20
23
  tools: readonly string[]
21
24
  skills: readonly string[]
22
25
  instructions: string
@@ -24,6 +27,7 @@ export interface CoreWorkstreamProfile {
24
27
 
25
28
  export let getCoreWorkstreamProfile: (coreType: string) => CoreWorkstreamProfile = (_coreType) => ({
26
29
  config: { coreType: _coreType, agentId: '', title: '' },
30
+ members: [],
27
31
  tools: [],
28
32
  skills: [],
29
33
  instructions: '',
@@ -34,6 +38,8 @@ export function configureAgents(config: {
34
38
  leadAgentId: string
35
39
  displayNames: Record<string, string>
36
40
  shortDisplayNames?: Record<string, string>
41
+ descriptions?: Record<string, string>
42
+ managerModelId?: string
37
43
  teamConsultParticipants: readonly string[]
38
44
  getCoreWorkstreamProfile?: (coreType: string) => CoreWorkstreamProfile
39
45
  }): void {
@@ -45,6 +51,8 @@ export function configureAgents(config: {
45
51
  leadAgentId = config.leadAgentId
46
52
  agentDisplayNames = config.displayNames
47
53
  agentShortDisplayNames = config.shortDisplayNames ?? {}
54
+ agentDescriptions = config.descriptions ?? {}
55
+ managerModelId = config.managerModelId
48
56
  teamConsultParticipants = config.teamConsultParticipants
49
57
  if (config.getCoreWorkstreamProfile) {
50
58
  getCoreWorkstreamProfile = config.getCoreWorkstreamProfile
@@ -250,6 +250,8 @@ 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,
254
+ managerModelId: runtimeConfig.agents.managerModelId,
253
255
  teamConsultParticipants: runtimeConfig.agents.teamConsultParticipants,
254
256
  getCoreWorkstreamProfile: runtimeConfig.agents.getCoreWorkstreamProfile,
255
257
  })
@@ -173,6 +173,10 @@ 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(),
179
+ managerModelId: z.string().trim().min(1).optional(),
176
180
  teamConsultParticipants: z.array(z.string().trim().min(1)),
177
181
  getCoreWorkstreamProfile: z
178
182
  .custom<(coreType: string) => CoreWorkstreamProfile>(isFunction, {
@@ -3,6 +3,7 @@ import type { ConvergenceState, PlanFailureClass } from '@lota-sdk/shared'
3
3
  import { serverLogger } from '../config/logger'
4
4
  import { recordIdToString } from '../db/record-id'
5
5
  import { TABLES } from '../db/tables'
6
+ import { shouldPlanNodeUseVisibleTurn } from '../runtime/execution-plan-visibility'
6
7
 
7
8
  function classifyDispatchFailure(ownerType: string, error: unknown): PlanFailureClass {
8
9
  const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
@@ -80,17 +81,48 @@ class GlobalOrchestratorService {
80
81
  })
81
82
  if (readyNodes.length === 0) break
82
83
 
84
+ // Split into silent (dispatch now) and visible (enqueue heartbeat wake)
85
+ const silentNodes = readyNodes.filter((nr) => {
86
+ const ns = nodeSpecs.find((s) => s.nodeId === nr.nodeId)
87
+ return ns && !shouldPlanNodeUseVisibleTurn(spec, ns)
88
+ })
89
+ const visibleNodes = readyNodes.filter((nr) => {
90
+ const ns = nodeSpecs.find((s) => s.nodeId === nr.nodeId)
91
+ return ns && shouldPlanNodeUseVisibleTurn(spec, ns)
92
+ })
93
+
83
94
  // Transition all ready nodes to 'running' BEFORE dispatching
84
95
  for (const nodeRun of readyNodes) {
85
96
  await planExecutorService.transitionNodeToRunning({ runId: params.runId, nodeId: nodeRun.nodeId })
86
97
  }
87
98
 
99
+ // Enqueue heartbeat wakes for visible agent nodes — they need a streaming turn
100
+ if (visibleNodes.length > 0) {
101
+ const { enqueuePlanAgentHeartbeatWake } = await import('../queues/plan-agent-heartbeat.queue')
102
+ const updatedRunForWake = await planRunService.getRunById(params.runId)
103
+ for (const nodeRun of visibleNodes) {
104
+ const ns = nodeSpecs.find((s) => s.nodeId === nodeRun.nodeId)
105
+ if (!ns || ns.owner.executorType !== 'agent') continue
106
+ await enqueuePlanAgentHeartbeatWake({
107
+ organizationId: recordIdToString(updatedRunForWake.organizationId, TABLES.ORGANIZATION),
108
+ workstreamId: recordIdToString(updatedRunForWake.workstreamId, TABLES.WORKSTREAM),
109
+ runId: recordIdToString(updatedRunForWake.id, TABLES.PLAN_RUN),
110
+ nodeId: nodeRun.nodeId,
111
+ agentId: ns.owner.ref,
112
+ reason: 'graph-full-visible',
113
+ })
114
+ }
115
+ }
116
+
117
+ // If no silent nodes to dispatch, break and let heartbeat handle visible ones
118
+ if (silentNodes.length === 0) break
119
+
88
120
  // Re-fetch run after transitions for accurate state in dispatch context
89
121
  const updatedRun = await planRunService.getRunById(params.runId)
90
122
 
91
- // Dispatch all in parallel with LINEAR mode override (prevents recursion)
123
+ // Dispatch silent nodes in parallel with LINEAR mode override (prevents recursion)
92
124
  const results = await Promise.allSettled(
93
- readyNodes.map(async (nodeRun) => {
125
+ silentNodes.map(async (nodeRun) => {
94
126
  const nodeSpecRecord = nodeSpecs.find((ns) => ns.nodeId === nodeRun.nodeId)
95
127
  if (!nodeSpecRecord) {
96
128
  throw new Error(`Node spec not found for node "${nodeRun.nodeId}".`)
@@ -114,7 +146,7 @@ class GlobalOrchestratorService {
114
146
  // Submit results sequentially (each triggers syncRunGraph internally)
115
147
  for (let i = 0; i < results.length; i++) {
116
148
  const settled = results[i]
117
- const nodeRun = readyNodes[i]
149
+ const nodeRun = silentNodes[i]
118
150
  const nodeSpecRecord = nodeSpecs.find((ns) => ns.nodeId === nodeRun.nodeId)
119
151
 
120
152
  if (settled.status === 'fulfilled') {
@@ -73,6 +73,34 @@ class PlanAgentQueryService {
73
73
 
74
74
  for (const run of runs) {
75
75
  const spec = await planRunService.getPlanSpecById(run.planSpecId)
76
+
77
+ // Graph-full plans can have multiple visible agent nodes running in parallel
78
+ if (spec.executionMode === 'graph-full') {
79
+ const [nodeSpecs, nodeRuns] = await Promise.all([
80
+ planRunService.listNodeSpecs(spec.id),
81
+ planRunService.listNodeRuns(run.id),
82
+ ])
83
+ for (const nodeRun of nodeRuns) {
84
+ if (!ACTIONABLE_NODE_STATUSES.has(nodeRun.status)) continue
85
+ const nodeSpec = nodeSpecs.find((ns) => ns.nodeId === nodeRun.nodeId)
86
+ if (!nodeSpec) continue
87
+ const visibleTarget = isVisibleAgentNode({ nodeSpec, spec })
88
+ if (!visibleTarget) continue
89
+ if (params.agentId && params.agentId !== visibleTarget.agentId) continue
90
+ actionable.push({
91
+ organizationId: recordIdToString(run.organizationId, TABLES.ORGANIZATION),
92
+ workstreamId: recordIdToString(run.workstreamId, TABLES.WORKSTREAM),
93
+ runId: recordIdToString(run.id, TABLES.PLAN_RUN),
94
+ nodeId: nodeSpec.nodeId,
95
+ agentId: visibleTarget.agentId,
96
+ status: nodeRun.status as 'ready' | 'running',
97
+ visibility: visibleTarget.visibility,
98
+ })
99
+ }
100
+ continue
101
+ }
102
+
103
+ // Linear plans: only the current node is actionable
76
104
  const currentNodeId = run.currentNodeId
77
105
  if (!currentNodeId) {
78
106
  continue
@@ -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,
@@ -277,6 +244,7 @@ interface StreamAgentResponseParams {
277
244
  stopWhen?: StopCondition<ToolSet> | Array<StopCondition<ToolSet>>
278
245
  prepareStep?: PrepareStepFunction<ToolSet>
279
246
  abortSignal?: AbortSignal
247
+ suppressFinish?: boolean
280
248
  }
281
249
 
282
250
  async function streamAgentResponse(
@@ -426,6 +394,15 @@ async function streamAgentResponse(
426
394
  firstChunkLogged = true
427
395
  }
428
396
  if (streamParams.writer) {
397
+ if (
398
+ streamParams.suppressFinish &&
399
+ typeof value === 'object' &&
400
+ value !== null &&
401
+ 'type' in value &&
402
+ (value as { type: string }).type === 'finish'
403
+ ) {
404
+ continue
405
+ }
429
406
  streamParams.writer.write(value)
430
407
  }
431
408
  }
@@ -850,6 +827,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
850
827
  filterTools?: (tools: ToolSet) => ToolSet
851
828
  includeExecutionPlanTools?: boolean
852
829
  metadataPatch?: NonNullable<MessageMetadata>
830
+ suppressFinish?: boolean
853
831
  }): Promise<ChatMessage> => {
854
832
  const visibleTimer = lotaDebugLogger.timer(`visible:${runParams.agentId}`)
855
833
  let runMemoryBlock = memoryBlock
@@ -885,6 +863,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
885
863
  additionalInstructionSections: runParams.additionalInstructionSections,
886
864
  includeExecutionPlanTools,
887
865
  writer,
866
+ suppressFinish: runParams.suppressFinish,
888
867
  })
889
868
 
890
869
  visibleTimer.step('stream-agent-response')
@@ -924,107 +903,106 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
924
903
  metadataPatch: { trigger: 'plan-turn', planRunId: planTurn.runId, planNodeId: planTurn.nodeId },
925
904
  })
926
905
  } 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
906
  if (workstream.mode === 'direct') {
1010
907
  if (!workstream.agentId) {
1011
908
  throw new WorkstreamTurnError('Direct workstreams require an assigned agent.', 400)
1012
909
  }
1013
910
  await runVisibleAgent({ agentId: workstream.agentId, mode: 'direct' })
1014
911
  } 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
- },
912
+ // Multi-agent orchestration for group workstreams
913
+ const wsMembers = (workstream as { members?: string[] }).members ?? []
914
+ const members = wsMembers.length > 0 ? wsMembers : [visibleWorkstreamAgentId ?? defaultLeadAgentId]
915
+ const fallbackAgentId = coreWorkstreamProfile?.config.agentId ?? defaultLeadAgentId
916
+
917
+ const recentContext = currentMessages
918
+ .slice(-6)
919
+ .map((m) => `${m.role}: ${extractMessageText(m).slice(0, 200)}`)
920
+ .join('\n')
921
+
922
+ const triageResult = await triageWorkstreamMessage({
923
+ workstreamTitle: workstream.title,
924
+ members,
925
+ messageText,
926
+ recentContext,
1027
927
  })
928
+
929
+ const runGroupAgent = async (
930
+ agentId: string,
931
+ options?: { routingContext?: string; suppressFinish?: boolean },
932
+ ) => {
933
+ const additionalSections = [...(coreInstructionSections ?? []), ...hookInstructionSections]
934
+ if (options?.routingContext) {
935
+ additionalSections.push(`<routing-context>\n${options.routingContext}\n</routing-context>`)
936
+ }
937
+ // Multi-agent member protocol: be direct, focus on domain
938
+ additionalSections.push(
939
+ '<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>',
940
+ )
941
+
942
+ return await runVisibleAgent({
943
+ agentId,
944
+ mode: 'workstreamMode',
945
+ skills: coreWorkstreamProfile?.skills ? [...coreWorkstreamProfile.skills] : undefined,
946
+ additionalInstructionSections: additionalSections,
947
+ suppressFinish: options?.suppressFinish,
948
+ })
949
+ }
950
+
951
+ if (!triageResult) {
952
+ // No specialist match — fallback to owner (core) or chief (non-core), single agent turn
953
+ await runGroupAgent(fallbackAgentId)
954
+ } else {
955
+ // Run first routed agent — suppress finish in case more agents follow
956
+ const respondedAgents: string[] = []
957
+ let lastResponse = await runGroupAgent(triageResult.agentId, {
958
+ routingContext: triageResult.routingContext,
959
+ suppressFinish: true,
960
+ })
961
+ respondedAgents.push(triageResult.agentId)
962
+
963
+ // Check if more agents should respond (max 3 total)
964
+ while (respondedAgents.length < 3) {
965
+ const lastResponseText = extractMessageText(lastResponse).slice(0, 500)
966
+ const checkResult = await checkForNextAgent({
967
+ workstreamTitle: workstream.title,
968
+ members,
969
+ messageText,
970
+ respondedAgents,
971
+ lastResponseSummary: lastResponseText,
972
+ })
973
+
974
+ if (checkResult.done || !checkResult.agentId) break
975
+
976
+ // Insert hidden bridge message between agent turns
977
+ const bridgeMessage: ChatMessage = {
978
+ id: Bun.randomUUIDv7(),
979
+ role: 'user',
980
+ parts: [
981
+ {
982
+ type: 'text',
983
+ text: checkResult.routingContext ?? 'Please also provide your perspective on this topic.',
984
+ },
985
+ ],
986
+ metadata: { hidden: true, createdAt: Date.now() } as MessageMetadata,
987
+ }
988
+ await workstreamMessageService.upsertMessages({
989
+ workstreamId: workstreamRef,
990
+ messages: [bridgeMessage],
991
+ })
992
+ currentMessages = upsertChatHistoryMessage(currentMessages, bridgeMessage)
993
+
994
+ lastResponse = await runGroupAgent(checkResult.agentId, {
995
+ routingContext: checkResult.routingContext,
996
+ suppressFinish: true,
997
+ })
998
+ respondedAgents.push(checkResult.agentId)
999
+ }
1000
+
1001
+ // Write final finish chunk so the client knows the turn is complete
1002
+ if (writer) {
1003
+ writer.write({ type: 'finish', finishReason: 'stop' } as ChatStreamChunk)
1004
+ }
1005
+ }
1028
1006
  }
1029
1007
  }
1030
1008
  } 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,152 @@
1
+ import { 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, managerModelId } from '../config/agent-defaults'
7
+ import {
8
+ OPENROUTER_FAST_REASONING_MODEL_ID,
9
+ OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS,
10
+ OPENROUTER_HIGH_REASONING_PROVIDER_OPTIONS,
11
+ } from '../config/model-constants'
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Schemas
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const TriageResultSchema = z.object({ agentId: z.string(), routingContext: z.string() })
18
+
19
+ const CheckResultSchema = z.object({
20
+ done: z.boolean(),
21
+ agentId: z.string().optional(),
22
+ routingContext: z.string().optional(),
23
+ })
24
+
25
+ export type ManagerTriageResult = z.infer<typeof TriageResultSchema>
26
+ export type ManagerCheckResult = z.infer<typeof CheckResultSchema>
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function buildMembersDescription(members: readonly string[]): string {
33
+ return members
34
+ .map((id) => {
35
+ const display = agentDisplayNames[id] ?? id
36
+ const desc = agentDescriptions[id] ?? ''
37
+ return `- ${display} (id: ${id}): ${desc}`
38
+ })
39
+ .join('\n')
40
+ }
41
+
42
+ function extractJson(text: string): unknown {
43
+ const match = text.match(/\{[\s\S]*\}/)
44
+ if (!match) return null
45
+ try {
46
+ return JSON.parse(match[0])
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Prompts
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const TRIAGE_SYSTEM_PROMPT = `You are a workstream message router. Decide which team member should respond to the user message.
57
+
58
+ Rules:
59
+ - Pick the single best-fit agent from the members list based on domain expertise.
60
+ - If no specialist clearly matches (general chat, greetings, coordination), respond with agentId "".
61
+ - Be decisive. Reply with ONLY a JSON object, no other text.
62
+
63
+ Format: {"agentId":"<id>","routingContext":"<1-sentence instruction>"}`
64
+
65
+ const CHECK_SYSTEM_PROMPT = `You decide if another team member should also respond after the previous agent.
66
+
67
+ Rules:
68
+ - Only add another agent if the user's question has a clearly separate dimension not yet covered.
69
+ - Do NOT add agents for agreement or acknowledgement.
70
+ - Most messages need only one agent. Reply with ONLY a JSON object, no other text.
71
+
72
+ Format: {"done":true} or {"done":false,"agentId":"<id>","routingContext":"<1-sentence>"}`
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Agent functions
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function createManagerAgent(systemPrompt: string) {
79
+ const modelId = managerModelId ?? OPENROUTER_FAST_REASONING_MODEL_ID
80
+ const providerOptions = managerModelId
81
+ ? OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS
82
+ : OPENROUTER_HIGH_REASONING_PROVIDER_OPTIONS
83
+ return new ToolLoopAgent({
84
+ id: 'workstream-manager',
85
+ model: aiGatewayChatModel(modelId),
86
+ headers: buildAiGatewayDirectCacheHeaders('workstream-manager'),
87
+ providerOptions,
88
+ instructions: systemPrompt,
89
+ maxOutputTokens: 256,
90
+ })
91
+ }
92
+
93
+ export async function triageWorkstreamMessage(params: {
94
+ workstreamTitle: string
95
+ members: readonly string[]
96
+ messageText: string
97
+ recentContext?: string
98
+ }): Promise<ManagerTriageResult | null> {
99
+ const membersDesc = buildMembersDescription(params.members)
100
+ const prompt = [
101
+ `Workstream: "${params.workstreamTitle}"`,
102
+ `Members:\n${membersDesc}`,
103
+ params.recentContext ? `Recent context:\n${params.recentContext}` : '',
104
+ `User message: "${params.messageText}"`,
105
+ ]
106
+ .filter(Boolean)
107
+ .join('\n\n')
108
+
109
+ const agent = createManagerAgent(TRIAGE_SYSTEM_PROMPT)
110
+ const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
111
+
112
+ const json = extractJson(typeof result.text === 'string' ? result.text : '')
113
+ const parsed = TriageResultSchema.safeParse(json)
114
+ if (!parsed.success) return null
115
+ if (!parsed.data.agentId) return null
116
+ if (!params.members.includes(parsed.data.agentId)) return null
117
+
118
+ return parsed.data
119
+ }
120
+
121
+ export async function checkForNextAgent(params: {
122
+ workstreamTitle: string
123
+ members: readonly string[]
124
+ messageText: string
125
+ respondedAgents: string[]
126
+ lastResponseSummary: string
127
+ }): Promise<ManagerCheckResult> {
128
+ const remainingMembers = params.members.filter((id) => !params.respondedAgents.includes(id))
129
+ if (remainingMembers.length === 0) return { done: true }
130
+
131
+ const membersDesc = buildMembersDescription(remainingMembers)
132
+ const respondedList = params.respondedAgents.map((id) => agentDisplayNames[id] ?? id).join(', ')
133
+
134
+ const prompt = [
135
+ `Workstream: "${params.workstreamTitle}"`,
136
+ `Remaining members:\n${membersDesc}`,
137
+ `Already responded: ${respondedList}`,
138
+ `User message: "${params.messageText}"`,
139
+ `Last response summary: "${params.lastResponseSummary}"`,
140
+ ].join('\n\n')
141
+
142
+ const agent = createManagerAgent(CHECK_SYSTEM_PROMPT)
143
+ const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
144
+
145
+ const json = extractJson(typeof result.text === 'string' ? result.text : '')
146
+ const parsed = CheckResultSchema.safeParse(json)
147
+ if (!parsed.success) return { done: true }
148
+ if (parsed.data.done) return { done: true }
149
+ if (!parsed.data.agentId || !remainingMembers.includes(parsed.data.agentId)) return { done: true }
150
+
151
+ return parsed.data
152
+ }