@lota-sdk/core 0.1.36 → 0.1.38

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
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.38",
36
36
  "@mendable/firecrawl-js": "^4.18.0",
37
37
  "@surrealdb/node": "^3.0.3",
38
38
  "ai": "^6.0.141",
@@ -12,6 +12,7 @@ function defaultGetAgentRuntimeConfig(): Record<string, never> {
12
12
  export let agentDisplayNames: Record<string, string> = {}
13
13
  export let agentShortDisplayNames: Record<string, string> = {}
14
14
  export let agentDescriptions: Record<string, string> = {}
15
+ export let routerModelId: string | undefined = undefined
15
16
  export let agentRoster: readonly string[] = []
16
17
  export let leadAgentId = ''
17
18
  export let teamConsultParticipants: readonly string[] = []
@@ -38,6 +39,7 @@ export function configureAgents(config: {
38
39
  displayNames: Record<string, string>
39
40
  shortDisplayNames?: Record<string, string>
40
41
  descriptions?: Record<string, string>
42
+ routerModelId?: string
41
43
  teamConsultParticipants: readonly string[]
42
44
  getCoreWorkstreamProfile?: (coreType: string) => CoreWorkstreamProfile
43
45
  }): void {
@@ -50,6 +52,7 @@ export function configureAgents(config: {
50
52
  agentDisplayNames = config.displayNames
51
53
  agentShortDisplayNames = config.shortDisplayNames ?? {}
52
54
  agentDescriptions = config.descriptions ?? {}
55
+ routerModelId = config.routerModelId
53
56
  teamConsultParticipants = config.teamConsultParticipants
54
57
  if (config.getCoreWorkstreamProfile) {
55
58
  getCoreWorkstreamProfile = config.getCoreWorkstreamProfile
@@ -12,7 +12,6 @@ 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,
16
15
  } from '@lota-sdk/shared'
17
16
 
18
17
  // Both aliases point to the same underlying model. Keep the names separate so
@@ -251,6 +251,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
251
251
  displayNames: agentDisplayNames,
252
252
  shortDisplayNames: runtimeConfig.agents.shortDisplayNames,
253
253
  descriptions: runtimeConfig.agents.descriptions,
254
+ routerModelId: runtimeConfig.agents.routerModelId,
254
255
  teamConsultParticipants: runtimeConfig.agents.teamConsultParticipants,
255
256
  getCoreWorkstreamProfile: runtimeConfig.agents.getCoreWorkstreamProfile,
256
257
  })
@@ -176,6 +176,7 @@ const agentsConfigSchema = z
176
176
  descriptions: z
177
177
  .custom<Record<string, string>>(isStringRecord, { error: 'agents.descriptions must be a string record' })
178
178
  .optional(),
179
+ routerModelId: z.string().trim().min(1).optional(),
179
180
  teamConsultParticipants: z.array(z.string().trim().min(1)),
180
181
  getCoreWorkstreamProfile: z
181
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
@@ -58,7 +58,7 @@ import type { WorkstreamState } from '../runtime/workstream-state'
58
58
  import { assembleWorkstreamTurnContext } from '../runtime/workstream-turn-context'
59
59
  import { chatRunRegistry } from '../services/chat-run-registry.service'
60
60
  import type { NormalizedWorkstream, WorkstreamRecord } from '../services/workstream.types'
61
- import { triageWorkstreamMessage, checkForNextAgent } from '../system-agents/workstream-manager.agent'
61
+ import { triageWorkstreamMessage, checkForNextAgent } from '../system-agents/workstream-router.agent'
62
62
  import { safeEnqueue } from '../utils/async'
63
63
  import { AppError } from '../utils/errors'
64
64
  import { attachmentService } from './attachment.service'
@@ -244,6 +244,7 @@ interface StreamAgentResponseParams {
244
244
  stopWhen?: StopCondition<ToolSet> | Array<StopCondition<ToolSet>>
245
245
  prepareStep?: PrepareStepFunction<ToolSet>
246
246
  abortSignal?: AbortSignal
247
+ suppressFinish?: boolean
247
248
  }
248
249
 
249
250
  async function streamAgentResponse(
@@ -393,6 +394,15 @@ async function streamAgentResponse(
393
394
  firstChunkLogged = true
394
395
  }
395
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
+ }
396
406
  streamParams.writer.write(value)
397
407
  }
398
408
  }
@@ -817,6 +827,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
817
827
  filterTools?: (tools: ToolSet) => ToolSet
818
828
  includeExecutionPlanTools?: boolean
819
829
  metadataPatch?: NonNullable<MessageMetadata>
830
+ suppressFinish?: boolean
820
831
  }): Promise<ChatMessage> => {
821
832
  const visibleTimer = lotaDebugLogger.timer(`visible:${runParams.agentId}`)
822
833
  let runMemoryBlock = memoryBlock
@@ -852,6 +863,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
852
863
  additionalInstructionSections: runParams.additionalInstructionSections,
853
864
  includeExecutionPlanTools,
854
865
  writer,
866
+ suppressFinish: runParams.suppressFinish,
855
867
  })
856
868
 
857
869
  visibleTimer.step('stream-agent-response')
@@ -898,8 +910,8 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
898
910
  await runVisibleAgent({ agentId: workstream.agentId, mode: 'direct' })
899
911
  } else {
900
912
  // Multi-agent orchestration for group workstreams
901
- const members =
902
- workstream.members.length > 0 ? workstream.members : [visibleWorkstreamAgentId ?? defaultLeadAgentId]
913
+ const wsMembers = (workstream as { members?: string[] }).members ?? []
914
+ const members = wsMembers.length > 0 ? wsMembers : [visibleWorkstreamAgentId ?? defaultLeadAgentId]
903
915
  const fallbackAgentId = coreWorkstreamProfile?.config.agentId ?? defaultLeadAgentId
904
916
 
905
917
  const recentContext = currentMessages
@@ -914,10 +926,13 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
914
926
  recentContext,
915
927
  })
916
928
 
917
- const runGroupAgent = async (agentId: string, routingContext?: string) => {
929
+ const runGroupAgent = async (
930
+ agentId: string,
931
+ options?: { routingContext?: string; suppressFinish?: boolean },
932
+ ) => {
918
933
  const additionalSections = [...(coreInstructionSections ?? []), ...hookInstructionSections]
919
- if (routingContext) {
920
- additionalSections.push(`<routing-context>\n${routingContext}\n</routing-context>`)
934
+ if (options?.routingContext) {
935
+ additionalSections.push(`<routing-context>\n${options.routingContext}\n</routing-context>`)
921
936
  }
922
937
  // Multi-agent member protocol: be direct, focus on domain
923
938
  additionalSections.push(
@@ -929,16 +944,20 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
929
944
  mode: 'workstreamMode',
930
945
  skills: coreWorkstreamProfile?.skills ? [...coreWorkstreamProfile.skills] : undefined,
931
946
  additionalInstructionSections: additionalSections,
947
+ suppressFinish: options?.suppressFinish,
932
948
  })
933
949
  }
934
950
 
935
951
  if (!triageResult) {
936
- // No specialist match — fallback to owner (core) or chief (non-core)
952
+ // No specialist match — fallback to owner (core) or chief (non-core), single agent turn
937
953
  await runGroupAgent(fallbackAgentId)
938
954
  } else {
939
- // Run first routed agent
955
+ // Run first routed agent — suppress finish in case more agents follow
940
956
  const respondedAgents: string[] = []
941
- let lastResponse = await runGroupAgent(triageResult.agentId, triageResult.routingContext)
957
+ let lastResponse = await runGroupAgent(triageResult.agentId, {
958
+ routingContext: triageResult.routingContext,
959
+ suppressFinish: true,
960
+ })
942
961
  respondedAgents.push(triageResult.agentId)
943
962
 
944
963
  // Check if more agents should respond (max 3 total)
@@ -964,7 +983,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
964
983
  text: checkResult.routingContext ?? 'Please also provide your perspective on this topic.',
965
984
  },
966
985
  ],
967
- metadata: { hidden: true, createdAt: Date.now() },
986
+ metadata: { hidden: true, createdAt: Date.now() } as MessageMetadata,
968
987
  }
969
988
  await workstreamMessageService.upsertMessages({
970
989
  workstreamId: workstreamRef,
@@ -972,9 +991,17 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
972
991
  })
973
992
  currentMessages = upsertChatHistoryMessage(currentMessages, bridgeMessage)
974
993
 
975
- lastResponse = await runGroupAgent(checkResult.agentId, checkResult.routingContext)
994
+ lastResponse = await runGroupAgent(checkResult.agentId, {
995
+ routingContext: checkResult.routingContext,
996
+ suppressFinish: true,
997
+ })
976
998
  respondedAgents.push(checkResult.agentId)
977
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
+ }
978
1005
  }
979
1006
  }
980
1007
  }
@@ -10,4 +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'
13
+ export * from './workstream-router.agent'
@@ -0,0 +1,157 @@
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, routerModelId } 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 RouterTriageResult = z.infer<typeof TriageResultSchema>
26
+ export type RouterCheckResult = 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 createRouterAgent(systemPrompt: string) {
79
+ const modelId = routerModelId ?? OPENROUTER_FAST_REASONING_MODEL_ID
80
+ const providerOptions = routerModelId
81
+ ? {
82
+ openai: {
83
+ ...OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS.openai,
84
+ provider: { order: ['groq'], allow_fallbacks: true },
85
+ },
86
+ }
87
+ : OPENROUTER_HIGH_REASONING_PROVIDER_OPTIONS
88
+ return new ToolLoopAgent({
89
+ id: 'workstream-router',
90
+ model: aiGatewayChatModel(modelId),
91
+ headers: buildAiGatewayDirectCacheHeaders('workstream-router'),
92
+ providerOptions,
93
+ instructions: systemPrompt,
94
+ maxOutputTokens: 256,
95
+ })
96
+ }
97
+
98
+ export async function triageWorkstreamMessage(params: {
99
+ workstreamTitle: string
100
+ members: readonly string[]
101
+ messageText: string
102
+ recentContext?: string
103
+ }): Promise<RouterTriageResult | null> {
104
+ const membersDesc = buildMembersDescription(params.members)
105
+ const prompt = [
106
+ `Workstream: "${params.workstreamTitle}"`,
107
+ `Members:\n${membersDesc}`,
108
+ params.recentContext ? `Recent context:\n${params.recentContext}` : '',
109
+ `User message: "${params.messageText}"`,
110
+ ]
111
+ .filter(Boolean)
112
+ .join('\n\n')
113
+
114
+ const agent = createRouterAgent(TRIAGE_SYSTEM_PROMPT)
115
+ const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
116
+
117
+ const json = extractJson(typeof result.text === 'string' ? result.text : '')
118
+ const parsed = TriageResultSchema.safeParse(json)
119
+ if (!parsed.success) return null
120
+ if (!parsed.data.agentId) return null
121
+ if (!params.members.includes(parsed.data.agentId)) return null
122
+
123
+ return parsed.data
124
+ }
125
+
126
+ export async function checkForNextAgent(params: {
127
+ workstreamTitle: string
128
+ members: readonly string[]
129
+ messageText: string
130
+ respondedAgents: string[]
131
+ lastResponseSummary: string
132
+ }): Promise<RouterCheckResult> {
133
+ const remainingMembers = params.members.filter((id) => !params.respondedAgents.includes(id))
134
+ if (remainingMembers.length === 0) return { done: true }
135
+
136
+ const membersDesc = buildMembersDescription(remainingMembers)
137
+ const respondedList = params.respondedAgents.map((id) => agentDisplayNames[id] ?? id).join(', ')
138
+
139
+ const prompt = [
140
+ `Workstream: "${params.workstreamTitle}"`,
141
+ `Remaining members:\n${membersDesc}`,
142
+ `Already responded: ${respondedList}`,
143
+ `User message: "${params.messageText}"`,
144
+ `Last response summary: "${params.lastResponseSummary}"`,
145
+ ].join('\n\n')
146
+
147
+ const agent = createRouterAgent(CHECK_SYSTEM_PROMPT)
148
+ const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
149
+
150
+ const json = extractJson(typeof result.text === 'string' ? result.text : '')
151
+ const parsed = CheckResultSchema.safeParse(json)
152
+ if (!parsed.success) return { done: true }
153
+ if (parsed.data.done) return { done: true }
154
+ if (!parsed.data.agentId || !remainingMembers.includes(parsed.data.agentId)) return { done: true }
155
+
156
+ return parsed.data
157
+ }
@@ -1,153 +0,0 @@
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
- }