@lota-sdk/core 0.1.14 → 0.1.15

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 (95) hide show
  1. package/package.json +5 -5
  2. package/src/ai/embedding-cache.ts +7 -6
  3. package/src/ai/index.ts +1 -0
  4. package/src/bifrost/bifrost.ts +12 -7
  5. package/src/config/agent-defaults.ts +1 -1
  6. package/src/config/logger.ts +7 -9
  7. package/src/{runtime.ts → create-runtime.ts} +6 -6
  8. package/src/db/cursor-pagination.ts +1 -1
  9. package/src/db/memory-store.ts +10 -6
  10. package/src/db/memory.ts +6 -4
  11. package/src/db/schema-fingerprint.ts +1 -0
  12. package/src/db/service.ts +40 -43
  13. package/src/db/startup.ts +3 -3
  14. package/src/index.ts +1 -1
  15. package/src/queues/context-compaction.queue.ts +4 -8
  16. package/src/queues/document-processor.queue.ts +7 -7
  17. package/src/queues/memory-consolidation.queue.ts +7 -8
  18. package/src/queues/post-chat-memory.queue.ts +2 -6
  19. package/src/queues/recent-activity-title-refinement.queue.ts +2 -6
  20. package/src/queues/regular-chat-memory-digest.queue.ts +4 -7
  21. package/src/queues/skill-extraction.queue.ts +4 -7
  22. package/src/queues/workstream-title-generation.queue.ts +2 -6
  23. package/src/redis/connection.ts +6 -3
  24. package/src/redis/index.ts +1 -0
  25. package/src/redis/org-memory-lock.ts +1 -1
  26. package/src/redis/redis-lease-lock.ts +41 -8
  27. package/src/runtime/agent-stream-helpers.ts +2 -1
  28. package/src/runtime/context-compaction-constants.ts +1 -1
  29. package/src/runtime/context-compaction-runtime.ts +6 -4
  30. package/src/runtime/context-compaction.ts +19 -38
  31. package/src/runtime/execution-plan.ts +2 -2
  32. package/src/runtime/helper-model.ts +3 -1
  33. package/src/runtime/index.ts +12 -1
  34. package/src/runtime/memory-block.ts +3 -2
  35. package/src/runtime/memory-pipeline.ts +24 -5
  36. package/src/runtime/plugin-types.ts +1 -1
  37. package/src/runtime/runtime-extensions.ts +89 -13
  38. package/src/runtime/title-helpers.ts +11 -2
  39. package/src/runtime/workstream-chat-helpers.ts +5 -6
  40. package/src/runtime/workstream-routing-policy.ts +0 -30
  41. package/src/runtime/workstream-state.ts +17 -7
  42. package/src/services/attachment.service.ts +1 -1
  43. package/src/services/context-compaction.service.ts +3 -3
  44. package/src/services/document-chunk.service.ts +37 -32
  45. package/src/services/execution-plan.service.ts +2 -0
  46. package/src/services/learned-skill.service.ts +6 -10
  47. package/src/services/{memory.utils.ts → memory-utils.ts} +4 -8
  48. package/src/services/memory.service.ts +21 -18
  49. package/src/services/organization-member.service.ts +1 -1
  50. package/src/services/plan-artifact.service.ts +1 -0
  51. package/src/services/plan-executor.service.ts +2 -18
  52. package/src/services/plan-helpers.ts +15 -0
  53. package/src/services/plan-validator.service.ts +3 -18
  54. package/src/services/recent-activity-title.service.ts +3 -10
  55. package/src/services/recent-activity.service.ts +6 -12
  56. package/src/services/workstream-message.service.ts +26 -16
  57. package/src/services/workstream-title.service.ts +1 -9
  58. package/src/services/{workstream-turn-preparation.ts → workstream-turn-preparation.service.ts} +401 -314
  59. package/src/services/workstream-turn.ts +2 -2
  60. package/src/services/workstream.service.ts +22 -10
  61. package/src/services/workstream.types.ts +7 -16
  62. package/src/storage/attachment-storage.service.ts +4 -4
  63. package/src/storage/{attachments.utils.ts → attachment-utils.ts} +1 -4
  64. package/src/storage/index.ts +2 -2
  65. package/src/system-agents/{context-compacter.agent.ts → context-compaction.agent.ts} +4 -4
  66. package/src/system-agents/delegated-agent-factory.ts +3 -2
  67. package/src/system-agents/index.ts +8 -0
  68. package/src/system-agents/memory-reranker.agent.ts +1 -1
  69. package/src/system-agents/memory.agent.ts +1 -1
  70. package/src/system-agents/recent-activity-title-refiner.agent.ts +1 -1
  71. package/src/tools/execution-plan.tool.ts +6 -2
  72. package/src/tools/fetch-webpage.tool.ts +20 -18
  73. package/src/tools/index.ts +2 -2
  74. package/src/tools/read-file-parts.tool.ts +1 -1
  75. package/src/tools/search-web.tool.ts +18 -15
  76. package/src/tools/{search-tools.ts → search.tool.ts} +1 -1
  77. package/src/tools/team-think.tool.ts +9 -5
  78. package/src/tools/{tool-contract.ts → tool-contracts.ts} +9 -2
  79. package/src/utils/async.ts +1 -1
  80. package/src/utils/errors.ts +15 -0
  81. package/src/utils/hono-error-handler.ts +1 -2
  82. package/src/utils/index.ts +10 -2
  83. package/src/utils/string.ts +14 -0
  84. package/src/workers/bootstrap.ts +2 -2
  85. package/src/workers/memory-consolidation.worker.ts +12 -12
  86. package/src/workers/regular-chat-memory-digest.helpers.ts +2 -7
  87. package/src/workers/regular-chat-memory-digest.runner.ts +9 -103
  88. package/src/workers/skill-extraction.runner.ts +7 -101
  89. package/src/workers/utils/file-section-chunker.ts +5 -3
  90. package/src/workers/utils/workstream-message-query.ts +106 -0
  91. package/src/workers/worker-utils.ts +4 -0
  92. package/src/runtime/retrieval-pipeline.ts +0 -3
  93. package/src/utils/error.ts +0 -10
  94. /package/src/services/{context-compaction-runtime.ts → context-compaction-runtime.singleton.ts} +0 -0
  95. /package/src/storage/{attachments.types.ts → attachment-types.ts} +0 -0
@@ -12,6 +12,7 @@ import {
12
12
  attachWorkerEvents,
13
13
  createTracedWorkerProcessor,
14
14
  createWorkerShutdown,
15
+ DEFAULT_JOB_RETENTION,
15
16
  registerShutdownSignals,
16
17
  } from '../workers/worker-utils'
17
18
  import type { WorkerHandle } from '../workers/worker-utils'
@@ -29,12 +30,7 @@ function getContextCompactionQueue(): Queue<ContextCompactionJob> {
29
30
  if (!_contextCompactionQueue) {
30
31
  _contextCompactionQueue = new Queue<ContextCompactionJob>(CONTEXT_COMPACTION_QUEUE, {
31
32
  connection: getRedisConnectionForBullMQ(),
32
- defaultJobOptions: {
33
- removeOnComplete: 200,
34
- removeOnFail: 200,
35
- attempts: 2,
36
- backoff: { type: 'exponential', delay: 3_000 },
37
- },
33
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 2, backoff: { type: 'exponential', delay: 3_000 } },
38
34
  })
39
35
  }
40
36
  return _contextCompactionQueue
@@ -51,11 +47,11 @@ async function processContextCompactionJob(job: Job<ContextCompactionJob>): Prom
51
47
 
52
48
  const { entityId, contextSize } = job.data
53
49
  const workstreamRef = ensureRecordId(entityId, TABLES.WORKSTREAM)
54
- await workstreamService.setCompacting(workstreamRef, true)
50
+ await workstreamService.markCompacting(workstreamRef)
55
51
  try {
56
52
  await contextCompactionService.compactWorkstreamHistory({ workstreamId: workstreamRef, contextSize })
57
53
  } finally {
58
- await workstreamService.setCompacting(workstreamRef, false)
54
+ await workstreamService.clearCompacting(workstreamRef)
59
55
  }
60
56
  }
61
57
 
@@ -4,7 +4,12 @@ import { Queue, Worker } from 'bullmq'
4
4
  import type IORedis from 'ioredis'
5
5
 
6
6
  import type { chatLogger } from '../config/logger'
7
- import { attachWorkerEvents, createWorkerShutdown, registerShutdownSignals } from '../workers/worker-utils'
7
+ import {
8
+ attachWorkerEvents,
9
+ createWorkerShutdown,
10
+ DEFAULT_JOB_RETENTION,
11
+ registerShutdownSignals,
12
+ } from '../workers/worker-utils'
8
13
  import type { WorkerHandle } from '../workers/worker-utils'
9
14
 
10
15
  export type DocumentSourceChannel = string
@@ -82,12 +87,7 @@ export function createDocumentProcessorQueueRuntime<TJob extends DocumentProcess
82
87
 
83
88
  queue = new Queue<TJob, unknown, string>(queueName, {
84
89
  connection: params.getConnectionForBullMQ(),
85
- defaultJobOptions: {
86
- removeOnComplete: 200,
87
- removeOnFail: 200,
88
- attempts: 3,
89
- backoff: { type: 'exponential', delay: 1000 },
90
- },
90
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
91
91
  })
92
92
 
93
93
  return queue
@@ -5,6 +5,8 @@ import { getRedisConnectionForBullMQ } from '../redis'
5
5
  import {
6
6
  attachWorkerEvents,
7
7
  getWorkerPath,
8
+ LONG_JOB_LOCK_DURATION_MS,
9
+ LOW_JOB_RETENTION,
8
10
  createWorkerShutdown,
9
11
  registerShutdownSignals,
10
12
  } from '../workers/worker-utils'
@@ -15,18 +17,15 @@ export interface MemoryConsolidationJob {
15
17
  }
16
18
 
17
19
  const MEMORY_CONSOLIDATION_QUEUE = 'memory-consolidation'
20
+ const MEMORY_CONSOLIDATION_INTERVAL_MS = 24 * 60 * 60 * 1000
21
+ const MEMORY_CONSOLIDATION_JOB_ID = 'memory-consolidation-recurring'
18
22
 
19
23
  let _memoryConsolidationQueue: Queue<MemoryConsolidationJob> | null = null
20
24
  function getMemoryConsolidationQueue(): Queue<MemoryConsolidationJob> {
21
25
  if (!_memoryConsolidationQueue) {
22
26
  _memoryConsolidationQueue = new Queue<MemoryConsolidationJob>(MEMORY_CONSOLIDATION_QUEUE, {
23
27
  connection: getRedisConnectionForBullMQ(),
24
- defaultJobOptions: {
25
- removeOnComplete: 50,
26
- removeOnFail: 50,
27
- attempts: 2,
28
- backoff: { type: 'exponential', delay: 5000 },
29
- },
28
+ defaultJobOptions: { ...LOW_JOB_RETENTION, attempts: 2, backoff: { type: 'exponential', delay: 5000 } },
30
29
  })
31
30
  }
32
31
  return _memoryConsolidationQueue
@@ -42,7 +41,7 @@ export async function scheduleRecurringConsolidation() {
42
41
  await getMemoryConsolidationQueue().add(
43
42
  'consolidate',
44
43
  {},
45
- { repeat: { every: 24 * 60 * 60 * 1000 }, jobId: 'memory-consolidation-recurring' },
44
+ { repeat: { every: MEMORY_CONSOLIDATION_INTERVAL_MS }, jobId: MEMORY_CONSOLIDATION_JOB_ID },
46
45
  )
47
46
  }
48
47
 
@@ -51,7 +50,7 @@ export function startMemoryConsolidationWorker(options: { registerSignals?: bool
51
50
  const processorPath = getWorkerPath('memory-consolidation.worker.ts')
52
51
  const worker = new Worker(MEMORY_CONSOLIDATION_QUEUE, processorPath, {
53
52
  connection: getRedisConnectionForBullMQ(),
54
- lockDuration: 600_000,
53
+ lockDuration: LONG_JOB_LOCK_DURATION_MS,
55
54
  concurrency: 1,
56
55
  })
57
56
 
@@ -10,6 +10,7 @@ import {
10
10
  attachWorkerEvents,
11
11
  createTracedWorkerProcessor,
12
12
  createWorkerShutdown,
13
+ DEFAULT_JOB_RETENTION,
13
14
  registerShutdownSignals,
14
15
  } from '../workers/worker-utils'
15
16
  import type { WorkerHandle } from '../workers/worker-utils'
@@ -43,12 +44,7 @@ function getPostChatMemoryQueue(): Queue<PostChatMemoryExtractionJob> {
43
44
  if (!_postChatMemoryQueue) {
44
45
  _postChatMemoryQueue = new Queue<PostChatMemoryExtractionJob>(POST_CHAT_MEMORY_QUEUE, {
45
46
  connection: getRedisConnectionForBullMQ(),
46
- defaultJobOptions: {
47
- removeOnComplete: 200,
48
- removeOnFail: 200,
49
- attempts: 3,
50
- backoff: { type: 'exponential', delay: 2_000 },
51
- },
47
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 3, backoff: { type: 'exponential', delay: 2_000 } },
52
48
  })
53
49
  }
54
50
  return _postChatMemoryQueue
@@ -9,6 +9,7 @@ import {
9
9
  attachWorkerEvents,
10
10
  createTracedWorkerProcessor,
11
11
  createWorkerShutdown,
12
+ DEFAULT_JOB_RETENTION,
12
13
  registerShutdownSignals,
13
14
  } from '../workers/worker-utils'
14
15
  import type { WorkerHandle } from '../workers/worker-utils'
@@ -26,12 +27,7 @@ function getRecentActivityTitleRefinementQueue(): Queue<RecentActivityTitleRefin
26
27
  RECENT_ACTIVITY_TITLE_REFINEMENT_QUEUE,
27
28
  {
28
29
  connection: getRedisConnectionForBullMQ(),
29
- defaultJobOptions: {
30
- removeOnComplete: 200,
31
- removeOnFail: 200,
32
- attempts: 3,
33
- backoff: { type: 'exponential', delay: 2_000 },
34
- },
30
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 3, backoff: { type: 'exponential', delay: 2_000 } },
35
31
  },
36
32
  )
37
33
  }
@@ -4,7 +4,9 @@ import { serverLogger } from '../config/logger'
4
4
  import { getRedisConnectionForBullMQ } from '../redis'
5
5
  import {
6
6
  attachWorkerEvents,
7
+ DEFAULT_JOB_RETENTION,
7
8
  getWorkerPath,
9
+ LONG_JOB_LOCK_DURATION_MS,
8
10
  createWorkerShutdown,
9
11
  registerShutdownSignals,
10
12
  } from '../workers/worker-utils'
@@ -25,12 +27,7 @@ function getRegularChatMemoryDigestQueue(): Queue<RegularChatMemoryDigestJob> {
25
27
  if (!_regularChatMemoryDigestQueue) {
26
28
  _regularChatMemoryDigestQueue = new Queue<RegularChatMemoryDigestJob>(REGULAR_CHAT_MEMORY_DIGEST_QUEUE, {
27
29
  connection: getRedisConnectionForBullMQ(),
28
- defaultJobOptions: {
29
- removeOnComplete: 200,
30
- removeOnFail: 200,
31
- attempts: 2,
32
- backoff: { type: 'exponential', delay: 5000 },
33
- },
30
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 2, backoff: { type: 'exponential', delay: 5000 } },
34
31
  })
35
32
  }
36
33
  return _regularChatMemoryDigestQueue
@@ -54,7 +51,7 @@ export function startRegularChatMemoryDigestWorker(options: { registerSignals?:
54
51
  const worker = new Worker(REGULAR_CHAT_MEMORY_DIGEST_QUEUE, processorPath, {
55
52
  connection: getRedisConnectionForBullMQ(),
56
53
  concurrency: 1,
57
- lockDuration: 600_000,
54
+ lockDuration: LONG_JOB_LOCK_DURATION_MS,
58
55
  })
59
56
 
60
57
  attachWorkerEvents(worker, 'Regular chat memory digest', serverLogger)
@@ -4,7 +4,9 @@ import { serverLogger } from '../config/logger'
4
4
  import { getRedisConnectionForBullMQ } from '../redis'
5
5
  import {
6
6
  attachWorkerEvents,
7
+ DEFAULT_JOB_RETENTION,
7
8
  getWorkerPath,
9
+ LONG_JOB_LOCK_DURATION_MS,
8
10
  createWorkerShutdown,
9
11
  registerShutdownSignals,
10
12
  } from '../workers/worker-utils'
@@ -22,12 +24,7 @@ function getSkillExtractionQueue(): Queue<SkillExtractionJob> {
22
24
  if (!_skillExtractionQueue) {
23
25
  _skillExtractionQueue = new Queue<SkillExtractionJob>(SKILL_EXTRACTION_QUEUE, {
24
26
  connection: getRedisConnectionForBullMQ(),
25
- defaultJobOptions: {
26
- removeOnComplete: 200,
27
- removeOnFail: 200,
28
- attempts: 2,
29
- backoff: { type: 'exponential', delay: 5000 },
30
- },
27
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 2, backoff: { type: 'exponential', delay: 5000 } },
31
28
  })
32
29
  }
33
30
  return _skillExtractionQueue
@@ -43,7 +40,7 @@ export function startSkillExtractionWorker(options: { registerSignals?: boolean
43
40
  const worker = new Worker(SKILL_EXTRACTION_QUEUE, processorPath, {
44
41
  connection: getRedisConnectionForBullMQ(),
45
42
  concurrency: 1,
46
- lockDuration: 600_000,
43
+ lockDuration: LONG_JOB_LOCK_DURATION_MS,
47
44
  })
48
45
 
49
46
  attachWorkerEvents(worker, 'Skill extraction', serverLogger)
@@ -10,6 +10,7 @@ import {
10
10
  attachWorkerEvents,
11
11
  createTracedWorkerProcessor,
12
12
  createWorkerShutdown,
13
+ DEFAULT_JOB_RETENTION,
13
14
  registerShutdownSignals,
14
15
  } from '../workers/worker-utils'
15
16
  import type { WorkerHandle } from '../workers/worker-utils'
@@ -26,12 +27,7 @@ function getWorkstreamTitleGenerationQueue(): Queue<WorkstreamTitleGenerationJob
26
27
  if (!_workstreamTitleGenerationQueue) {
27
28
  _workstreamTitleGenerationQueue = new Queue<WorkstreamTitleGenerationJob>(WORKSTREAM_TITLE_GENERATION_QUEUE, {
28
29
  connection: getRedisConnectionForBullMQ(),
29
- defaultJobOptions: {
30
- removeOnComplete: 200,
31
- removeOnFail: 200,
32
- attempts: 2,
33
- backoff: { type: 'exponential', delay: 2_000 },
34
- },
30
+ defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 2, backoff: { type: 'exponential', delay: 2_000 } },
35
31
  })
36
32
  }
37
33
  return _workstreamTitleGenerationQueue
@@ -1,7 +1,7 @@
1
1
  import IORedis from 'ioredis'
2
2
  import type { RedisOptions } from 'ioredis'
3
3
 
4
- import { getErrorMessage } from '../utils/error'
4
+ import { getErrorMessage } from '../utils/errors'
5
5
 
6
6
  export interface RedisConnectionLogger {
7
7
  debug?: (message: string) => void
@@ -25,20 +25,23 @@ export interface RedisConnectionManager {
25
25
  }
26
26
 
27
27
  const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 30_000
28
+ const REDIS_RETRY_STEP_MS = 50
29
+ const REDIS_RETRY_MAX_DELAY_MS = 2000
30
+ const REDIS_CONNECT_TIMEOUT_MS = 10_000
28
31
 
29
32
  export const DEFAULT_REDIS_OPTIONS: RedisOptions = {
30
33
  maxRetriesPerRequest: null,
31
34
  enableReadyCheck: true,
32
35
  enableOfflineQueue: true,
33
36
  retryStrategy: (times: number) => {
34
- const delay = Math.min(times * 50, 2000)
37
+ const delay = Math.min(times * REDIS_RETRY_STEP_MS, REDIS_RETRY_MAX_DELAY_MS)
35
38
  return delay
36
39
  },
37
40
  reconnectOnError: (err: Error) => {
38
41
  const targetErrors = ['READONLY', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN']
39
42
  return targetErrors.some((candidate) => err.message.includes(candidate))
40
43
  },
41
- connectTimeout: 10000,
44
+ connectTimeout: REDIS_CONNECT_TIMEOUT_MS,
42
45
  lazyConnect: false,
43
46
  }
44
47
 
@@ -8,6 +8,7 @@ export {
8
8
  type RedisConnectionAccessor,
9
9
  } from './connection-accessor'
10
10
  export { withOrgMemoryLock } from './org-memory-lock'
11
+ export { LeaseLockLostError, withRedisLeaseLock } from './redis-lease-lock'
11
12
  export { createWorkstreamResumableContext } from './stream-context'
12
13
 
13
14
  export { createRedisConnectionManager }
@@ -9,7 +9,7 @@ const ORG_MEMORY_LOCK_REFRESH_INTERVAL_MS = 30_000
9
9
  const ORG_MEMORY_LOCK_WAIT_LOG_INTERVAL_MS = 30_000
10
10
  const ORG_MEMORY_LOCK_MAX_WAIT_MS = 15 * 60 * 1000
11
11
 
12
- export async function withOrgMemoryLock<T>(orgId: string, fn: () => Promise<T>): Promise<T> {
12
+ export async function withOrgMemoryLock<T>(orgId: string, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
13
13
  const normalizedOrgId = orgId.trim()
14
14
 
15
15
  if (!normalizedOrgId) {
@@ -3,7 +3,7 @@ import { setTimeout as delay } from 'node:timers/promises'
3
3
 
4
4
  import type IORedis from 'ioredis'
5
5
 
6
- import { getErrorMessage } from '../utils/error'
6
+ import { getErrorMessage } from '../utils/errors'
7
7
 
8
8
  interface RedisLeaseLockLogger {
9
9
  debug?: (message: string) => void
@@ -95,16 +95,30 @@ async function releaseLeaseLock(options: RedisLeaseLockOptions & { lockValue: st
95
95
  await options.redis.eval(RELEASE_LOCK_SCRIPT, 1, options.lockKey, options.lockValue)
96
96
  }
97
97
 
98
+ export class LeaseLockLostError extends Error {
99
+ constructor(message: string) {
100
+ super(message)
101
+ this.name = 'LeaseLockLostError'
102
+ }
103
+ }
104
+
98
105
  function startLeaseLockRefreshLoop(
99
106
  options: Required<Pick<RedisLeaseLockOptions, 'refreshIntervalMs'>> &
100
107
  RedisLeaseLockOptions & { lockValue: string; label: string },
108
+ ac: AbortController,
101
109
  ): () => void {
102
110
  let stopped = false
103
111
 
104
112
  const timer = setInterval(() => {
105
113
  if (stopped) return
106
114
  void refreshLeaseLock(options).catch((error: unknown) => {
107
- log(options.logger, 'warn', `Failed to refresh ${options.label} (${options.lockKey}): ${getErrorMessage(error)}`)
115
+ stopped = true
116
+ clearInterval(timer)
117
+ const message = `Failed to refresh ${options.label} (${options.lockKey}): ${getErrorMessage(error)}`
118
+ log(options.logger, 'warn', message)
119
+ if (!ac.signal.aborted) {
120
+ ac.abort(new LeaseLockLostError(message))
121
+ }
108
122
  })
109
123
  }, options.refreshIntervalMs)
110
124
 
@@ -116,7 +130,10 @@ function startLeaseLockRefreshLoop(
116
130
  }
117
131
  }
118
132
 
119
- export async function withRedisLeaseLock<T>(options: RedisLeaseLockOptions, fn: () => Promise<T>): Promise<T> {
133
+ export async function withRedisLeaseLock<T>(
134
+ options: RedisLeaseLockOptions,
135
+ fn: (signal: AbortSignal) => Promise<T>,
136
+ ): Promise<T> {
120
137
  const lockKey = options.lockKey.trim()
121
138
  if (!lockKey) {
122
139
  throw new Error('Redis lease lock requires a non-empty lock key')
@@ -138,17 +155,33 @@ export async function withRedisLeaseLock<T>(options: RedisLeaseLockOptions, fn:
138
155
  log(options.logger, 'info', `Acquired ${label} (${lockKey}) after waiting waitedMs=${waitedMs}`)
139
156
  }
140
157
 
141
- const stopRefreshLoop = startLeaseLockRefreshLoop({ ...options, lockKey, lockValue, label, refreshIntervalMs })
158
+ const ac = new AbortController()
159
+ const stopRefreshLoop = startLeaseLockRefreshLoop({ ...options, lockKey, lockValue, label, refreshIntervalMs }, ac)
142
160
  const holdStart = Date.now()
143
161
 
144
162
  try {
145
- return await fn()
163
+ const abortPromise = new Promise<never>((_, reject) => {
164
+ if (ac.signal.aborted) {
165
+ reject(ac.signal.reason as Error)
166
+ return
167
+ }
168
+ ac.signal.addEventListener(
169
+ 'abort',
170
+ () => {
171
+ reject(ac.signal.reason as Error)
172
+ },
173
+ { once: true },
174
+ )
175
+ })
176
+ return await Promise.race([fn(ac.signal), abortPromise])
146
177
  } finally {
147
178
  stopRefreshLoop()
148
179
  const heldMs = Date.now() - holdStart
149
- await releaseLeaseLock({ ...options, lockKey, lockValue }).catch((error: unknown) => {
150
- log(options.logger, 'warn', `Failed to release ${label} (${lockKey}): ${getErrorMessage(error)}`)
151
- })
180
+ if (!ac.signal.aborted) {
181
+ await releaseLeaseLock({ ...options, lockKey, lockValue }).catch((error: unknown) => {
182
+ log(options.logger, 'warn', `Failed to release ${label} (${lockKey}): ${getErrorMessage(error)}`)
183
+ })
184
+ }
152
185
  if (heldMs >= heldInfoThresholdMs) {
153
186
  log(options.logger, 'info', `Released ${label} (${lockKey}) heldMs=${heldMs}`)
154
187
  } else {
@@ -2,13 +2,14 @@ import type { ChatMessage } from '@lota-sdk/shared'
2
2
  import type { LanguageModelUsage, UIMessageStreamOptions } from 'ai'
3
3
 
4
4
  import { agentDisplayNames, getLeadAgentDisplayName } from '../config/agent-defaults'
5
+ import { readRecord as _readRecord } from '../utils/string'
5
6
 
6
7
  export function readFiniteNumber(value: unknown): number | undefined {
7
8
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
8
9
  }
9
10
 
10
11
  export function readRecord(value: unknown): Record<string, unknown> | undefined {
11
- return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined
12
+ return _readRecord(value) ?? undefined
12
13
  }
13
14
 
14
15
  export function readOpenRouterUsageCost(usage: LanguageModelUsage): { cost?: number; upstreamInferenceCost?: number } {
@@ -7,5 +7,5 @@ export const CONTEXT_COMPACTION_INCLUDED_TOOL_NAMES = ['userQuestions', 'proceed
7
7
  export const CONTEXT_COMPACTION_INCLUDED_TOOL_PREFIXES = ['linear'] as const
8
8
  export const MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES = 15
9
9
  export const MEMORY_BLOCK_COMPACTION_CHUNK_ENTRIES = 10
10
- export const CONTEXT_SIZE = 200_000
10
+ export const CONTEXT_WINDOW_TOKENS = 200_000
11
11
  export const WORKSTREAM_RAW_TAIL_MESSAGES = 6
@@ -1,4 +1,4 @@
1
- import { createContextCompacterAgent } from '../system-agents/context-compacter.agent'
1
+ import { createContextCompactionAgent } from '../system-agents/context-compaction.agent'
2
2
  import {
3
3
  buildContextCompactionPrompt,
4
4
  buildMemoryBlockCompactionPrompt,
@@ -18,6 +18,8 @@ import {
18
18
  import type { GenerateHelperStructuredParams, GenerateHelperTextParams } from './helper-model'
19
19
  import { StructuredCompactionOutputSchema } from './workstream-state'
20
20
 
21
+ const CONTEXT_COMPACTION_MAX_OUTPUT_TOKENS = 512
22
+
21
23
  interface HelperModelRuntime {
22
24
  generateHelperStructured<T>(params: GenerateHelperStructuredParams<T>): Promise<T>
23
25
  generateHelperText(params: GenerateHelperTextParams): Promise<string>
@@ -32,7 +34,7 @@ interface CreateContextCompactionRuntimeDeps {
32
34
  async function runContextCompacter(helperModelRuntime: HelperModelRuntime, params: ContextCompactionRunnerParams) {
33
35
  const output = await helperModelRuntime.generateHelperStructured({
34
36
  tag: 'context-compaction',
35
- createAgent: createContextCompacterAgent,
37
+ createAgent: createContextCompactionAgent,
36
38
  messages: [
37
39
  {
38
40
  role: 'user',
@@ -76,9 +78,9 @@ export function createWiredContextCompactionRuntime(deps: CreateContextCompactio
76
78
 
77
79
  return await helperModelRuntime.generateHelperText({
78
80
  tag: 'memory-block-compaction',
79
- createAgent: createContextCompacterAgent,
81
+ createAgent: createContextCompactionAgent,
80
82
  messages: [{ role: 'user', content: buildMemoryBlockCompactionPrompt({ previousSummary, newEntriesText }) }],
81
- maxOutputTokens: 512,
83
+ maxOutputTokens: CONTEXT_COMPACTION_MAX_OUTPUT_TOKENS,
82
84
  })
83
85
  }
84
86
 
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'
2
2
 
3
3
  import type { ChatMessage } from '@lota-sdk/shared'
4
4
 
5
- import { readString } from '../utils/string'
5
+ import { CHARS_PER_TOKEN_ESTIMATE, compactWhitespace, readRecord, readString } from '../utils/string'
6
6
  import {
7
7
  COMPACTION_CHUNK_MAX_CHARS,
8
8
  CONTEXT_COMPACTION_INCLUDED_TOOL_NAMES,
@@ -15,14 +15,8 @@ import {
15
15
  import {
16
16
  StructuredCompactionOutputSchema,
17
17
  WorkstreamStateDeltaSchema,
18
- WORKSTREAM_STATE_MAX_ACTIVE_CONSTRAINTS,
19
- WORKSTREAM_STATE_MAX_AGENT_CONTRIBUTIONS,
20
- WORKSTREAM_STATE_MAX_ARTIFACTS,
21
- WORKSTREAM_STATE_MAX_KEY_DECISIONS,
22
- WORKSTREAM_STATE_MAX_OPEN_QUESTIONS,
23
- WORKSTREAM_STATE_MAX_RISKS,
24
- WORKSTREAM_STATE_MAX_TASKS,
25
18
  WorkstreamStateSchema,
19
+ applyWorkstreamStateCaps,
26
20
  createEmptyWorkstreamState,
27
21
  parseStructuredWorkstreamStateDelta,
28
22
  } from './workstream-state'
@@ -99,15 +93,11 @@ const PROMPT_INJECTION_PATTERN =
99
93
  /\b(ignore (all )?(previous|prior|system|developer)? instructions?|system prompt|developer prompt|tool override|jailbreak|role ?override|do not follow|bypass)\b/i
100
94
 
101
95
  function estimateTokens(text: string): number {
102
- return Math.ceil(text.length / 3)
103
- }
104
-
105
- function normalizeWhitespace(value: string): string {
106
- return value.replace(/\s+/g, ' ').trim()
96
+ return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
107
97
  }
108
98
 
109
99
  function sanitizeStateText(value: string): string | null {
110
- const normalized = normalizeWhitespace(value)
100
+ const normalized = compactWhitespace(value)
111
101
  if (!normalized) return null
112
102
  if (PROMPT_INJECTION_PATTERN.test(normalized)) return null
113
103
  return normalized
@@ -116,16 +106,12 @@ function sanitizeStateText(value: string): string | null {
116
106
  function createStableId(prefix: string, ...parts: Array<string | number | undefined>): string {
117
107
  const payload = parts
118
108
  .map((part) => (part === undefined ? '' : String(part)))
119
- .map((part) => normalizeWhitespace(part))
109
+ .map((part) => compactWhitespace(part))
120
110
  .join('|')
121
111
  const hash = createHash('sha1').update(`${prefix}|${payload}`).digest('hex').slice(0, 20)
122
112
  return `${prefix}_${hash}`
123
113
  }
124
114
 
125
- function readRecord(value: unknown): Record<string, unknown> | null {
126
- return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
127
- }
128
-
129
115
  function stringifyUnknown(value: unknown): string | null {
130
116
  if (value === undefined) return null
131
117
  if (typeof value === 'string') {
@@ -141,11 +127,11 @@ function stringifyUnknown(value: unknown): string | null {
141
127
  }
142
128
 
143
129
  function appendUnique(values: string[], nextValues: string[]): string[] {
144
- const seen = new Set(values.map((value) => normalizeWhitespace(value).toLowerCase()))
130
+ const seen = new Set(values.map((value) => compactWhitespace(value).toLowerCase()))
145
131
  const merged = [...values]
146
132
 
147
133
  for (const value of nextValues) {
148
- const normalized = normalizeWhitespace(value)
134
+ const normalized = compactWhitespace(value)
149
135
  if (!normalized) continue
150
136
  const key = normalized.toLowerCase()
151
137
  if (seen.has(key)) continue
@@ -395,7 +381,7 @@ export function mergeStateDelta(
395
381
  const normalizedRationale = sanitizeStateText(decision.rationale)
396
382
  if (!normalizedDecision || !normalizedRationale) continue
397
383
 
398
- const sourceIds = [...new Set(decision.sourceMessageIds.map((id) => normalizeWhitespace(id)).filter(Boolean))]
384
+ const sourceIds = [...new Set(decision.sourceMessageIds.map((id) => compactWhitespace(id)).filter(Boolean))]
399
385
  const decisionId = createStableId('decision', normalizedDecision, normalizedRationale, sourceIds.sort().join('|'))
400
386
  const alreadyExists = state.keyDecisions.some((item) => item.id === decisionId)
401
387
  if (alreadyExists) continue
@@ -403,7 +389,7 @@ export function mergeStateDelta(
403
389
  id: decisionId,
404
390
  decision: normalizedDecision,
405
391
  rationale: normalizedRationale,
406
- agent: normalizeWhitespace(decision.agent),
392
+ agent: compactWhitespace(decision.agent),
407
393
  sourceMessageIds: sourceIds,
408
394
  confidence: decision.confidence,
409
395
  timestamp,
@@ -417,11 +403,9 @@ export function mergeStateDelta(
417
403
  if (!title) continue
418
404
 
419
405
  const externalId = sanitizeStateText(update.externalId ?? '')
420
- const owner = normalizeWhitespace(update.owner)
406
+ const owner = compactWhitespace(update.owner)
421
407
  const taskId = externalId ? createStableId('task-external', externalId) : createStableId('task', title, owner)
422
- const sourceMessageIds = [
423
- ...new Set(update.sourceMessageIds.map((id) => normalizeWhitespace(id)).filter(Boolean)),
424
- ]
408
+ const sourceMessageIds = [...new Set(update.sourceMessageIds.map((id) => compactWhitespace(id)).filter(Boolean))]
425
409
  const existingIndex = state.tasks.findIndex((task) => task.id === taskId)
426
410
  const nextTask = {
427
411
  id: taskId,
@@ -455,7 +439,7 @@ export function mergeStateDelta(
455
439
  const artifactId = createStableId('artifact', name, pointer)
456
440
  const exists = state.artifacts.some((item) => item.id === artifactId)
457
441
  if (exists) continue
458
- state.artifacts.push({ id: artifactId, name, type: normalizeWhitespace(artifact.type), pointer, timestamp })
442
+ state.artifacts.push({ id: artifactId, name, type: compactWhitespace(artifact.type), pointer, timestamp })
459
443
  }
460
444
  }
461
445
 
@@ -495,13 +479,7 @@ export function mergeStateDelta(
495
479
  state.approvalNote = sanitizeStateText(delta.approvalNote) ?? undefined
496
480
  }
497
481
 
498
- state.keyDecisions = state.keyDecisions.slice(-WORKSTREAM_STATE_MAX_KEY_DECISIONS)
499
- state.activeConstraints = state.activeConstraints.slice(-WORKSTREAM_STATE_MAX_ACTIVE_CONSTRAINTS)
500
- state.tasks = state.tasks.slice(-WORKSTREAM_STATE_MAX_TASKS)
501
- state.openQuestions = state.openQuestions.slice(-WORKSTREAM_STATE_MAX_OPEN_QUESTIONS)
502
- state.risks = state.risks.slice(-WORKSTREAM_STATE_MAX_RISKS)
503
- state.artifacts = state.artifacts.slice(-WORKSTREAM_STATE_MAX_ARTIFACTS)
504
- state.agentContributions = state.agentContributions.slice(-WORKSTREAM_STATE_MAX_AGENT_CONTRIBUTIONS)
482
+ applyWorkstreamStateCaps(state)
505
483
 
506
484
  return WorkstreamStateSchema.parse(state)
507
485
  }
@@ -727,9 +705,12 @@ export function createContextCompactionRuntime(
727
705
  return summaryMessage ? [summaryMessage, ...liveMessages] : [...liveMessages]
728
706
  }
729
707
 
708
+ const CONTEXT_OUTPUT_RESERVE_MAX_RATIO = 0.35
709
+ const CONTEXT_SAFETY_MARGIN_MAX_RATIO = 0.1
710
+
730
711
  const estimateThreshold = (contextSize = 256_000): number => {
731
- const reservedOutput = Math.min(outputReserveTokens, Math.floor(contextSize * 0.35))
732
- const safetyMargin = Math.min(safetyMarginTokens, Math.floor(contextSize * 0.1))
712
+ const reservedOutput = Math.min(outputReserveTokens, Math.floor(contextSize * CONTEXT_OUTPUT_RESERVE_MAX_RATIO))
713
+ const safetyMargin = Math.min(safetyMarginTokens, Math.floor(contextSize * CONTEXT_SAFETY_MARGIN_MAX_RATIO))
733
714
  const reservedThreshold = contextSize - (reservedOutput + safetyMargin)
734
715
  const ratioThreshold = Math.floor(contextSize * thresholdRatio)
735
716
  return Math.max(1_000, Math.min(contextSize - 1, Math.min(reservedThreshold, ratioThreshold)))
@@ -834,7 +815,7 @@ export function createContextCompactionRuntime(
834
815
  const contextMessages = messagesToCompact.map(toContextMessageFromChatMessage)
835
816
  const sourceText = toCompactionTranscript(contextMessages)
836
817
 
837
- if (!normalizeWhitespace(sourceText)) {
818
+ if (!compactWhitespace(sourceText)) {
838
819
  const summaryPayload = buildSyntheticSummaryPayload(summaryText)
839
820
  const outputPayload = JSON.stringify([...(summaryPayload ? [summaryPayload] : []), ...remainingMessages])
840
821
  return {
@@ -1,6 +1,6 @@
1
1
  import type { SerializableExecutionPlan } from '@lota-sdk/shared'
2
2
 
3
- export const EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT = `<execution-plan-protocol>
3
+ const EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT = `<execution-plan-protocol>
4
4
  - Before doing multi-step work, create a contract-driven execution plan instead of tracking steps only in prose.
5
5
  - Plans are graph-capable workflow contracts. Every execution node must define objective, instructions, deliverables, success criteria, completion checks, retry policy, failure policy, and tool/context policy.
6
6
  - The runtime executor owns lifecycle truth. Do not claim that a node is complete until submitExecutionNodeResult succeeds.
@@ -10,7 +10,7 @@ export const EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT = `<execution-plan-protocol>
10
10
  - If the graph, contracts, or success criteria materially change, replace the plan instead of silently drifting.
11
11
  </execution-plan-protocol>`
12
12
 
13
- export function formatExecutionPlanForPrompt(plan: SerializableExecutionPlan | null | undefined): string | undefined {
13
+ function formatExecutionPlanForPrompt(plan: SerializableExecutionPlan | null | undefined): string | undefined {
14
14
  if (!plan) return undefined
15
15
 
16
16
  const payload = {
@@ -7,6 +7,8 @@ import type {
7
7
  } from 'ai'
8
8
  import type { ZodSchema } from 'zod'
9
9
 
10
+ import { compactWhitespace } from '../utils/string'
11
+
10
12
  export interface HelperToolLoopAgentOptions {
11
13
  instructions?: string
12
14
  maxOutputTokens?: number
@@ -102,7 +104,7 @@ function stringifyUnknown(value: unknown, maxChars: number): string | null {
102
104
  }
103
105
  })()
104
106
 
105
- const normalized = raw.replace(/\s+/g, ' ').trim()
107
+ const normalized = compactWhitespace(raw)
106
108
  if (!normalized) return null
107
109
  return normalized.length > maxChars ? `${normalized.slice(0, maxChars)}...` : normalized
108
110
  }
@@ -23,4 +23,15 @@ export * from './team-consultation-prompts'
23
23
  export * from './turn-lifecycle'
24
24
  export * from './workstream-chat-helpers'
25
25
  export * from './workstream-routing-policy'
26
- export * from './workstream-state'
26
+ export {
27
+ WorkstreamStateSchema,
28
+ type WorkstreamState,
29
+ type WorkstreamStateDelta,
30
+ StructuredWorkstreamStateDeltaSchema,
31
+ type StructuredWorkstreamStateDelta,
32
+ createEmptyStructuredWorkstreamStateDelta,
33
+ parseStructuredWorkstreamStateDelta,
34
+ StructuredCompactionOutputSchema,
35
+ type CompactionOutput,
36
+ createEmptyWorkstreamState,
37
+ } from './workstream-state'