@lota-sdk/core 0.4.27 → 0.4.29

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.4.27",
3
+ "version": "0.4.29",
4
4
  "files": [
5
5
  "src",
6
6
  "infrastructure/schema"
@@ -32,7 +32,7 @@
32
32
  "@ai-sdk/provider": "^3.0.9",
33
33
  "@chat-adapter/slack": "^4.26.0",
34
34
  "@chat-adapter/state-ioredis": "^4.26.0",
35
- "@lota-sdk/shared": "0.4.27",
35
+ "@lota-sdk/shared": "0.4.29",
36
36
  "@mendable/firecrawl-js": "^4.20.0",
37
37
  "@surrealdb/node": "^3.0.3",
38
38
  "ai": "^6.0.170",
@@ -38,8 +38,8 @@ class AiGatewayStreamAttemptTag extends Context.Service<
38
38
 
39
39
  const EXPECTED_GATEWAY_KEY_PREFIX = 'sk-bf-'
40
40
  const AI_GATEWAY_VIRTUAL_KEY_HEADER = 'x-bf-vk'
41
- const AI_GATEWAY_TIMEOUT_MS = 180_000
42
- const AI_GATEWAY_STREAM_IDLE_TIMEOUT_MS = 30_000
41
+ const AI_GATEWAY_TIMEOUT_MS = 360_000
42
+ const AI_GATEWAY_STREAM_IDLE_TIMEOUT_MS = 180_000
43
43
  const AI_GATEWAY_MAX_RETRIES = 4
44
44
  const AI_GATEWAY_MAX_RETRY_DELAY_MS = 15_000
45
45
  const OPENAI_RESPONSES_PROVIDER_ID = 'openai.responses'
@@ -748,13 +748,60 @@ function isOpenRouterModel(modelId: string): boolean {
748
748
  return modelId.trim().toLowerCase().startsWith('openrouter/')
749
749
  }
750
750
 
751
+ function mergeAbortSignals(signals: Array<AbortSignal | undefined>): { signal?: AbortSignal; cleanup: () => void } {
752
+ const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal))
753
+ if (activeSignals.length === 0) return { cleanup: () => undefined }
754
+ if (activeSignals.length === 1) return { signal: activeSignals[0], cleanup: () => undefined }
755
+
756
+ const controller = new AbortController()
757
+ const listeners: Array<() => void> = []
758
+ const abortFrom = (signal: AbortSignal) => {
759
+ if (!controller.signal.aborted) controller.abort(signal.reason)
760
+ }
761
+
762
+ for (const signal of activeSignals) {
763
+ if (signal.aborted) {
764
+ abortFrom(signal)
765
+ continue
766
+ }
767
+
768
+ const listener = () => abortFrom(signal)
769
+ signal.addEventListener('abort', listener, { once: true })
770
+ listeners.push(() => signal.removeEventListener('abort', listener))
771
+ }
772
+
773
+ return {
774
+ signal: controller.signal,
775
+ cleanup: () => {
776
+ for (const cleanup of listeners) cleanup()
777
+ },
778
+ }
779
+ }
780
+
781
+ async function callAbortableProvider<T>(
782
+ params: AiGatewayCallOptions,
783
+ effectSignal: AbortSignal,
784
+ evaluate: (params: AiGatewayCallOptions) => PromiseLike<T>,
785
+ ): Promise<T> {
786
+ const { signal, cleanup } = mergeAbortSignals([effectSignal, params.abortSignal])
787
+ try {
788
+ return await evaluate(signal ? { ...params, abortSignal: signal } : params)
789
+ } finally {
790
+ cleanup()
791
+ }
792
+ }
793
+
751
794
  function attemptAiGatewayGenerate(
752
795
  source: string,
753
- evaluate: () => PromiseLike<AiGatewayGenerateResult>,
796
+ params: AiGatewayCallOptions,
797
+ evaluate: (params: AiGatewayCallOptions) => PromiseLike<AiGatewayGenerateResult>,
754
798
  ): Effect.Effect<AiGatewayAttemptResult<AiGatewayGenerateResult>, AiGenerationError> {
755
799
  return withAiGatewayResilience(
756
800
  source,
757
- Effect.tryPromise({ try: evaluate, catch: (cause) => classifyAiGatewayError(source, cause) }),
801
+ Effect.tryPromise({
802
+ try: (signal) => callAbortableProvider(params, signal, evaluate),
803
+ catch: (cause) => classifyAiGatewayError(source, cause),
804
+ }),
758
805
  ).pipe(
759
806
  Effect.map((result) => ({ source, result })),
760
807
  Effect.withSpan('AiGateway.generateAttempt'),
@@ -764,11 +811,15 @@ function attemptAiGatewayGenerate(
764
811
 
765
812
  function attemptAiGatewayStream(
766
813
  source: string,
767
- evaluate: () => PromiseLike<AiGatewayStreamResult>,
814
+ params: AiGatewayCallOptions,
815
+ evaluate: (params: AiGatewayCallOptions) => PromiseLike<AiGatewayStreamResult>,
768
816
  ): Effect.Effect<AiGatewayAttemptResult<AiGatewayStreamResult>, AiGenerationError> {
769
817
  return withAiGatewayResilience(
770
818
  source,
771
- Effect.tryPromise({ try: evaluate, catch: (cause) => classifyAiGatewayError(source, cause) }),
819
+ Effect.tryPromise({
820
+ try: (signal) => callAbortableProvider(params, signal, evaluate),
821
+ catch: (cause) => classifyAiGatewayError(source, cause),
822
+ }),
772
823
  ).pipe(
773
824
  Effect.map((result) => ({ source, result })),
774
825
  Effect.withSpan('AiGateway.streamAttempt'),
@@ -778,10 +829,11 @@ function attemptAiGatewayStream(
778
829
 
779
830
  function executeGenerateAttemptPlan(
780
831
  modelId: string,
781
- doGenerate: () => PromiseLike<AiGatewayGenerateResult>,
832
+ params: AiGatewayCallOptions,
833
+ doGenerate: (params: AiGatewayCallOptions) => PromiseLike<AiGatewayGenerateResult>,
782
834
  ): Effect.Effect<AiGatewayAttemptResult<AiGatewayGenerateResult>, AiGenerationError> {
783
835
  const primary = Layer.succeed(AiGatewayGenerateAttemptTag, {
784
- execute: attemptAiGatewayGenerate('ai-gateway.generate', doGenerate),
836
+ execute: attemptAiGatewayGenerate('ai-gateway.generate', params, doGenerate),
785
837
  })
786
838
  const effect = Effect.gen(function* () {
787
839
  const attempt = yield* AiGatewayGenerateAttemptTag
@@ -797,10 +849,11 @@ function executeGenerateAttemptPlan(
797
849
 
798
850
  function executeStreamAttemptPlan(
799
851
  modelId: string,
800
- doStream: () => PromiseLike<AiGatewayStreamResult>,
852
+ params: AiGatewayCallOptions,
853
+ doStream: (params: AiGatewayCallOptions) => PromiseLike<AiGatewayStreamResult>,
801
854
  ): Effect.Effect<AiGatewayAttemptResult<AiGatewayStreamResult>, AiGenerationError> {
802
855
  const primary = Layer.succeed(AiGatewayStreamAttemptTag, {
803
- execute: attemptAiGatewayStream('ai-gateway.stream', doStream),
856
+ execute: attemptAiGatewayStream('ai-gateway.stream', params, doStream),
804
857
  })
805
858
  const effect = Effect.gen(function* () {
806
859
  const attempt = yield* AiGatewayStreamAttemptTag
@@ -1046,7 +1099,7 @@ function createAiGatewayLanguageModelMiddleware(
1046
1099
  const model = resolveProviderModel(resolvedDeps.gateway.provider, modelId, providerId)
1047
1100
  return resolvedDeps.runPromise(
1048
1101
  withAiGatewayConcurrency(
1049
- executeGenerateAttemptPlan(modelId, () => model.doGenerate(params)).pipe(
1102
+ executeGenerateAttemptPlan(modelId, params, (attemptParams) => model.doGenerate(attemptParams)).pipe(
1050
1103
  Effect.map(({ result }) => ({
1051
1104
  ...result,
1052
1105
  content: injectAiGatewayChatReasoningContent(
@@ -1063,7 +1116,7 @@ function createAiGatewayLanguageModelMiddleware(
1063
1116
  const model = resolveProviderModel(resolvedDeps.gateway.provider, modelId, providerId)
1064
1117
  return resolvedDeps.runPromise(
1065
1118
  withAiGatewayStreamConcurrency(
1066
- executeStreamAttemptPlan(modelId, () => model.doStream(params)).pipe(
1119
+ executeStreamAttemptPlan(modelId, params, (attemptParams) => model.doStream(attemptParams)).pipe(
1067
1120
  Effect.map((attempt) => ({
1068
1121
  ...attempt,
1069
1122
  result: isReasoningEnabled(params)
@@ -66,11 +66,13 @@ export function createDocumentProcessorQueueRuntime<TJob extends DocumentProcess
66
66
  queueName?: string
67
67
  workerName?: string
68
68
  concurrency?: number
69
+ attempts?: number
69
70
  lockDuration?: number
70
71
  }): { enqueue: (job: TJob) => Promise<void>; startWorker: (options?: { registerSignals?: boolean }) => WorkerHandle } {
71
72
  const queueName = params.queueName ?? DEFAULT_DOCUMENT_PROCESSOR_QUEUE
72
73
  const workerName = params.workerName ?? DEFAULT_WORKER_NAME
73
74
  const concurrency = params.concurrency ?? 10
75
+ const attempts = params.attempts ?? 3
74
76
  const lockDuration = params.lockDuration ?? 300_000
75
77
  const queueRuntime = createQueueFactory<TJob>({
76
78
  name: queueName,
@@ -80,7 +82,7 @@ export function createDocumentProcessorQueueRuntime<TJob extends DocumentProcess
80
82
  lockDuration,
81
83
  logger: params.logger,
82
84
  connectionProvider: params.getConnectionForBullMQ,
83
- defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
85
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts, backoff: { type: 'exponential', delay: 1000 } },
84
86
  processorPath: params.getWorkerPath(),
85
87
  queueJobService: params.queueJobService,
86
88
  })
@@ -9,6 +9,7 @@ import { ERROR_TAGS } from '../effect/errors'
9
9
  import type { TrackedBullJobLike } from '../services/queue-job.service'
10
10
  import {
11
11
  attachWorkerEvents,
12
+ attachSandboxChildRecycling,
12
13
  createTracedWorkerProcessor,
13
14
  createWorkerShutdown,
14
15
  DEFAULT_JOB_RETENTION,
@@ -42,6 +43,7 @@ interface QueueFactoryConfigBase {
42
43
  stalledInterval?: number
43
44
  maxStalledCount?: number
44
45
  defaultJobOptions?: JobsOptions
46
+ recycleSandboxChildren?: boolean
45
47
  connectionProvider: () => IORedis
46
48
  queueJobService: QueueJobService
47
49
  }
@@ -280,6 +282,9 @@ function createQueueFactoryRuntime<TJob>(config: QueueFactoryConfigBase): {
280
282
  // leaks.
281
283
  try {
282
284
  attachWorkerEvents(worker, config.displayName, logger)
285
+ if ((config.recycleSandboxChildren ?? true) && workerConfig.processorPath) {
286
+ attachSandboxChildRecycling(worker, config.displayName, logger)
287
+ }
283
288
  const shutdown = createWorkerShutdown(worker, config.displayName, logger)
284
289
 
285
290
  if (registerSignals) {
@@ -4,7 +4,7 @@ import { AiGatewayModelsTag, isAiGenerationContentFilterError } from '../ai-gate
4
4
  import type { AiGatewayModels } from '../ai-gateway/ai-gateway'
5
5
  import type { ResolvedAgentConfig } from '../config/agent-defaults'
6
6
  import { chatLogger } from '../config/logger'
7
- import { ERROR_TAGS, ServiceError } from '../effect/errors'
7
+ import { ServiceError } from '../effect/errors'
8
8
  import { AgentConfigServiceTag } from '../effect/services'
9
9
  import type { HelperModelRuntime } from '../runtime/helper-model'
10
10
  import { HelperModelTag } from '../runtime/helper-model'
@@ -13,10 +13,19 @@ import {
13
13
  makeRecentActivityTitleRefinerAgentFactory,
14
14
  RECENT_ACTIVITY_TITLE_REFINER_PROMPT,
15
15
  } from '../system-agents/recent-activity-title-refiner.agent'
16
+ import { compactWhitespace, truncateText } from '../utils/string'
16
17
  import type { makeRecentActivityService } from './recent-activity.service'
17
18
  import { RecentActivityServiceTag } from './recent-activity.service'
18
19
 
19
20
  const RECENT_ACTIVITY_TITLE_TIMEOUT_MS = 60_000
21
+ const RECENT_ACTIVITY_TITLE_FIELD_MAX_CHARS = 800
22
+
23
+ function formatPromptField(label: string, value: string | undefined): string | null {
24
+ if (!value) return null
25
+ const normalized = compactWhitespace(value)
26
+ if (!normalized) return null
27
+ return `${label}=${truncateText(normalized, RECENT_ACTIVITY_TITLE_FIELD_MAX_CHARS)}`
28
+ }
20
29
 
21
30
  function buildRefinementPromptInput(
22
31
  candidate: {
@@ -32,12 +41,12 @@ function buildRefinementPromptInput(
32
41
 
33
42
  const metadata = candidate.metadata
34
43
  const lines = [
35
- `sourceLabel=${candidate.sourceLabel}`,
36
- `systemTitle=${candidate.systemTitle}`,
37
- metadata.agentName ? `agentName=${metadata.agentName}` : null,
38
- metadata.threadTitle ? `threadTitle=${metadata.threadTitle}` : null,
39
- metadata.userMessageText ? `userMessage=${metadata.userMessageText}` : null,
40
- metadata.assistantSummary ? `assistantSummary=${metadata.assistantSummary}` : null,
44
+ formatPromptField('sourceLabel', candidate.sourceLabel),
45
+ formatPromptField('systemTitle', candidate.systemTitle),
46
+ formatPromptField('agentName', metadata.agentName),
47
+ formatPromptField('threadTitle', metadata.threadTitle),
48
+ formatPromptField('userMessage', metadata.userMessageText),
49
+ formatPromptField('assistantSummary', metadata.assistantSummary),
41
50
  ].filter((line): line is string => Boolean(line))
42
51
 
43
52
  if (lines.length === 0) return null
@@ -77,13 +86,12 @@ export function makeRecentActivityTitleService(
77
86
  ? cause
78
87
  : new ServiceError({ message: 'Failed to generate recent activity title refinement.', cause }),
79
88
  }).pipe(
80
- Effect.catchTag(ERROR_TAGS.AiGenerationError, (error) =>
81
- isAiGenerationContentFilterError(error)
82
- ? Effect.sync(() => {
83
- chatLogger.warn`Skipping recent activity title refinement after provider content filter (activityId=${activityId})`
84
- return null
85
- })
86
- : Effect.fail(error),
89
+ Effect.catch((error) =>
90
+ Effect.sync(() => {
91
+ const reason = isAiGenerationContentFilterError(error) ? 'provider content filter' : 'non-fatal error'
92
+ chatLogger.warn`Skipping recent activity title refinement after ${reason} (activityId=${activityId}): ${error}`
93
+ return null
94
+ }),
87
95
  ),
88
96
  )
89
97
  if (maybeRefinedTitle === null) {
@@ -1,3 +1,4 @@
1
+ import { setTimeout as delay } from 'node:timers/promises'
1
2
  import { fileURLToPath } from 'node:url'
2
3
 
3
4
  import type { Job, Worker } from 'bullmq'
@@ -30,6 +31,32 @@ export interface WorkerHandle {
30
31
  shutdown: () => Promise<void>
31
32
  }
32
33
 
34
+ async function closeWorkerWithTimeout(
35
+ worker: Worker,
36
+ name: string,
37
+ logger: typeof chatLogger,
38
+ timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS,
39
+ ): Promise<void> {
40
+ const gracefulClose = worker.close(false)
41
+ const gracefulOutcome = gracefulClose.then(
42
+ () => ({ status: 'closed' as const }),
43
+ (error: unknown) => ({ status: 'failed' as const, error }),
44
+ )
45
+
46
+ const outcome = await Promise.race([gracefulOutcome, delay(timeoutMs).then(() => ({ status: 'timed_out' as const }))])
47
+
48
+ if (outcome.status === 'closed') return
49
+
50
+ if (outcome.status === 'timed_out') {
51
+ logger.warn`${name} worker did not close within ${timeoutMs}ms; force-closing`
52
+ } else {
53
+ logger.warn`${name} worker graceful close failed; force-closing: ${outcome.error}`
54
+ }
55
+
56
+ await worker.close(true)
57
+ if (outcome.status === 'failed') throw outcome.error
58
+ }
59
+
33
60
  interface TracedWorkerJobLike {
34
61
  id?: unknown
35
62
  name: string
@@ -87,7 +114,7 @@ export const createWorkerShutdown = (worker: Worker, name: string, logger: typeo
87
114
  return Effect.runPromise(
88
115
  Effect.asVoid(
89
116
  Effect.tryPromise({
90
- try: () => worker.close(true),
117
+ try: () => closeWorkerWithTimeout(worker, name, logger),
91
118
  catch: (cause) => new QueueWorkerError({ phase: 'close', cause }),
92
119
  }),
93
120
  ),
@@ -95,6 +122,60 @@ export const createWorkerShutdown = (worker: Worker, name: string, logger: typeo
95
122
  }
96
123
  }
97
124
 
125
+ interface SandboxChildLike {
126
+ pid?: number
127
+ }
128
+
129
+ interface SandboxChildPoolLike {
130
+ getAllFree?: () => SandboxChildLike[]
131
+ kill?: (child: SandboxChildLike, signal?: NodeJS.Signals) => Promise<void>
132
+ }
133
+
134
+ interface SandboxedWorkerLike {
135
+ childPool?: SandboxChildPoolLike
136
+ }
137
+
138
+ function getSandboxChildPool(worker: Worker): SandboxChildPoolLike | null {
139
+ const pool = (worker as unknown as SandboxedWorkerLike).childPool
140
+ return pool && typeof pool.getAllFree === 'function' && typeof pool.kill === 'function' ? pool : null
141
+ }
142
+
143
+ export function recycleIdleSandboxChildren(
144
+ worker: Worker,
145
+ name: string,
146
+ logger: typeof chatLogger = chatLogger,
147
+ ): Promise<void> {
148
+ const pool = getSandboxChildPool(worker)
149
+ if (!pool) return Promise.resolve()
150
+
151
+ const idleChildren = pool.getAllFree?.() ?? []
152
+ if (idleChildren.length === 0) return Promise.resolve()
153
+
154
+ return Promise.all(
155
+ idleChildren.map((child) =>
156
+ (pool.kill?.(child, 'SIGTERM') ?? Promise.resolve()).catch((error: unknown) => {
157
+ logger.warn`Failed to recycle idle ${name} sandbox child (${child.pid ?? 'unknown'}): ${error}`
158
+ }),
159
+ ),
160
+ ).then(() => undefined)
161
+ }
162
+
163
+ export function attachSandboxChildRecycling(
164
+ worker: Worker,
165
+ name: string,
166
+ logger: typeof chatLogger = chatLogger,
167
+ ): void {
168
+ const recycle = () => {
169
+ // @effect-diagnostics-next-line globalTimers:off -- BullMQ worker event callback; defer until BullMQ releases the child.
170
+ setTimeout(() => {
171
+ void recycleIdleSandboxChildren(worker, name, logger)
172
+ }, 0)
173
+ }
174
+
175
+ worker.on('completed', recycle)
176
+ worker.on('failed', recycle)
177
+ }
178
+
98
179
  export function createTracedWorkerProcessor<TJob extends TracedWorkerJobLike, TResult = void>(
99
180
  queueName: string,
100
181
  processor: (job: TJob) => Promise<TResult>,