@lota-sdk/core 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/infrastructure/schema/02_execution_plan.surql +4 -0
  2. package/package.json +6 -6
  3. package/src/ai-gateway/ai-gateway.ts +2 -4
  4. package/src/create-runtime.ts +8 -0
  5. package/src/queues/document-processor.queue.ts +11 -8
  6. package/src/queues/index.ts +1 -0
  7. package/src/queues/plan-agent-heartbeat.queue.ts +100 -0
  8. package/src/queues/queue-factory.ts +12 -11
  9. package/src/redis/redis-lease-lock.ts +1 -1
  10. package/src/runtime/agent-runtime-policy.ts +41 -4
  11. package/src/runtime/execution-plan-visibility.ts +23 -0
  12. package/src/runtime/execution-plan.ts +1 -0
  13. package/src/runtime/runtime-extensions.ts +26 -0
  14. package/src/runtime/runtime-worker-registry.ts +9 -1
  15. package/src/services/agent-executor.service.ts +6 -0
  16. package/src/services/execution-plan.service.ts +51 -36
  17. package/src/services/index.ts +3 -0
  18. package/src/services/ownership-dispatcher.service.ts +50 -8
  19. package/src/services/plan-agent-heartbeat.service.ts +136 -0
  20. package/src/services/plan-agent-query.service.ts +238 -0
  21. package/src/services/plan-builder.service.ts +11 -1
  22. package/src/services/plan-compiler.service.ts +2 -0
  23. package/src/services/plan-deadline.service.ts +186 -44
  24. package/src/services/plan-event-delivery.service.ts +170 -0
  25. package/src/services/plan-executor.service.ts +107 -3
  26. package/src/services/plan-helpers.ts +13 -0
  27. package/src/services/plan-run.service.ts +4 -0
  28. package/src/services/plan-template.service.ts +0 -1
  29. package/src/services/workstream-turn-preparation.service.ts +452 -176
  30. package/src/services/workstream-turn.ts +101 -1
  31. package/src/services/workstream.service.ts +76 -16
  32. package/src/tools/execution-plan.tool.ts +0 -2
@@ -6,10 +6,19 @@ import {
6
6
  ConsultSpecialistArgsSchema,
7
7
  dataPartsSchemas,
8
8
  messageMetadataSchema,
9
+ PlanNodeResultSubmissionSchema,
10
+ SUBMIT_PLAN_TURN_RESULT_TOOL_NAME,
9
11
  toTimestamp,
10
12
  withMessageCreatedAt,
11
13
  } from '@lota-sdk/shared'
12
- import type { ChatMessage, MessageMetadata } from '@lota-sdk/shared'
14
+ import type {
15
+ ChatMessage,
16
+ MessageMetadata,
17
+ PlanArtifactSubmission,
18
+ PlanNodeHandoffContext,
19
+ PlanNodeRunRecord,
20
+ PlanNodeSpecRecord,
21
+ } from '@lota-sdk/shared'
13
22
  import { convertToModelMessages, readUIMessageStream, stepCountIs, tool as createTool, validateUIMessages } from 'ai'
14
23
  import type { PrepareStepFunction, StopCondition, ToolLoopAgent, ToolSet, UIMessageStreamWriter } from 'ai'
15
24
  import type { z } from 'zod'
@@ -36,6 +45,10 @@ import { enqueueRegularChatMemoryDigest } from '../queues/regular-chat-memory-di
36
45
  import { enqueueSkillExtraction } from '../queues/skill-extraction.queue'
37
46
  import { enqueueWorkstreamTitleGeneration } from '../queues/workstream-title-generation.queue'
38
47
  import { buildAgentPromptContext } from '../runtime/agent-prompt-context'
48
+ import {
49
+ OWNERSHIP_DISPATCH_BLOCKED_TOOL_NAMES,
50
+ buildCompletionCheckStructuredOutputHints,
51
+ } from '../runtime/agent-runtime-policy'
39
52
  import {
40
53
  buildSpecialistTaskMessage,
41
54
  createAgentMessageMetadata,
@@ -85,7 +98,7 @@ import { memoryService } from './memory.service'
85
98
  import { planRunService } from './plan-run.service'
86
99
  import { recentActivityService } from './recent-activity.service'
87
100
  import { workstreamMessageService } from './workstream-message.service'
88
- import { workstreamService } from './workstream.service'
101
+ import { ActiveWorkstreamRunConflictError, workstreamService } from './workstream.service'
89
102
 
90
103
  type ChatStreamChunk = Parameters<UIMessageStreamWriter<ChatMessage>['write']>[0]
91
104
 
@@ -183,6 +196,190 @@ function optionalInstructionSection(value: unknown): string[] | undefined {
183
196
  return section ? [section] : undefined
184
197
  }
185
198
 
199
+ export interface PlanTurnUpstreamHandoff {
200
+ nodeId: string
201
+ label: string
202
+ ownerRef: string
203
+ ownerType: PlanNodeSpecRecord['owner']['executorType']
204
+ handoffContext: PlanNodeHandoffContext
205
+ }
206
+
207
+ export interface WorkstreamPlanTurnContext {
208
+ runId: string
209
+ nodeId: string
210
+ planTitle: string
211
+ nodeSpec: PlanNodeSpecRecord
212
+ nodeRun: PlanNodeRunRecord
213
+ resolvedInput: Record<string, unknown>
214
+ inputArtifacts: PlanArtifactSubmission[]
215
+ upstreamHandoffs: PlanTurnUpstreamHandoff[]
216
+ }
217
+
218
+ function buildPlanTurnExecutionSection(planTurn: WorkstreamPlanTurnContext): string {
219
+ const payload = {
220
+ runId: planTurn.runId,
221
+ planTitle: planTurn.planTitle,
222
+ node: {
223
+ id: planTurn.nodeSpec.nodeId,
224
+ label: planTurn.nodeSpec.label,
225
+ owner: planTurn.nodeSpec.owner,
226
+ objective: planTurn.nodeSpec.objective,
227
+ instructions: planTurn.nodeSpec.instructions,
228
+ outputSchemaRef: planTurn.nodeSpec.outputSchemaRef ?? null,
229
+ deliverables: planTurn.nodeSpec.deliverables,
230
+ successCriteria: planTurn.nodeSpec.successCriteria,
231
+ completionChecks: planTurn.nodeSpec.completionChecks,
232
+ toolPolicy: planTurn.nodeSpec.toolPolicy,
233
+ contextPolicy: planTurn.nodeSpec.contextPolicy,
234
+ },
235
+ resolvedInput: planTurn.resolvedInput,
236
+ inputArtifacts: planTurn.inputArtifacts,
237
+ }
238
+
239
+ return [
240
+ '<plan-turn-execution>',
241
+ 'The runtime has activated a visible execution-plan node inside this workstream.',
242
+ `Complete node "${planTurn.nodeSpec.label}" for plan "${planTurn.planTitle}".`,
243
+ 'Use only the node contract, resolved input, input artifacts, and upstream handoff context provided here.',
244
+ 'Do not ask the user for more input and do not rely on unstated external context.',
245
+ 'Do not submit placeholders, partial work, or speculative outputs.',
246
+ 'Before submitting, satisfy every required deliverable, success criterion, and completion check for this node.',
247
+ 'Deliverables must use the exact artifact names and kinds declared in the node contract.',
248
+ 'If a deliverable declares schemaRef, include the same schemaRef and a payload that satisfies that schema.',
249
+ 'If outputSchemaRef is declared, structuredOutput must satisfy that schema before you submit.',
250
+ `When finished, call ${SUBMIT_PLAN_TURN_RESULT_TOOL_NAME} exactly once.`,
251
+ 'Always include durable handoffContext for downstream nodes when you submit the final result.',
252
+ 'Do not ask the user for confirmation and do not create or replace execution plans in this turn.',
253
+ JSON.stringify(payload, null, 2),
254
+ '</plan-turn-execution>',
255
+ ].join('\n')
256
+ }
257
+
258
+ function describePlanTurnDeliverable(deliverable: PlanNodeSpecRecord['deliverables'][number]): string {
259
+ return [
260
+ `- ${deliverable.name}`,
261
+ `kind=${deliverable.kind}`,
262
+ deliverable.required ? 'required' : 'optional',
263
+ deliverable.schemaRef ? `schemaRef=${deliverable.schemaRef}` : undefined,
264
+ deliverable.description ? `description=${deliverable.description}` : undefined,
265
+ ]
266
+ .filter(Boolean)
267
+ .join(' | ')
268
+ }
269
+
270
+ function describePlanTurnCompletionCheck(check: PlanNodeSpecRecord['completionChecks'][number]): string {
271
+ return [
272
+ `- ${check.description}`,
273
+ `type=${check.type}`,
274
+ check.blocking ? 'blocking' : 'warning',
275
+ Object.keys(check.config).length > 0 ? `config=${JSON.stringify(check.config)}` : undefined,
276
+ ]
277
+ .filter(Boolean)
278
+ .join(' | ')
279
+ }
280
+
281
+ function buildPlanTurnResultContractSection(planTurn: WorkstreamPlanTurnContext): string {
282
+ const requiredDeliverables = planTurn.nodeSpec.deliverables.filter((deliverable) => deliverable.required)
283
+ const completionCheckOutputHints = buildCompletionCheckStructuredOutputHints(planTurn.nodeSpec)
284
+ const deliverableLines =
285
+ planTurn.nodeSpec.deliverables.length > 0
286
+ ? planTurn.nodeSpec.deliverables.map(describePlanTurnDeliverable)
287
+ : ['- none']
288
+ const completionCheckLines =
289
+ planTurn.nodeSpec.completionChecks.length > 0
290
+ ? planTurn.nodeSpec.completionChecks.map(describePlanTurnCompletionCheck)
291
+ : ['- none']
292
+
293
+ return [
294
+ '<plan-turn-result-contract>',
295
+ `Call ${SUBMIT_PLAN_TURN_RESULT_TOOL_NAME} exactly once with a result object that passes node validation.`,
296
+ 'Validation is strict. Missing required artifacts, schema mismatches, or failed completion checks will fail the node run.',
297
+ `Required artifacts: ${requiredDeliverables.length > 0 ? requiredDeliverables.map((deliverable) => deliverable.name).join(', ') : 'none'}`,
298
+ `Structured output: ${
299
+ planTurn.nodeSpec.outputSchemaRef
300
+ ? `required and must match schema "${planTurn.nodeSpec.outputSchemaRef}"`
301
+ : 'optional unless needed by a completion check'
302
+ }`,
303
+ 'Deliverables:',
304
+ ...deliverableLines,
305
+ 'Completion checks:',
306
+ ...completionCheckLines,
307
+ ...(completionCheckOutputHints.length > 0
308
+ ? ['Structured output fields required by completion checks:', ...completionCheckOutputHints]
309
+ : []),
310
+ 'Include notes with a concise completion summary grounded in the submitted artifacts and structuredOutput.',
311
+ 'Always include handoffContext for downstream execution with a durable summary, key decisions, open questions, risks, recommendations, and references when relevant.',
312
+ '</plan-turn-result-contract>',
313
+ ].join('\n')
314
+ }
315
+
316
+ function buildPlanTurnSubmitToolDescription(planTurn: WorkstreamPlanTurnContext): string {
317
+ const requiredArtifacts =
318
+ planTurn.nodeSpec.deliverables
319
+ .filter((deliverable) => deliverable.required)
320
+ .map((deliverable) => `${deliverable.name} (${deliverable.kind})`)
321
+ .join(', ') || 'none'
322
+
323
+ return [
324
+ 'Submit the final result for the active plan-triggered node turn.',
325
+ 'Call this exactly once when the node output is complete.',
326
+ `Required artifacts: ${requiredArtifacts}.`,
327
+ `Structured output: ${
328
+ planTurn.nodeSpec.outputSchemaRef ? `must satisfy ${planTurn.nodeSpec.outputSchemaRef}` : 'optional'
329
+ }.`,
330
+ 'Do not submit partial results. Include durable handoffContext for downstream nodes.',
331
+ ].join(' ')
332
+ }
333
+
334
+ function buildPlanTurnPromptMessage(planTurn: WorkstreamPlanTurnContext): ChatMessage {
335
+ return {
336
+ id: Bun.randomUUIDv7(),
337
+ role: 'user',
338
+ parts: [
339
+ {
340
+ type: 'text',
341
+ text: `Execute the active plan node "${planTurn.nodeSpec.label}" now and submit the result with ${SUBMIT_PLAN_TURN_RESULT_TOOL_NAME}.`,
342
+ },
343
+ ],
344
+ metadata: { trigger: 'plan-turn', planRunId: planTurn.runId, planNodeId: planTurn.nodeId, createdAt: Date.now() },
345
+ }
346
+ }
347
+
348
+ function buildUpstreamHandoffSection(upstreamHandoffs: PlanTurnUpstreamHandoff[]): string | undefined {
349
+ if (upstreamHandoffs.length === 0) {
350
+ return undefined
351
+ }
352
+
353
+ return [
354
+ '<upstream-handoff>',
355
+ JSON.stringify(
356
+ upstreamHandoffs.map((handoff) => ({
357
+ nodeId: handoff.nodeId,
358
+ label: handoff.label,
359
+ ownerRef: handoff.ownerRef,
360
+ ownerType: handoff.ownerType,
361
+ handoffContext: handoff.handoffContext,
362
+ })),
363
+ null,
364
+ 2,
365
+ ),
366
+ '</upstream-handoff>',
367
+ ].join('\n')
368
+ }
369
+
370
+ function applyPlanTurnToolPolicy(tools: ToolSet, nodeSpec: PlanNodeSpecRecord): ToolSet {
371
+ const blockedToolNames = new Set([...OWNERSHIP_DISPATCH_BLOCKED_TOOL_NAMES, ...nodeSpec.toolPolicy.deny])
372
+ const allowList = nodeSpec.toolPolicy.allow.length > 0 ? new Set(nodeSpec.toolPolicy.allow) : null
373
+
374
+ return Object.fromEntries(
375
+ Object.entries(tools).filter(
376
+ ([toolName]) =>
377
+ !blockedToolNames.has(toolName) &&
378
+ (toolName === SUBMIT_PLAN_TURN_RESULT_TOOL_NAME || allowList === null || allowList.has(toolName)),
379
+ ),
380
+ )
381
+ }
382
+
186
383
  export interface WorkstreamTurnParams {
187
384
  workstream: NormalizedWorkstream
188
385
  workstreamRef: RecordIdRef
@@ -207,6 +404,17 @@ export interface WorkstreamApprovalContinuationParams {
207
404
  streamId?: string
208
405
  }
209
406
 
407
+ export interface WorkstreamPlanTurnParams {
408
+ workstream: NormalizedWorkstream
409
+ workstreamRef: RecordIdRef
410
+ orgRef: RecordIdRef
411
+ userRef: RecordIdRef
412
+ userName?: string | null
413
+ planTurn: WorkstreamPlanTurnContext
414
+ abortSignal?: AbortSignal
415
+ streamId?: string
416
+ }
417
+
210
418
  type WorkstreamRunCoreParams = {
211
419
  workstream: NormalizedWorkstream
212
420
  workstreamRef: RecordIdRef
@@ -220,6 +428,7 @@ type WorkstreamRunCoreParams = {
220
428
  | { kind: 'userTurn'; inputMessage: ChatMessage; skipInputMessagePersistence?: boolean }
221
429
  | { kind: 'approvalContinuation'; approvalMessages: ChatMessage[] }
222
430
  | { kind: 'nativeToolApprovalTurn'; approvalMessages: ChatMessage[] }
431
+ | { kind: 'planTurn'; planTurn: WorkstreamPlanTurnContext }
223
432
  )
224
433
 
225
434
  interface PreparedWorkstreamTurn {
@@ -617,7 +826,8 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
617
826
  let inputMessage: ChatMessage | undefined
618
827
  const shouldPersistInputMessage = params.kind === 'userTurn' ? params.skipInputMessagePersistence !== true : false
619
828
  const shouldProcessPostRunSideEffects =
620
- params.kind === 'approvalContinuation' || params.kind === 'nativeToolApprovalTurn' || shouldPersistInputMessage
829
+ params.kind !== 'planTurn' &&
830
+ (params.kind === 'approvalContinuation' || params.kind === 'nativeToolApprovalTurn' || shouldPersistInputMessage)
621
831
  if (params.kind === 'userTurn') {
622
832
  inputMessage = hydrateMessageFileUrls(withMessageCreatedAt(params.inputMessage, Date.now()))
623
833
  if (inputMessage.role !== 'user') {
@@ -638,7 +848,10 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
638
848
 
639
849
  const workstreamRecord = await waitForWorkstreamCompactionIfNeeded(workstreamRef)
640
850
  timer.step('compaction-gate')
641
- if (toOptionalTrimmedString(workstreamRecord.activeRunId)) {
851
+ if (
852
+ (await workstreamService.hasActiveRunLease(workstreamRef)) ||
853
+ toOptionalTrimmedString(workstreamRecord.activeRunId)
854
+ ) {
642
855
  const clearedStaleRun = await workstreamService.clearStaleActiveRunIfMissingFromRegistry(workstreamRef)
643
856
  if (!clearedStaleRun) {
644
857
  throw new WorkstreamTurnError('A chat run is already active.', 409)
@@ -713,10 +926,17 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
713
926
  const originalMessages = userMessage ? upsertChatHistoryMessage(liveHistory, userMessage) : liveHistory
714
927
  let allAssistantMessages: ChatMessage[] = []
715
928
  const referenceUserMessage =
716
- params.kind === 'userTurn' && !shouldPersistInputMessage
717
- ? [...liveHistory].reverse().find((m) => m.role === 'user')
718
- : (userMessage ?? [...liveHistory].reverse().find((m) => m.role === 'user'))
719
- const messageText = referenceUserMessage ? extractMessageText(referenceUserMessage).trim() : ''
929
+ params.kind === 'planTurn'
930
+ ? undefined
931
+ : params.kind === 'userTurn' && !shouldPersistInputMessage
932
+ ? [...liveHistory].reverse().find((m) => m.role === 'user')
933
+ : (userMessage ?? [...liveHistory].reverse().find((m) => m.role === 'user'))
934
+ const messageText =
935
+ params.kind === 'planTurn'
936
+ ? `${params.planTurn.nodeSpec.label}\n${params.planTurn.nodeSpec.objective}\n${params.planTurn.nodeSpec.instructions}`
937
+ : referenceUserMessage
938
+ ? extractMessageText(referenceUserMessage).trim()
939
+ : ''
720
940
 
721
941
  if (
722
942
  params.kind === 'userTurn' &&
@@ -975,9 +1195,17 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
975
1195
  },
976
1196
  })
977
1197
 
978
- const commitAssistantResponse = async (response: ChatMessage, agentId: string, agentName: string) => {
1198
+ const commitAssistantResponse = async (
1199
+ response: ChatMessage,
1200
+ agentId: string,
1201
+ agentName: string,
1202
+ metadataPatch?: NonNullable<MessageMetadata>,
1203
+ ) => {
979
1204
  const committed = withMessageCreatedAt(
980
- { ...response, metadata: { ...response.metadata, ...buildAgentMetadataPatch(agentId, agentName) } },
1205
+ {
1206
+ ...response,
1207
+ metadata: { ...response.metadata, ...buildAgentMetadataPatch(agentId, agentName), ...metadataPatch },
1208
+ },
981
1209
  Date.now(),
982
1210
  )
983
1211
 
@@ -1017,11 +1245,15 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1017
1245
  additionalInstructionSections?: string[]
1018
1246
  extraMessages?: ChatMessage[]
1019
1247
  extraTools?: ToolSet
1248
+ filterTools?: (tools: ToolSet) => ToolSet
1249
+ includeExecutionPlanTools?: boolean
1250
+ metadataPatch?: NonNullable<MessageMetadata>
1020
1251
  }): Promise<ChatMessage> => {
1021
1252
  const visibleTimer = lotaDebugLogger.timer(`visible:${runParams.agentId}`)
1022
1253
  let runMemoryBlock = memoryBlock
1023
- const includeExecutionPlanTools = runParams.mode !== 'fixedWorkstreamMode' && !onboardingActive
1024
- const tools: ToolSet = {
1254
+ const includeExecutionPlanTools =
1255
+ runParams.includeExecutionPlanTools ?? (runParams.mode !== 'fixedWorkstreamMode' && !onboardingActive)
1256
+ const rawTools: ToolSet = {
1025
1257
  ...((await buildAgentTools({
1026
1258
  agentId: runParams.agentId,
1027
1259
  orgId: orgRef,
@@ -1049,6 +1281,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1049
1281
  ...toolProviders,
1050
1282
  ...runParams.extraTools,
1051
1283
  }
1284
+ const tools = runParams.filterTools ? runParams.filterTools(rawTools) : rawTools
1052
1285
  visibleTimer.step('build-agent-tools')
1053
1286
  streamCtx.memoryBlock = memoryBlock
1054
1287
  const responseMessage = await streamAgentResponse(streamCtx, {
@@ -1069,6 +1302,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1069
1302
  responseMessage,
1070
1303
  runParams.agentId,
1071
1304
  agentDisplayNames[runParams.agentId] ?? runParams.agentId,
1305
+ runParams.metadataPatch,
1072
1306
  )
1073
1307
  }
1074
1308
 
@@ -1078,181 +1312,214 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1078
1312
  return { inputMessageId: referenceUserMessage?.id, assistantMessages: [] }
1079
1313
  }
1080
1314
 
1081
- const consultSpecialistTool = createTool({
1082
- description: 'Consult one specialist teammate for domain-specific guidance before replying to the user.',
1083
- inputSchema: ConsultSpecialistArgsSchema,
1084
- execute: async function* (
1085
- { agentId, task }: z.infer<typeof ConsultSpecialistArgsSchema>,
1086
- { abortSignal: toolAbortSignal }: { abortSignal?: AbortSignal },
1087
- ) {
1088
- let specialistMemoryBlock = memoryBlock
1089
- const specialistTaskMessage = buildSpecialistTaskMessage({ agentId, task })
1090
- const specialistTools = await buildAgentTools({
1091
- agentId,
1092
- orgId: orgRef,
1093
- userId: userRef,
1094
- userName: userName ?? 'there',
1095
- workstreamId: workstreamRef,
1096
- orgIdString,
1097
- workstreamMode: workstream.mode,
1098
- mode: 'fixedWorkstreamMode',
1099
- linearInstalled,
1100
- onboardingActive,
1101
- githubInstalled,
1102
- provideRepoTool: indexedRepoContext.provideRepoTool,
1103
- defaultRepoSections: indexedRepoContext.defaultSectionsByAgent[agentId],
1104
- memoryBlock: specialistMemoryBlock,
1105
- onAppendMemoryBlock: (value: string) => {
1106
- specialistMemoryBlock = value
1107
- },
1108
- availableUploads: listReadableUploads([specialistTaskMessage]),
1109
- includeExecutionPlanTools: false,
1110
- context: buildContextResult,
1111
- })
1112
-
1113
- const [
1114
- specialistExecutionPlanInstructionSections,
1115
- specialistPreSeededMemories,
1116
- specialistWorkstreamState,
1117
- specialistLearnedSkills,
1118
- ] = await Promise.all([
1119
- getExecutionPlanInstructionSections(),
1120
- getPreSeededMemoriesSection(agentId),
1121
- getWorkstreamStateSection(),
1122
- getLearnedSkillsSection(agentId),
1123
- ])
1124
- const specialistConfig = getAgentRuntimeConfig({
1125
- agentId,
1126
- workstreamMode: workstream.mode,
1127
- mode: 'fixedWorkstreamMode',
1128
- onboardingActive,
1129
- linearInstalled,
1130
- systemWorkspaceDetails: promptContext.systemWorkspaceDetails,
1131
- preSeededMemoriesSection: specialistPreSeededMemories,
1132
- retrievedKnowledgeSection,
1133
- workstreamMemoryBlock: specialistMemoryBlock,
1134
- workstreamStateSection: specialistWorkstreamState,
1135
- learnedSkillsSection: specialistLearnedSkills,
1136
- additionalInstructionSections: mergeInstructionSections(
1137
- specialistExecutionPlanInstructionSections,
1138
- coreInstructionSections,
1139
- hookInstructionSections,
1140
- ),
1141
- context: buildContextResult,
1142
- }) as Record<string, unknown>
1143
- const observer = createObserver(agentId)
1144
- const agent = createAgent[specialistConfig.id as string]({
1145
- mode: 'fixedWorkstreamMode',
1146
- tools: { ...(specialistTools as ToolSet), ...toolProviders },
1147
- extraInstructions: specialistConfig.extraInstructions,
1148
- stopWhen: [stepCountIs(specialistConfig.maxSteps as number)],
1149
- }) as ToolLoopAgent<never, ToolSet>
1150
- const modelMessages = await convertToModelMessages(buildRunInputMessages([specialistTaskMessage]), {
1151
- ignoreIncompleteToolCalls: true,
1152
- })
1153
- const specialistAbortSignal = toolAbortSignal ?? runAbort.signal
1154
- let result: unknown
1155
- try {
1156
- result = await observer.run(() =>
1157
- agent.stream({ messages: modelMessages, abortSignal: specialistAbortSignal }),
1158
- )
1159
- } catch (error) {
1160
- if (specialistAbortSignal.aborted) {
1161
- observer.recordAbort(error)
1162
- } else {
1163
- observer.recordError(error)
1164
- }
1165
- throw error
1166
- }
1167
- if (!hasUIMessageStream(result)) {
1168
- throw new Error(`Specialist ${agentId} did not expose a UI message stream.`)
1169
- }
1170
-
1171
- let finalMessage: ChatMessage | null = null
1172
- for await (const message of readUIMessageStream<ChatMessage>({
1173
- stream: result.toUIMessageStream({
1174
- generateMessageId: () => Bun.randomUUIDv7(),
1175
- sendReasoning: true,
1176
- sendSources: true,
1177
- sendStart: false,
1178
- sendFinish: false,
1179
- messageMetadata: createAgentMessageMetadata({
1180
- agentId,
1181
- agentName: specialistConfig.displayName as string,
1182
- }),
1183
- }) as ReadableStream<never>,
1184
- })) {
1185
- finalMessage = withMessageCreatedAt(message, Date.now())
1186
- yield finalMessage
1187
- }
1188
-
1189
- if (!finalMessage) {
1190
- throw new Error(`Specialist ${agentId} did not produce a response message.`)
1191
- }
1192
-
1193
- for (const toolError of collectToolOutputErrors({ responseMessage: finalMessage })) {
1194
- aiLogger.warn`Tool execution failed (agent=${agentId}, tool=${toolError.toolName}, toolCallId=${toolError.toolCallId}): ${toolError.errorText}`
1195
- }
1196
-
1197
- memoryBlock = specialistMemoryBlock
1198
- return finalMessage
1199
- },
1200
- toModelOutput: ({ output }) => {
1201
- const result = getChatMessageFromToolOutput(output)
1202
- const agentName =
1203
- typeof result?.metadata?.agentName === 'string' && result.metadata.agentName.trim().length > 0
1204
- ? result.metadata.agentName.trim()
1205
- : 'Specialist'
1206
- const summary = result ? extractMessageText(result).trim() : ''
1207
- return {
1208
- type: 'text',
1209
- value: summary ? `${agentName}: ${summary}` : `${agentName} completed the requested task.`,
1210
- }
1211
- },
1212
- })
1315
+ if (params.kind === 'planTurn') {
1316
+ const planTurn = params.planTurn
1317
+ const submitPlanTurnNodeResultTool = createTool({
1318
+ description: buildPlanTurnSubmitToolDescription(planTurn),
1319
+ inputSchema: PlanNodeResultSubmissionSchema,
1320
+ execute: async (result) =>
1321
+ await executionPlanService.submitPlanTurnResult({
1322
+ workstreamId: workstreamRef,
1323
+ runId: planTurn.runId,
1324
+ nodeId: planTurn.nodeId,
1325
+ emittedBy: planTurn.nodeSpec.owner.ref,
1326
+ input: result,
1327
+ }),
1328
+ })
1213
1329
 
1214
- const teamThinkTool =
1215
- workstream.mode === 'group' && !onboardingActive
1216
- ? createTeamThinkTool({
1217
- historyMessages: currentMessages,
1218
- latestUserMessageId: referenceUserMessageId,
1330
+ await runVisibleAgent({
1331
+ agentId: planTurn.nodeSpec.owner.ref,
1332
+ mode: workstream.mode === 'direct' ? 'direct' : 'workstreamMode',
1333
+ additionalInstructionSections: mergeInstructionSections(
1334
+ [buildPlanTurnExecutionSection(planTurn), buildPlanTurnResultContractSection(planTurn)],
1335
+ optionalInstructionSection(buildUpstreamHandoffSection(planTurn.upstreamHandoffs)),
1336
+ ),
1337
+ extraMessages: [buildPlanTurnPromptMessage(planTurn)],
1338
+ includeExecutionPlanTools: false,
1339
+ extraTools: { [SUBMIT_PLAN_TURN_RESULT_TOOL_NAME]: submitPlanTurnNodeResultTool },
1340
+ filterTools: (tools) => applyPlanTurnToolPolicy(tools, planTurn.nodeSpec),
1341
+ metadataPatch: { trigger: 'plan-turn', planRunId: planTurn.runId, planNodeId: planTurn.nodeId },
1342
+ })
1343
+ } else {
1344
+ const consultSpecialistTool = createTool({
1345
+ description: 'Consult one specialist teammate for domain-specific guidance before replying to the user.',
1346
+ inputSchema: ConsultSpecialistArgsSchema,
1347
+ execute: async function* (
1348
+ { agentId, task }: z.infer<typeof ConsultSpecialistArgsSchema>,
1349
+ { abortSignal: toolAbortSignal }: { abortSignal?: AbortSignal },
1350
+ ) {
1351
+ let specialistMemoryBlock = memoryBlock
1352
+ const specialistTaskMessage = buildSpecialistTaskMessage({ agentId, task })
1353
+ const specialistTools = await buildAgentTools({
1354
+ agentId,
1219
1355
  orgId: orgRef,
1220
1356
  userId: userRef,
1357
+ userName: userName ?? 'there',
1221
1358
  workstreamId: workstreamRef,
1359
+ orgIdString,
1360
+ workstreamMode: workstream.mode,
1361
+ mode: 'fixedWorkstreamMode',
1362
+ linearInstalled,
1363
+ onboardingActive,
1222
1364
  githubInstalled,
1223
- availableUploads: listReadableUploads(),
1224
1365
  provideRepoTool: indexedRepoContext.provideRepoTool,
1225
- defaultRepoSectionsByAgent: indexedRepoContext.defaultSectionsByAgent as never,
1366
+ defaultRepoSections: indexedRepoContext.defaultSectionsByAgent[agentId],
1367
+ memoryBlock: specialistMemoryBlock,
1368
+ onAppendMemoryBlock: (value: string) => {
1369
+ specialistMemoryBlock = value
1370
+ },
1371
+ availableUploads: listReadableUploads([specialistTaskMessage]),
1372
+ includeExecutionPlanTools: false,
1373
+ context: buildContextResult,
1374
+ })
1375
+
1376
+ const [
1377
+ specialistExecutionPlanInstructionSections,
1378
+ specialistPreSeededMemories,
1379
+ specialistWorkstreamState,
1380
+ specialistLearnedSkills,
1381
+ ] = await Promise.all([
1382
+ getExecutionPlanInstructionSections(),
1383
+ getPreSeededMemoriesSection(agentId),
1384
+ getWorkstreamStateSection(),
1385
+ getLearnedSkillsSection(agentId),
1386
+ ])
1387
+ const specialistConfig = getAgentRuntimeConfig({
1388
+ agentId,
1389
+ workstreamMode: workstream.mode,
1390
+ mode: 'fixedWorkstreamMode',
1391
+ onboardingActive,
1392
+ linearInstalled,
1226
1393
  systemWorkspaceDetails: promptContext.systemWorkspaceDetails,
1227
- getPreSeededMemoriesSection,
1394
+ preSeededMemoriesSection: specialistPreSeededMemories,
1228
1395
  retrievedKnowledgeSection,
1396
+ workstreamMemoryBlock: specialistMemoryBlock,
1397
+ workstreamStateSection: specialistWorkstreamState,
1398
+ learnedSkillsSection: specialistLearnedSkills,
1229
1399
  additionalInstructionSections: mergeInstructionSections(
1400
+ specialistExecutionPlanInstructionSections,
1230
1401
  coreInstructionSections,
1231
1402
  hookInstructionSections,
1232
1403
  ),
1233
- getAdditionalInstructionSections: getExecutionPlanInstructionSections,
1234
1404
  context: buildContextResult,
1235
- toolProviders,
1236
- abortSignal: runAbort.signal,
1405
+ }) as Record<string, unknown>
1406
+ const observer = createObserver(agentId)
1407
+ const agent = createAgent[specialistConfig.id as string]({
1408
+ mode: 'fixedWorkstreamMode',
1409
+ tools: { ...(specialistTools as ToolSet), ...toolProviders },
1410
+ extraInstructions: specialistConfig.extraInstructions,
1411
+ stopWhen: [stepCountIs(specialistConfig.maxSteps as number)],
1412
+ }) as ToolLoopAgent<never, ToolSet>
1413
+ const modelMessages = await convertToModelMessages(buildRunInputMessages([specialistTaskMessage]), {
1414
+ ignoreIncompleteToolCalls: true,
1237
1415
  })
1238
- : null
1416
+ const specialistAbortSignal = toolAbortSignal ?? runAbort.signal
1417
+ let result: unknown
1418
+ try {
1419
+ result = await observer.run(() =>
1420
+ agent.stream({ messages: modelMessages, abortSignal: specialistAbortSignal }),
1421
+ )
1422
+ } catch (error) {
1423
+ if (specialistAbortSignal.aborted) {
1424
+ observer.recordAbort(error)
1425
+ } else {
1426
+ observer.recordError(error)
1427
+ }
1428
+ throw error
1429
+ }
1430
+ if (!hasUIMessageStream(result)) {
1431
+ throw new Error(`Specialist ${agentId} did not expose a UI message stream.`)
1432
+ }
1239
1433
 
1240
- if (workstream.mode === 'direct') {
1241
- if (!workstream.agentId) {
1242
- throw new WorkstreamTurnError('Direct workstreams require an assigned agent.', 400)
1243
- }
1244
- await runVisibleAgent({ agentId: workstream.agentId, mode: 'direct' })
1245
- } else {
1246
- await runVisibleAgent({
1247
- agentId: visibleWorkstreamAgentId ?? defaultLeadAgentId,
1248
- mode: 'workstreamMode',
1249
- skills: coreWorkstreamProfile?.skills ? [...coreWorkstreamProfile.skills] : undefined,
1250
- additionalInstructionSections: mergeInstructionSections(coreInstructionSections, hookInstructionSections),
1251
- extraTools: {
1252
- [CONSULT_SPECIALIST_TOOL_NAME]: consultSpecialistTool,
1253
- ...(teamThinkTool ? { [CONSULT_TEAM_TOOL_NAME]: teamThinkTool } : {}),
1434
+ let finalMessage: ChatMessage | null = null
1435
+ for await (const message of readUIMessageStream<ChatMessage>({
1436
+ stream: result.toUIMessageStream({
1437
+ generateMessageId: () => Bun.randomUUIDv7(),
1438
+ sendReasoning: true,
1439
+ sendSources: true,
1440
+ sendStart: false,
1441
+ sendFinish: false,
1442
+ messageMetadata: createAgentMessageMetadata({
1443
+ agentId,
1444
+ agentName: specialistConfig.displayName as string,
1445
+ }),
1446
+ }) as ReadableStream<never>,
1447
+ })) {
1448
+ finalMessage = withMessageCreatedAt(message, Date.now())
1449
+ yield finalMessage
1450
+ }
1451
+
1452
+ if (!finalMessage) {
1453
+ throw new Error(`Specialist ${agentId} did not produce a response message.`)
1454
+ }
1455
+
1456
+ for (const toolError of collectToolOutputErrors({ responseMessage: finalMessage })) {
1457
+ aiLogger.warn`Tool execution failed (agent=${agentId}, tool=${toolError.toolName}, toolCallId=${toolError.toolCallId}): ${toolError.errorText}`
1458
+ }
1459
+
1460
+ memoryBlock = specialistMemoryBlock
1461
+ return finalMessage
1462
+ },
1463
+ toModelOutput: ({ output }) => {
1464
+ const result = getChatMessageFromToolOutput(output)
1465
+ const agentName =
1466
+ typeof result?.metadata?.agentName === 'string' && result.metadata.agentName.trim().length > 0
1467
+ ? result.metadata.agentName.trim()
1468
+ : 'Specialist'
1469
+ const summary = result ? extractMessageText(result).trim() : ''
1470
+ return {
1471
+ type: 'text',
1472
+ value: summary ? `${agentName}: ${summary}` : `${agentName} completed the requested task.`,
1473
+ }
1254
1474
  },
1255
1475
  })
1476
+
1477
+ const teamThinkTool =
1478
+ workstream.mode === 'group' && !onboardingActive
1479
+ ? createTeamThinkTool({
1480
+ historyMessages: currentMessages,
1481
+ latestUserMessageId: referenceUserMessageId,
1482
+ orgId: orgRef,
1483
+ userId: userRef,
1484
+ workstreamId: workstreamRef,
1485
+ githubInstalled,
1486
+ availableUploads: listReadableUploads(),
1487
+ provideRepoTool: indexedRepoContext.provideRepoTool,
1488
+ defaultRepoSectionsByAgent: indexedRepoContext.defaultSectionsByAgent as never,
1489
+ systemWorkspaceDetails: promptContext.systemWorkspaceDetails,
1490
+ getPreSeededMemoriesSection,
1491
+ retrievedKnowledgeSection,
1492
+ additionalInstructionSections: mergeInstructionSections(
1493
+ coreInstructionSections,
1494
+ hookInstructionSections,
1495
+ ),
1496
+ getAdditionalInstructionSections: getExecutionPlanInstructionSections,
1497
+ context: buildContextResult,
1498
+ toolProviders,
1499
+ abortSignal: runAbort.signal,
1500
+ })
1501
+ : null
1502
+
1503
+ if (workstream.mode === 'direct') {
1504
+ if (!workstream.agentId) {
1505
+ throw new WorkstreamTurnError('Direct workstreams require an assigned agent.', 400)
1506
+ }
1507
+ await runVisibleAgent({ agentId: workstream.agentId, mode: 'direct' })
1508
+ } else {
1509
+ await runVisibleAgent({
1510
+ agentId: visibleWorkstreamAgentId ?? defaultLeadAgentId,
1511
+ mode: 'workstreamMode',
1512
+ skills: coreWorkstreamProfile?.skills ? [...coreWorkstreamProfile.skills] : undefined,
1513
+ additionalInstructionSections: mergeInstructionSections(
1514
+ coreInstructionSections,
1515
+ hookInstructionSections,
1516
+ ),
1517
+ extraTools: {
1518
+ [CONSULT_SPECIALIST_TOOL_NAME]: consultSpecialistTool,
1519
+ ...(teamThinkTool ? { [CONSULT_TEAM_TOOL_NAME]: teamThinkTool } : {}),
1520
+ },
1521
+ })
1522
+ }
1256
1523
  }
1257
1524
  } finally {
1258
1525
  try {
@@ -1312,7 +1579,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1312
1579
  })
1313
1580
  }
1314
1581
 
1315
- if (allAssistantMessages.length > 0) {
1582
+ if (allAssistantMessages.length > 0 && params.kind !== 'planTurn') {
1316
1583
  await turnHooks.afterTurn?.({
1317
1584
  workstream,
1318
1585
  workstreamRef,
@@ -1333,12 +1600,21 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1333
1600
  }
1334
1601
  }
1335
1602
 
1336
- const runResult = await executeRun()
1337
- if (runResult) {
1338
- return runResult
1339
- }
1603
+ try {
1604
+ return await workstreamService.withActiveRunLease(workstreamRef, async () => {
1605
+ const runResult = await executeRun()
1606
+ if (runResult) {
1607
+ return runResult
1608
+ }
1340
1609
 
1341
- return { inputMessageId: referenceUserMessage?.id, assistantMessages: [...allAssistantMessages] }
1610
+ return { inputMessageId: referenceUserMessage?.id, assistantMessages: [...allAssistantMessages] }
1611
+ })
1612
+ } catch (error) {
1613
+ if (error instanceof ActiveWorkstreamRunConflictError) {
1614
+ throw new WorkstreamTurnError(error.message, 409)
1615
+ }
1616
+ throw error
1617
+ }
1342
1618
  },
1343
1619
  }
1344
1620
  }