@lota-sdk/core 0.1.47 → 0.2.0

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.47",
3
+ "version": "0.2.0",
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.47",
35
+ "@lota-sdk/shared": "0.2.0",
36
36
  "@mendable/firecrawl-js": "^4.18.0",
37
37
  "@surrealdb/node": "^3.0.3",
38
38
  "ai": "^6.0.141",
@@ -50,22 +50,38 @@ export function createAgentMessageMetadata(params: {
50
50
  }
51
51
  }
52
52
 
53
- export function createServerRunAbortController(externalAbortSignal?: AbortSignal) {
53
+ function toExternalAbortSignals(externalAbortSignal?: AbortSignal | readonly AbortSignal[]): AbortSignal[] {
54
+ if (!externalAbortSignal) return []
55
+
56
+ const signals = Array.isArray(externalAbortSignal) ? externalAbortSignal : [externalAbortSignal]
57
+ return [...new Set<AbortSignal>(signals)]
58
+ }
59
+
60
+ export function createServerRunAbortController(externalAbortSignal?: AbortSignal | readonly AbortSignal[]) {
54
61
  const controller = new AbortController()
55
62
  const abort = (reason?: unknown) => {
56
63
  if (controller.signal.aborted) return
57
64
  controller.abort(reason ?? new DOMException('Run stopped by user.', 'AbortError'))
58
65
  }
59
66
 
60
- const abortFromExternal = () => {
61
- abort((externalAbortSignal as (AbortSignal & { reason?: unknown }) | undefined)?.reason)
62
- }
67
+ const externalSignals = toExternalAbortSignals(externalAbortSignal)
68
+ const listeners = externalSignals.map((signal) => {
69
+ const abortFromExternal = () => {
70
+ abort((signal as AbortSignal & { reason?: unknown }).reason)
71
+ }
63
72
 
64
- if (externalAbortSignal) {
65
- if (externalAbortSignal.aborted) {
73
+ if (signal.aborted) {
66
74
  abortFromExternal()
67
75
  } else {
68
- externalAbortSignal.addEventListener('abort', abortFromExternal, { once: true })
76
+ signal.addEventListener('abort', abortFromExternal, { once: true })
77
+ }
78
+
79
+ return { signal, abortFromExternal }
80
+ })
81
+
82
+ if (controller.signal.aborted) {
83
+ for (const { signal, abortFromExternal } of listeners) {
84
+ signal.removeEventListener('abort', abortFromExternal)
69
85
  }
70
86
  }
71
87
 
@@ -74,8 +90,9 @@ export function createServerRunAbortController(externalAbortSignal?: AbortSignal
74
90
  signal: controller.signal,
75
91
  abort,
76
92
  dispose: () => {
77
- if (!externalAbortSignal) return
78
- externalAbortSignal.removeEventListener('abort', abortFromExternal)
93
+ for (const { signal, abortFromExternal } of listeners) {
94
+ signal.removeEventListener('abort', abortFromExternal)
95
+ }
79
96
  },
80
97
  }
81
98
  }
@@ -4,10 +4,11 @@ type ExecutionPlanPromptSummary = Pick<SerializableExecutionPlan, 'runId' | 'tit
4
4
 
5
5
  const EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT = `<execution-plan-protocol>
6
6
  - Create execution plans for multi-step work. Review existing plans before creating new ones.
7
+ - The active execution runs in <execution-plan-state> are a summary inventory only. They list runId and title, not node-level state.
7
8
  - The runtime executor owns lifecycle truth. Do not claim node completion until the executor confirms.
8
- - Work only on active/ready nodes assigned to you. Stop at human gates.
9
+ - Do not invent or restate run/node lifecycle details that are not present in the prompt or tool results.
9
10
  - During plan-triggered turns, use the dedicated result-submission tool. Include handoffContext.
10
- - Treat the active execution runs in <execution-plan-state> as authoritative. Do not mutate run or node status in prose.
11
+ - Treat the active execution runs in <execution-plan-state> as authoritative for whether a plan already exists.
11
12
  - If contracts or criteria materially change, replace the plan.
12
13
  </execution-plan-protocol>`
13
14
 
@@ -734,10 +734,16 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
734
734
  return {
735
735
  originalMessages,
736
736
  run: async (writer?: UIMessageStreamWriter<ChatMessage>) => {
737
- const executeRun = async (): Promise<PreparedWorkstreamTurnResult | void> => {
737
+ const executeRun = async (leaseAbortSignal?: AbortSignal): Promise<PreparedWorkstreamTurnResult | void> => {
738
738
  const runTimer = lotaDebugLogger.timer('run')
739
739
  const serverRunId = Bun.randomUUIDv7()
740
- const runAbort = createServerRunAbortController(params.abortSignal)
740
+ const runAbortSignals = leaseAbortSignal ? [params.abortSignal, leaseAbortSignal] : [params.abortSignal]
741
+ const runAbort = createServerRunAbortController(
742
+ runAbortSignals.filter((signal): signal is AbortSignal => Boolean(signal)),
743
+ )
744
+ if (runAbort.signal.aborted) {
745
+ throw runAbort.signal.reason ?? new DOMException('The operation was aborted.', 'AbortError')
746
+ }
741
747
  // Plan turns run without the chat lease — don't claim the active run slot.
742
748
  if (params.kind !== 'planTurn') {
743
749
  await workstreamService.setActiveTurn(workstreamRef, serverRunId, params.streamId ?? null)
@@ -746,6 +752,11 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
746
752
  runTimer.step('set-active-run+stream')
747
753
 
748
754
  try {
755
+ const throwIfRunAborted = () => {
756
+ if (!runAbort.signal.aborted) return
757
+ throw runAbort.signal.reason ?? new DOMException('The operation was aborted.', 'AbortError')
758
+ }
759
+
749
760
  const buildAgentMetadataPatch = (agentId: string, agentName: string): NonNullable<MessageMetadata> => ({
750
761
  agentId,
751
762
  agentName,
@@ -768,6 +779,8 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
768
779
  agentName: string,
769
780
  metadataPatch?: NonNullable<MessageMetadata>,
770
781
  ) => {
782
+ throwIfRunAborted()
783
+
771
784
  const committed = withMessageCreatedAt(
772
785
  {
773
786
  ...response,
@@ -779,6 +792,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
779
792
  await workstreamMessageService.upsertMessages({ workstreamId: workstreamRef, messages: [committed] })
780
793
  currentMessages = upsertChatHistoryMessage(currentMessages, committed)
781
794
  allAssistantMessages = upsertChatHistoryMessage(allAssistantMessages, committed)
795
+ throwIfRunAborted()
782
796
 
783
797
  return committed
784
798
  }
@@ -840,6 +854,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
840
854
  const tools = runParams.filterTools ? runParams.filterTools(rawTools) : rawTools
841
855
  visibleTimer.step('build-agent-tools')
842
856
  streamCtx.memoryBlock = memoryBlock
857
+ throwIfRunAborted()
843
858
  const responseMessage = await streamAgentResponse(streamCtx, {
844
859
  agentId: runParams.agentId,
845
860
  mode: runParams.mode,
@@ -899,6 +914,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
899
914
  const wsMembers = (workstream as { members?: string[] }).members ?? []
900
915
  const members = wsMembers.length > 0 ? wsMembers : [...agentRoster]
901
916
  const fallbackAgentId = coreWorkstreamProfile?.config.agentId ?? defaultLeadAgentId
917
+ throwIfRunAborted()
902
918
  writeMultiAgentEvent(writer, { phase: 'routing', note: 'Routing this turn to the right agent.' })
903
919
 
904
920
  const recentContext = currentMessages
@@ -912,6 +928,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
912
928
  messageText,
913
929
  recentContext,
914
930
  })
931
+ throwIfRunAborted()
915
932
 
916
933
  const runGroupAgent = async (
917
934
  agentId: string,
@@ -938,6 +955,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
938
955
  if (!triageResult) {
939
956
  // No specialist match — fallback to owner (core) or chief (non-core), single visible turn.
940
957
  await runGroupAgent(fallbackAgentId)
958
+ throwIfRunAborted()
941
959
  writeMultiAgentEvent(writer, { phase: 'complete' })
942
960
  } else {
943
961
  const respondedAgents: string[] = []
@@ -945,6 +963,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
945
963
  routingContext: triageResult.routingContext,
946
964
  })
947
965
  respondedAgents.push(triageResult.agentId)
966
+ throwIfRunAborted()
948
967
 
949
968
  // Follow-up specialists run headless, persist their own messages,
950
969
  // and are surfaced by transient events plus cache refresh.
@@ -957,6 +976,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
957
976
  respondedAgents,
958
977
  lastResponseSummary: lastResponseText,
959
978
  })
979
+ throwIfRunAborted()
960
980
 
961
981
  if (checkResult.done || !checkResult.agentId) break
962
982
 
@@ -979,17 +999,20 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
979
999
  ],
980
1000
  metadata: { hidden: true, createdAt: Date.now() } as MessageMetadata,
981
1001
  }
1002
+ throwIfRunAborted()
982
1003
  await workstreamMessageService.upsertMessages({
983
1004
  workstreamId: workstreamRef,
984
1005
  messages: [bridgeMessage],
985
1006
  })
986
1007
  currentMessages = upsertChatHistoryMessage(currentMessages, bridgeMessage)
1008
+ throwIfRunAborted()
987
1009
 
988
1010
  lastResponse = await runGroupAgent(checkResult.agentId, {
989
1011
  routingContext: checkResult.routingContext,
990
1012
  headless: true,
991
1013
  })
992
1014
  respondedAgents.push(checkResult.agentId)
1015
+ throwIfRunAborted()
993
1016
  writeMultiAgentEvent(writer, {
994
1017
  phase: 'agent-message-persisted',
995
1018
  agentId: checkResult.agentId,
@@ -998,6 +1021,7 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
998
1021
  })
999
1022
  }
1000
1023
 
1024
+ throwIfRunAborted()
1001
1025
  writeMultiAgentEvent(writer, { phase: 'complete' })
1002
1026
  }
1003
1027
  }
@@ -1093,8 +1117,8 @@ export async function prepareWorkstreamRunCore(params: WorkstreamRunCoreParams):
1093
1117
  }
1094
1118
 
1095
1119
  try {
1096
- return await workstreamService.withActiveRunLease(workstreamRef, async () => {
1097
- const runResult = await executeRun()
1120
+ return await workstreamService.withActiveRunLease(workstreamRef, async (leaseAbortSignal) => {
1121
+ const runResult = await executeRun(leaseAbortSignal)
1098
1122
  if (runResult) {
1099
1123
  return runResult
1100
1124
  }
@@ -3,7 +3,13 @@ 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, routerModelId } from '../config/agent-defaults'
6
+ import {
7
+ agentDescriptions,
8
+ agentDisplayNames,
9
+ agentShortDisplayNames,
10
+ resolveAgentNameAlias,
11
+ routerModelId,
12
+ } from '../config/agent-defaults'
7
13
 
8
14
  // ---------------------------------------------------------------------------
9
15
  // Schemas
@@ -34,6 +40,60 @@ function buildMembersDescription(members: readonly string[]): string {
34
40
  .join('\n')
35
41
  }
36
42
 
43
+ function escapeRegex(value: string): string {
44
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
45
+ }
46
+
47
+ function buildExplicitAgentRoutingContext(agentId: string): string {
48
+ const displayName = agentDisplayNames[agentId] ?? agentId
49
+ return `Respond directly to the part of the user's request explicitly addressed to ${displayName}.`
50
+ }
51
+
52
+ function extractExplicitAgentTargets(messageText: string, members: readonly string[]): string[] {
53
+ const normalizedMessage = messageText.trim()
54
+ if (!normalizedMessage) return []
55
+
56
+ const memberSet = new Set(members)
57
+ const aliases = new Map<string, string>()
58
+
59
+ for (const member of members) {
60
+ for (const rawAlias of [member, agentDisplayNames[member], agentShortDisplayNames[member]]) {
61
+ if (typeof rawAlias !== 'string') continue
62
+ const alias = rawAlias.trim().toLowerCase()
63
+ if (!alias) continue
64
+ aliases.set(alias, member)
65
+ }
66
+ }
67
+
68
+ if (aliases.size === 0) return []
69
+
70
+ const orderedAliases = [...aliases.keys()].sort((left, right) => right.length - left.length)
71
+ const matches: Array<{ agentId: string; index: number }> = []
72
+ const directAddressRegex = new RegExp(
73
+ `(^|[^\\w@])(?<alias>${orderedAliases.map(escapeRegex).join('|')})(?=\\s*(?::|\\-|—))`,
74
+ 'gi',
75
+ )
76
+
77
+ for (const match of normalizedMessage.matchAll(directAddressRegex)) {
78
+ const alias = match.groups?.alias
79
+ const agentId = alias ? resolveAgentNameAlias(alias) : undefined
80
+ if (!agentId || !memberSet.has(agentId)) continue
81
+
82
+ const prefix = match[1]
83
+ const index = match.index + prefix.length
84
+ matches.push({ agentId, index })
85
+ }
86
+
87
+ const seenAgents = new Set<string>()
88
+ return matches
89
+ .sort((left, right) => left.index - right.index)
90
+ .flatMap(({ agentId }) => {
91
+ if (seenAgents.has(agentId)) return []
92
+ seenAgents.add(agentId)
93
+ return [agentId]
94
+ })
95
+ }
96
+
37
97
  function extractJson(text: string): unknown {
38
98
  const match = text.match(/\{[\s\S]*\}/)
39
99
  if (!match) return null
@@ -53,7 +113,13 @@ function extractResultText(result: { text?: string; reasoning?: unknown }): stri
53
113
  if (typeof reasoning === 'string') return reasoning
54
114
  if (Array.isArray(reasoning)) {
55
115
  return reasoning
56
- .map((r) => (typeof r === 'string' ? r : typeof r === 'object' && r && 'text' in r ? String(r.text) : ''))
116
+ .map((r) => {
117
+ if (typeof r === 'string') return r
118
+ if (typeof r !== 'object' || r === null || !('text' in r)) return ''
119
+
120
+ const text = (r as { text?: unknown }).text
121
+ return typeof text === 'string' ? text : ''
122
+ })
57
123
  .join('')
58
124
  }
59
125
  return ''
@@ -112,6 +178,12 @@ export async function triageWorkstreamMessage(params: {
112
178
  messageText: string
113
179
  recentContext?: string
114
180
  }): Promise<RouterTriageResult | null> {
181
+ const explicitTargets = extractExplicitAgentTargets(params.messageText, params.members)
182
+ const firstExplicitTarget = explicitTargets[0]
183
+ if (firstExplicitTarget) {
184
+ return { agentId: firstExplicitTarget, routingContext: buildExplicitAgentRoutingContext(firstExplicitTarget) }
185
+ }
186
+
115
187
  const membersDesc = buildMembersDescription(params.members)
116
188
  const prompt = [
117
189
  `Workstream: "${params.workstreamTitle}"`,
@@ -159,6 +231,16 @@ export async function checkForNextAgent(params: {
159
231
  respondedAgents: string[]
160
232
  lastResponseSummary: string
161
233
  }): Promise<RouterCheckResult> {
234
+ const explicitTargets = extractExplicitAgentTargets(params.messageText, params.members)
235
+ const nextExplicitTarget = explicitTargets.find((agentId) => !params.respondedAgents.includes(agentId))
236
+ if (nextExplicitTarget) {
237
+ return {
238
+ done: false,
239
+ agentId: nextExplicitTarget,
240
+ routingContext: buildExplicitAgentRoutingContext(nextExplicitTarget),
241
+ }
242
+ }
243
+
162
244
  const remainingMembers = params.members.filter((id) => !params.respondedAgents.includes(id))
163
245
  if (remainingMembers.length === 0) return { done: true }
164
246