@lota-sdk/core 0.1.36 → 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.
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.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",
@@ -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 managerModelId: 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
+ managerModelId?: 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
+ managerModelId = config.managerModelId
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
+ managerModelId: runtimeConfig.agents.managerModelId,
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
+ managerModelId: 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
@@ -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
  }
@@ -1,31 +1,32 @@
1
- import { Output, ToolLoopAgent } from 'ai'
1
+ import { ToolLoopAgent } from 'ai'
2
2
  import { z } from 'zod'
3
3
 
4
4
  import { aiGatewayChatModel } from '../ai-gateway/ai-gateway'
5
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'
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'
8
12
 
9
13
  // ---------------------------------------------------------------------------
10
14
  // Schemas
11
15
  // ---------------------------------------------------------------------------
12
16
 
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
+ const TriageResultSchema = z.object({ agentId: z.string(), routingContext: z.string() })
17
18
 
18
19
  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'),
20
+ done: z.boolean(),
21
+ agentId: z.string().optional(),
22
+ routingContext: z.string().optional(),
22
23
  })
23
24
 
24
25
  export type ManagerTriageResult = z.infer<typeof TriageResultSchema>
25
26
  export type ManagerCheckResult = z.infer<typeof CheckResultSchema>
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
- // Prompt builders
29
+ // Helpers
29
30
  // ---------------------------------------------------------------------------
30
31
 
31
32
  function buildMembersDescription(members: readonly string[]): string {
@@ -33,53 +34,58 @@ function buildMembersDescription(members: readonly string[]): string {
33
34
  .map((id) => {
34
35
  const display = agentDisplayNames[id] ?? id
35
36
  const desc = agentDescriptions[id] ?? ''
36
- return `- **${display}** (id: \`${id}\`): ${desc}`
37
+ return `- ${display} (id: ${id}): ${desc}`
37
38
  })
38
39
  .join('\n')
39
40
  }
40
41
 
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
+ 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.
42
57
 
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>
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.
51
62
 
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>`
63
+ Format: {"agentId":"<id>","routingContext":"<1-sentence instruction>"}`
56
64
 
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.
65
+ const CHECK_SYSTEM_PROMPT = `You decide if another team member should also respond after the previous agent.
58
66
 
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>
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.
66
71
 
67
- <output-format>
68
- Respond with JSON only: { "done": true } or { "done": false, "agentId": "<agent-id>", "routingContext": "<1-sentence instruction>" }
69
- </output-format>`
72
+ Format: {"done":true} or {"done":false,"agentId":"<id>","routingContext":"<1-sentence>"}`
70
73
 
71
74
  // ---------------------------------------------------------------------------
72
75
  // Agent functions
73
76
  // ---------------------------------------------------------------------------
74
77
 
75
- function createManagerAgent(systemPrompt: string, schema: z.ZodSchema) {
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
76
83
  return new ToolLoopAgent({
77
84
  id: 'workstream-manager',
78
- model: aiGatewayChatModel(OPENROUTER_MANAGER_MODEL_ID),
85
+ model: aiGatewayChatModel(modelId),
79
86
  headers: buildAiGatewayDirectCacheHeaders('workstream-manager'),
80
- providerOptions: OPENROUTER_XHIGH_REASONING_PROVIDER_OPTIONS,
87
+ providerOptions,
81
88
  instructions: systemPrompt,
82
- output: Output.object({ schema }),
83
89
  maxOutputTokens: 256,
84
90
  })
85
91
  }
@@ -93,23 +99,20 @@ export async function triageWorkstreamMessage(params: {
93
99
  const membersDesc = buildMembersDescription(params.members)
94
100
  const prompt = [
95
101
  `Workstream: "${params.workstreamTitle}"`,
96
- `\nMembers:\n${membersDesc}`,
97
- params.recentContext ? `\nRecent context:\n${params.recentContext}` : '',
98
- `\nUser message: "${params.messageText}"`,
102
+ `Members:\n${membersDesc}`,
103
+ params.recentContext ? `Recent context:\n${params.recentContext}` : '',
104
+ `User message: "${params.messageText}"`,
99
105
  ]
100
106
  .filter(Boolean)
101
- .join('\n')
107
+ .join('\n\n')
102
108
 
103
- const agent = createManagerAgent(TRIAGE_SYSTEM_PROMPT, TriageResultSchema)
104
- const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 15_000 } })
109
+ const agent = createManagerAgent(TRIAGE_SYSTEM_PROMPT)
110
+ const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
105
111
 
106
- const parsed = TriageResultSchema.safeParse(result.output)
112
+ const json = extractJson(typeof result.text === 'string' ? result.text : '')
113
+ const parsed = TriageResultSchema.safeParse(json)
107
114
  if (!parsed.success) return null
108
-
109
- // Empty agentId = no specialist match, fallback to owner/chief
110
115
  if (!parsed.data.agentId) return null
111
-
112
- // Validate agent is in members list
113
116
  if (!params.members.includes(parsed.data.agentId)) return null
114
117
 
115
118
  return parsed.data
@@ -130,24 +133,20 @@ export async function checkForNextAgent(params: {
130
133
 
131
134
  const prompt = [
132
135
  `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')
136
+ `Remaining members:\n${membersDesc}`,
137
+ `Already responded: ${respondedList}`,
138
+ `User message: "${params.messageText}"`,
139
+ `Last response summary: "${params.lastResponseSummary}"`,
140
+ ].join('\n\n')
138
141
 
139
- const agent = createManagerAgent(CHECK_SYSTEM_PROMPT, CheckResultSchema)
140
- const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 15_000 } })
142
+ const agent = createManagerAgent(CHECK_SYSTEM_PROMPT)
143
+ const result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
141
144
 
142
- const parsed = CheckResultSchema.safeParse(result.output)
145
+ const json = extractJson(typeof result.text === 'string' ? result.text : '')
146
+ const parsed = CheckResultSchema.safeParse(json)
143
147
  if (!parsed.success) return { done: true }
144
-
145
148
  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
- }
149
+ if (!parsed.data.agentId || !remainingMembers.includes(parsed.data.agentId)) return { done: true }
151
150
 
152
151
  return parsed.data
153
152
  }