@lota-sdk/core 0.1.8 → 0.1.11

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 (38) hide show
  1. package/infrastructure/schema/00_workstream.surql +2 -1
  2. package/infrastructure/schema/02_execution_plan.surql +202 -52
  3. package/package.json +4 -2
  4. package/src/bifrost/bifrost.ts +94 -25
  5. package/src/config/model-constants.ts +8 -6
  6. package/src/db/memory-store.ts +3 -71
  7. package/src/db/service.ts +42 -2
  8. package/src/db/tables.ts +9 -2
  9. package/src/embeddings/provider.ts +92 -21
  10. package/src/index.ts +6 -0
  11. package/src/redis/stream-context.ts +44 -0
  12. package/src/runtime/approval-continuation.ts +59 -0
  13. package/src/runtime/chat-request-routing.ts +5 -1
  14. package/src/runtime/execution-plan.ts +21 -14
  15. package/src/runtime/turn-lifecycle.ts +14 -6
  16. package/src/runtime/workstream-chat-helpers.ts +5 -5
  17. package/src/services/context-compaction.service.ts +6 -2
  18. package/src/services/document-chunk.service.ts +2 -2
  19. package/src/services/execution-plan.service.ts +579 -786
  20. package/src/services/learned-skill.service.ts +2 -2
  21. package/src/services/plan-approval.service.ts +83 -0
  22. package/src/services/plan-artifact.service.ts +45 -0
  23. package/src/services/plan-builder.service.ts +61 -0
  24. package/src/services/plan-checkpoint.service.ts +53 -0
  25. package/src/services/plan-compiler.service.ts +81 -0
  26. package/src/services/plan-executor.service.ts +1623 -0
  27. package/src/services/plan-run.service.ts +422 -0
  28. package/src/services/plan-validator.service.ts +760 -0
  29. package/src/services/workstream-turn-preparation.ts +70 -196
  30. package/src/services/workstream-turn.ts +12 -0
  31. package/src/services/workstream.service.ts +24 -182
  32. package/src/services/workstream.types.ts +2 -65
  33. package/src/system-agents/title-generator.agent.ts +2 -2
  34. package/src/tools/execution-plan.tool.ts +20 -46
  35. package/src/tools/log-hello-world.tool.ts +17 -0
  36. package/src/workers/skill-extraction.runner.ts +2 -2
  37. package/src/services/workstream-change-tracker.service.ts +0 -313
  38. package/src/system-agents/workstream-tracker.agent.ts +0 -58
package/src/db/service.ts CHANGED
@@ -66,6 +66,8 @@ export type CreateMutationBuilder = {
66
66
  export interface DatabaseTransaction {
67
67
  query: (query: unknown) => Promise<unknown>
68
68
  create: (target: unknown) => CreateMutationBuilder
69
+ update: (target: unknown) => MutationBuilder
70
+ delete: (target: unknown) => Promise<unknown>
69
71
  relate: (from: unknown, edgeTable: unknown, to: unknown, data?: Values<Record<string, unknown>>) => Promise<unknown>
70
72
  commit: () => Promise<void>
71
73
  cancel: () => Promise<void>
@@ -458,6 +460,38 @@ export class SurrealDBService {
458
460
  throw new SurrealDBError('Invalid table value')
459
461
  }
460
462
 
463
+ private isRecordIdLike(value: unknown): boolean {
464
+ if (value instanceof RecordId || value instanceof StringRecordId) {
465
+ return true
466
+ }
467
+
468
+ if (typeof value === 'string') {
469
+ return /^[a-zA-Z][a-zA-Z0-9_]*:/.test(value)
470
+ }
471
+
472
+ if (value && typeof value === 'object') {
473
+ const record = value as { tb?: unknown; id?: unknown }
474
+ if (typeof record.tb === 'string' && record.id !== undefined) {
475
+ return true
476
+ }
477
+
478
+ const stringValue = toStringLikeValue(value)
479
+ if (stringValue) {
480
+ return /^[a-zA-Z][a-zA-Z0-9_]*:/.test(stringValue)
481
+ }
482
+ }
483
+
484
+ return false
485
+ }
486
+
487
+ private normalizeCreateTarget(value: unknown): Table | RecordId {
488
+ if (this.isRecordIdLike(value)) {
489
+ return ensureRecordId(value as RecordIdInput)
490
+ }
491
+
492
+ return this.normalizeTableValue(value)
493
+ }
494
+
461
495
  private wrapMutationBuilder(builder: MutationBuilder): MutationBuilder {
462
496
  return {
463
497
  content: (data) => this.wrapMutationBuilder(builder.content(this.normalizeMutationData(data))),
@@ -478,8 +512,14 @@ export class SurrealDBService {
478
512
  private wrapTransaction(tx: SurrealTransaction): DatabaseTransaction {
479
513
  return {
480
514
  query: (query: unknown) => tx.query(this.normalizeBoundQuery(query as BoundQuery | BoundQueryLike)),
481
- create: (target: unknown) =>
482
- this.wrapCreateBuilder(tx.create(this.normalizeTableValue(target)) as unknown as CreateMutationBuilder),
515
+ create: (target: unknown) => {
516
+ const normalizedTarget = this.normalizeCreateTarget(target)
517
+ const builder = normalizedTarget instanceof Table ? tx.create(normalizedTarget) : tx.create(normalizedTarget)
518
+ return this.wrapCreateBuilder(builder as unknown as CreateMutationBuilder)
519
+ },
520
+ update: (target: unknown) =>
521
+ this.wrapMutationBuilder(tx.update(ensureRecordId(target as RecordIdInput)) as unknown as MutationBuilder),
522
+ delete: (target: unknown) => tx.delete(ensureRecordId(target as RecordIdInput)),
483
523
  relate: (from: unknown, edgeTable: unknown, to: unknown, data?: Values<Record<string, unknown>>) =>
484
524
  tx.relate(
485
525
  ensureRecordId(from as RecordIdInput),
package/src/db/tables.ts CHANGED
@@ -7,8 +7,15 @@ export const TABLES = {
7
7
  MEMORY_RELATION: 'memoryRelation',
8
8
  MEMORY_HISTORY: 'memoryHistory',
9
9
  LEARNED_SKILL: 'learnedSkill',
10
- PLAN: 'plan',
11
- PLAN_TASK: 'planTask',
10
+ PLAN_SPEC: 'planSpec',
11
+ PLAN_NODE_SPEC: 'planNodeSpec',
12
+ PLAN_RUN: 'planRun',
13
+ PLAN_NODE_RUN: 'planNodeRun',
14
+ PLAN_NODE_ATTEMPT: 'planNodeAttempt',
15
+ PLAN_ARTIFACT: 'planArtifact',
16
+ PLAN_APPROVAL: 'planApproval',
17
+ PLAN_CHECKPOINT: 'planCheckpoint',
18
+ PLAN_VALIDATION_ISSUE: 'planValidationIssue',
12
19
  PLAN_EVENT: 'planEvent',
13
20
  ORGANIZATION: 'organization',
14
21
  ORGANIZATION_MEMBER: 'organizationMember',
@@ -1,10 +1,23 @@
1
1
  import { embed, embedMany } from 'ai'
2
2
 
3
+ import { getEmbeddingCache } from '../ai/embedding-cache'
3
4
  import { bifrostEmbeddingModel } from '../bifrost/bifrost'
4
5
  import { env } from '../config/env-shapes'
5
6
 
6
7
  const SUPPORTED_EMBEDDING_PREFIXES = ['openai/', 'openrouter/'] as const
7
8
 
9
+ type SharedEmbeddingCache = {
10
+ get(model: string, text: string): Promise<number[] | null>
11
+ set(model: string, text: string, embedding: number[]): Promise<void>
12
+ }
13
+
14
+ type ProviderEmbeddingsOptions = {
15
+ embedFn?: typeof embed
16
+ embedManyFn?: typeof embedMany
17
+ getCache?: () => SharedEmbeddingCache | null
18
+ modelId?: string
19
+ }
20
+
8
21
  function resolveEmbeddingModel(modelId: string) {
9
22
  const normalized = modelId.trim()
10
23
  if (!normalized) {
@@ -20,23 +33,54 @@ function resolveEmbeddingModel(modelId: string) {
20
33
  return bifrostEmbeddingModel(normalized)
21
34
  }
22
35
 
23
- class ProviderEmbeddings {
36
+ function normalizeEmbedding(embedding: readonly number[]): number[] {
37
+ return embedding.map((value) => Number(value))
38
+ }
39
+
40
+ export class ProviderEmbeddings {
41
+ private readonly embedFn: typeof embed
42
+ private readonly embedManyFn: typeof embedMany
43
+ private readonly getCache: () => SharedEmbeddingCache | null
44
+ private readonly modelId: string
24
45
  private _model: ReturnType<typeof resolveEmbeddingModel> | null = null
25
46
 
47
+ constructor(options: ProviderEmbeddingsOptions = {}) {
48
+ this.embedFn = options.embedFn ?? embed
49
+ this.embedManyFn = options.embedManyFn ?? embedMany
50
+ this.getCache = options.getCache ?? getEmbeddingCache
51
+ this.modelId = options.modelId ?? env.AI_EMBEDDING_MODEL
52
+ }
53
+
26
54
  private getModel() {
27
55
  if (!this._model) {
28
- this._model = resolveEmbeddingModel(env.AI_EMBEDDING_MODEL)
56
+ this._model = resolveEmbeddingModel(this.modelId)
29
57
  }
30
58
  return this._model
31
59
  }
32
60
 
61
+ private async loadCachedEmbedding(text: string): Promise<number[] | null> {
62
+ const redisCache = this.getCache()
63
+ if (!redisCache) return null
64
+
65
+ return await redisCache.get(this.modelId, text)
66
+ }
67
+
33
68
  async embedQuery(text: string): Promise<number[]> {
34
69
  const input = text.trim()
35
70
  if (!input) return []
36
71
 
37
- const result = await embed({ model: this.getModel(), value: input, maxRetries: 2 })
72
+ const cached = await this.loadCachedEmbedding(input)
73
+ if (cached) return cached
74
+
75
+ const result = await this.embedFn({ model: this.getModel(), value: input, maxRetries: 2 })
76
+ const embedding = normalizeEmbedding(result.embedding)
77
+
78
+ const redisCache = this.getCache()
79
+ if (redisCache) {
80
+ void redisCache.set(this.modelId, input, embedding)
81
+ }
38
82
 
39
- return result.embedding.map((value) => Number(value))
83
+ return embedding
40
84
  }
41
85
 
42
86
  async embedDocuments(values: string[]): Promise<number[][]> {
@@ -51,26 +95,53 @@ class ProviderEmbeddings {
51
95
  return normalized.map(() => [])
52
96
  }
53
97
 
54
- const result = await embedMany({
55
- model: this.getModel(),
56
- values: nonEmptyEntries.map((entry) => entry.value),
57
- maxRetries: 2,
58
- })
59
-
60
- const embeddingsByIndex = new Map<number, number[]>()
61
- result.embeddings.forEach((embedding, index) => {
62
- const entry = nonEmptyEntries.at(index)
63
- if (!entry) return
64
- embeddingsByIndex.set(
65
- entry.index,
66
- embedding.map((value) => Number(value)),
98
+ const uniqueTexts = [...new Set(nonEmptyEntries.map((entry) => entry.value))]
99
+ const embeddingsByText = new Map<string, number[]>()
100
+ let missingTexts = [...uniqueTexts]
101
+
102
+ const redisCache = this.getCache()
103
+ if (redisCache && missingTexts.length > 0) {
104
+ const redisResults = await Promise.all(
105
+ missingTexts.map(async (text) => ({ text, embedding: await redisCache.get(this.modelId, text) })),
67
106
  )
68
- })
69
107
 
70
- return normalized.map((_, index) => embeddingsByIndex.get(index) ?? [])
108
+ missingTexts = []
109
+ for (const result of redisResults) {
110
+ if (!result.embedding) {
111
+ missingTexts.push(result.text)
112
+ continue
113
+ }
114
+
115
+ embeddingsByText.set(result.text, result.embedding)
116
+ }
117
+ }
118
+
119
+ if (missingTexts.length > 0) {
120
+ const result = await this.embedManyFn({ model: this.getModel(), values: missingTexts, maxRetries: 2 })
121
+
122
+ missingTexts.forEach((text, index) => {
123
+ const embedding = normalizeEmbedding(result.embeddings[index] ?? [])
124
+ embeddingsByText.set(text, embedding)
125
+ if (redisCache) {
126
+ void redisCache.set(this.modelId, text, embedding)
127
+ }
128
+ })
129
+ }
130
+
131
+ return normalized.map((text) => (text ? (embeddingsByText.get(text) ?? []) : []))
132
+ }
133
+ }
134
+
135
+ let defaultEmbeddings: ProviderEmbeddings | null = null
136
+
137
+ export function getDefaultEmbeddings(): ProviderEmbeddings {
138
+ if (!defaultEmbeddings) {
139
+ defaultEmbeddings = new ProviderEmbeddings()
71
140
  }
141
+
142
+ return defaultEmbeddings
72
143
  }
73
144
 
74
- export function createDefaultEmbeddings(): ProviderEmbeddings {
75
- return new ProviderEmbeddings()
145
+ export function resetDefaultEmbeddingsForTests(): void {
146
+ defaultEmbeddings = null
76
147
  }
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ import type { workstreamMessageService } from './services/workstream-message.ser
32
32
  import type { workstreamTitleService } from './services/workstream-title.service'
33
33
  import type {
34
34
  createWorkstreamApprovalContinuationStream,
35
+ createWorkstreamNativeToolApprovalStream,
35
36
  createWorkstreamTurnStream,
36
37
  runWorkstreamTurnInBackground,
37
38
  } from './services/workstream-turn'
@@ -124,6 +125,7 @@ export interface LotaRuntime {
124
125
  workstreamService: typeof workstreamService
125
126
  workstreamTitleService: typeof workstreamTitleService
126
127
  createWorkstreamApprovalContinuationStream: typeof createWorkstreamApprovalContinuationStream
128
+ createWorkstreamNativeToolApprovalStream: typeof createWorkstreamNativeToolApprovalStream
127
129
  createWorkstreamTurnStream: typeof createWorkstreamTurnStream
128
130
  isApprovalContinuationRequest: typeof isApprovalContinuationRequest
129
131
  runWorkstreamTurnInBackground: typeof runWorkstreamTurnInBackground
@@ -291,6 +293,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
291
293
  const { workstreamTitleService } = await import('./services/workstream-title.service')
292
294
  const {
293
295
  createWorkstreamApprovalContinuationStream,
296
+ createWorkstreamNativeToolApprovalStream,
294
297
  createWorkstreamTurnStream,
295
298
  isApprovalContinuationRequest,
296
299
  runWorkstreamTurnInBackground,
@@ -431,6 +434,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
431
434
  workstreamService,
432
435
  workstreamTitleService,
433
436
  createWorkstreamApprovalContinuationStream,
437
+ createWorkstreamNativeToolApprovalStream,
434
438
  createWorkstreamTurnStream,
435
439
  isApprovalContinuationRequest,
436
440
  runWorkstreamTurnInBackground,
@@ -495,5 +499,7 @@ function createPluginDatabaseConnector(pluginRuntime: Record<string, LotaPlugin>
495
499
  export type { CoreWorkstreamProfile } from './config/agent-defaults'
496
500
  export type { SurrealDBService } from './db/service'
497
501
  export type { RedisConnectionManager } from './redis/connection'
502
+ export { createWorkstreamResumableContext } from './redis/stream-context'
498
503
  export type { LotaPlugin, LotaPluginContributions } from './runtime/plugin-types'
499
504
  export type { LotaRuntimeAdapters, LotaRuntimeTurnHooks } from './runtime/runtime-extensions'
505
+ export { createLogHelloWorldTool } from './tools/log-hello-world.tool'
@@ -0,0 +1,44 @@
1
+ import type { Redis } from 'ioredis'
2
+ import type { Publisher, Subscriber } from 'resumable-stream/ioredis'
3
+ import { createResumableStreamContext } from 'resumable-stream/ioredis'
4
+
5
+ import { getRedisConnection } from './index'
6
+
7
+ function toSubscriber(client: Redis): Subscriber {
8
+ const handlers = new Map<string, (message: string) => void>()
9
+ const messageListener = (channel: string, message: string) => {
10
+ handlers.get(channel)?.(message)
11
+ }
12
+ return {
13
+ connect: () => Promise.resolve(),
14
+ subscribe: async (channel, callback) => {
15
+ if (handlers.size === 0) client.on('message', messageListener)
16
+ handlers.set(channel, callback)
17
+ await client.subscribe(channel)
18
+ },
19
+ unsubscribe: async (channel) => {
20
+ handlers.delete(channel)
21
+ if (handlers.size === 0) client.removeListener('message', messageListener)
22
+ return client.unsubscribe(channel)
23
+ },
24
+ }
25
+ }
26
+
27
+ function toPublisher(client: Redis): Publisher {
28
+ return {
29
+ connect: () => Promise.resolve(),
30
+ publish: (channel, message) => client.publish(channel, message),
31
+ set: (key, value, options) => (options?.EX ? client.set(key, value, 'EX', options.EX) : client.set(key, value)),
32
+ get: (key) => client.get(key),
33
+ incr: (key) => client.incr(key),
34
+ }
35
+ }
36
+
37
+ export function createWorkstreamResumableContext() {
38
+ const redis = getRedisConnection()
39
+ return createResumableStreamContext({
40
+ waitUntil: null,
41
+ subscriber: toSubscriber(redis.duplicate()),
42
+ publisher: toPublisher(redis),
43
+ })
44
+ }
@@ -4,6 +4,44 @@ export function hasApprovalRespondedParts(message: ChatMessage): boolean {
4
4
  return message.parts.some((part) => 'state' in part && (part as { state: string }).state === 'approval-responded')
5
5
  }
6
6
 
7
+ export interface ApprovalContinuationResponse {
8
+ approvalId: string
9
+ approved: boolean
10
+ comments?: string
11
+ requiredEdits: string[]
12
+ }
13
+
14
+ export function readApprovalContinuationResponse(message: ChatMessage): ApprovalContinuationResponse | null {
15
+ for (const part of message.parts) {
16
+ if (!('state' in part) || (part as { state: string }).state !== 'approval-responded') continue
17
+ const approval = (part as { approval?: unknown }).approval
18
+ if (!approval || typeof approval !== 'object') continue
19
+
20
+ const record = approval as {
21
+ id?: unknown
22
+ approved?: unknown
23
+ reason?: unknown
24
+ comments?: unknown
25
+ requiredEdits?: unknown
26
+ }
27
+ if (typeof record.id !== 'string' || typeof record.approved !== 'boolean') continue
28
+
29
+ const requiredEdits = Array.isArray(record.requiredEdits)
30
+ ? record.requiredEdits.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
31
+ : []
32
+ const comments =
33
+ typeof record.comments === 'string' && record.comments.trim().length > 0
34
+ ? record.comments.trim()
35
+ : typeof record.reason === 'string' && record.reason.trim().length > 0
36
+ ? record.reason.trim()
37
+ : undefined
38
+
39
+ return { approvalId: record.id, approved: record.approved, comments, requiredEdits }
40
+ }
41
+
42
+ return null
43
+ }
44
+
7
45
  export function isApprovalContinuationRequest(messages: ChatMessage[]): boolean {
8
46
  const lastAssistant = [...messages].reverse().find((message) => message.role === 'assistant')
9
47
  if (!lastAssistant) return false
@@ -14,3 +52,24 @@ export function isApprovalContinuationRequest(messages: ChatMessage[]): boolean
14
52
 
15
53
  return hasApprovalRespondedParts(lastAssistant)
16
54
  }
55
+
56
+ const PLAN_TOOL_NAMES = new Set([
57
+ 'createExecutionPlan',
58
+ 'replaceExecutionPlan',
59
+ 'submitExecutionNodeResult',
60
+ 'getActiveExecutionPlan',
61
+ 'resumeExecutionPlanRun',
62
+ ])
63
+
64
+ export function isNativeToolApprovalRequest(messages: ChatMessage[]): boolean {
65
+ const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant')
66
+ if (!lastAssistant) return false
67
+
68
+ return lastAssistant.parts.some((part) => {
69
+ if (!('state' in part) || (part as { state: string }).state !== 'approval-responded') return false
70
+ const type = (part as { type?: string }).type
71
+ if (typeof type !== 'string' || !type.startsWith('tool-')) return false
72
+ const toolName = type.slice(5)
73
+ return !PLAN_TOOL_NAMES.has(toolName)
74
+ })
75
+ }
@@ -1,14 +1,18 @@
1
1
  import type { ChatMessage } from '@lota-sdk/shared/schemas/chat-message'
2
2
 
3
- import { isApprovalContinuationRequest } from './approval-continuation'
3
+ import { isApprovalContinuationRequest, isNativeToolApprovalRequest } from './approval-continuation'
4
4
 
5
5
  export type RoutedChatRequest =
6
6
  | { kind: 'approval-continuation'; approvalMessages: ChatMessage[] }
7
+ | { kind: 'native-tool-approval'; messages: ChatMessage[] }
7
8
  | { kind: 'turn'; inputMessage: ChatMessage }
8
9
  | { kind: 'invalid'; message: string }
9
10
 
10
11
  export function routeWorkstreamChatMessages(messages: ChatMessage[]): RoutedChatRequest {
11
12
  if (isApprovalContinuationRequest(messages)) {
13
+ if (isNativeToolApprovalRequest(messages)) {
14
+ return { kind: 'native-tool-approval', messages }
15
+ }
12
16
  return { kind: 'approval-continuation', approvalMessages: messages }
13
17
  }
14
18
 
@@ -1,14 +1,13 @@
1
1
  import type { SerializableExecutionPlan } from '@lota-sdk/shared/schemas/execution-plan'
2
2
 
3
3
  export const EXECUTION_PLAN_AGENT_PROTOCOL_PROMPT = `<execution-plan-protocol>
4
- - Before doing multi-step work, create an execution plan instead of tracking steps only in prose.
5
- - Keep plans short and operational. Prefer 2 to 7 tasks unless the work truly needs more.
6
- - Use execution-plan tools to create, replace, update, inspect, and restart the plan.
7
- - Only one execution-plan task may be active at a time.
8
- - Every task should have a concrete rationale and a concise output summary when work happens.
9
- - Treat prior task output summaries and carried tasks in <execution-plan-state> as observed facts.
10
- - If the ordered steps materially change, replace the plan instead of silently rewriting it in prose.
11
- - If the active plan is blocked, do not continue blindly. Replan, restart a task, ask for user input, or abort.
4
+ - Before doing multi-step work, create a contract-driven execution plan instead of tracking steps only in prose.
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
+ - The runtime executor owns lifecycle truth. Do not claim that a node is complete until submitExecutionNodeResult succeeds.
7
+ - Use execution-plan tools to create, replace, inspect, submit node results, and resume runs.
8
+ - Treat the active execution run in <execution-plan-state> as authoritative. Do not mutate run or node status in prose.
9
+ - Work only on nodes that are active or explicitly ready for your executor. If a node is awaiting human input or approval, stop and let the runtime resume it.
10
+ - If the graph, contracts, or success criteria materially change, replace the plan instead of silently drifting.
12
11
  </execution-plan-protocol>`
13
12
 
14
13
  export function formatExecutionPlanForPrompt(plan: SerializableExecutionPlan | null | undefined): string | undefined {
@@ -16,11 +15,11 @@ export function formatExecutionPlanForPrompt(plan: SerializableExecutionPlan | n
16
15
 
17
16
  const payload = {
18
17
  policy: {
19
- activePlanIsAuthoritative: true,
20
- priorOutputSummariesAreObservedFacts: true,
21
- singleActiveTask: true,
22
- explicitReplanRequired: true,
23
- failureBudget: 2,
18
+ executorOwnsLifecycleTruth: true,
19
+ contractDrivenExecution: true,
20
+ humanGatesAreDurable: true,
21
+ artifactsAreFirstClassOutputs: true,
22
+ checkpointRecoveryEnabled: true,
24
23
  },
25
24
  plan,
26
25
  }
@@ -43,16 +42,24 @@ export function createExecutionPlanInstructionSectionCache(params: {
43
42
  disabled?: boolean
44
43
  loadPlan: () => Promise<SerializableExecutionPlan | null | undefined>
45
44
  }) {
45
+ let planPromise: Promise<SerializableExecutionPlan | null | undefined> | null = null
46
46
  let sectionsPromise: Promise<string[] | undefined> | null = null
47
47
 
48
48
  return {
49
49
  invalidate() {
50
+ planPromise = null
50
51
  sectionsPromise = null
51
52
  },
53
+ async getPlan(): Promise<SerializableExecutionPlan | null | undefined> {
54
+ if (params.disabled) return undefined
55
+
56
+ planPromise ??= params.loadPlan()
57
+ return await planPromise
58
+ },
52
59
  async getSections(): Promise<string[] | undefined> {
53
60
  if (params.disabled) return undefined
54
61
 
55
- sectionsPromise ??= params.loadPlan().then((plan) => buildExecutionPlanInstructionSections(plan))
62
+ sectionsPromise ??= this.getPlan().then((plan) => buildExecutionPlanInstructionSections(plan))
56
63
  return await sectionsPromise
57
64
  },
58
65
  }
@@ -2,27 +2,35 @@ import type { ChatMessage } from '@lota-sdk/shared/schemas/chat-message'
2
2
 
3
3
  export async function finalizeTurnRun(params: {
4
4
  serverRunId: string
5
- getEntity: () => Promise<{ lastCompactedMessageId?: string | null; chatSummary?: string | null }>
5
+ getEntity: () => Promise<{ lastCompactedMessageId?: string | null; compactionSummary?: string | null }>
6
6
  getUncompactedMessages: (cursor?: string) => Promise<ChatMessage[]>
7
7
  assessCompaction: (summaryText: string, messages: ChatMessage[]) => { shouldCompact: boolean }
8
8
  enqueueCompaction: () => Promise<void>
9
9
  unregisterRun: (runId: string) => void
10
10
  clearActiveRunId: (runId: string) => Promise<void>
11
11
  disposeAbort: () => void
12
+ activeStreamId?: string
13
+ clearActiveStreamId?: (streamId: string) => Promise<void>
12
14
  }): Promise<void> {
15
+ // Clear the active run immediately so new messages are not blocked
16
+ params.unregisterRun(params.serverRunId)
17
+ await params.clearActiveRunId(params.serverRunId)
18
+ if (params.activeStreamId && params.clearActiveStreamId) {
19
+ await params.clearActiveStreamId(params.activeStreamId)
20
+ }
21
+ params.disposeAbort()
22
+
13
23
  try {
14
24
  const entity = await params.getEntity()
15
25
  const cursor = typeof entity.lastCompactedMessageId === 'string' ? entity.lastCompactedMessageId : undefined
16
26
  const uncompactedMessages = await params.getUncompactedMessages(cursor)
17
- const summaryText = typeof entity.chatSummary === 'string' ? entity.chatSummary : ''
27
+ const summaryText = typeof entity.compactionSummary === 'string' ? entity.compactionSummary : ''
18
28
  const { shouldCompact } = params.assessCompaction(summaryText, uncompactedMessages)
19
29
 
20
30
  if (shouldCompact) {
21
31
  await params.enqueueCompaction()
22
32
  }
23
- } finally {
24
- params.unregisterRun(params.serverRunId)
25
- await params.clearActiveRunId(params.serverRunId)
26
- params.disposeAbort()
33
+ } catch {
34
+ // compaction assessment errors should not surface to callers
27
35
  }
28
36
  }
@@ -121,14 +121,14 @@ export function buildAgentHistoryMessages(messages: ChatMessageLike[]): Array<{
121
121
  .map((message) => ({ content: message.content, ...(message.agentName ? { agentName: message.agentName } : {}) }))
122
122
  }
123
123
 
124
- export function appendCompactionContextToHistoryMessages(
124
+ export function appendPersistedWorkstreamContextToHistoryMessages(
125
125
  historyMessages: WorkstreamHistoryMessage[],
126
- params: { chatSummary?: string | null; persistedState?: unknown },
126
+ params: { compactionSummary?: string | null; persistedState?: unknown },
127
127
  ): WorkstreamHistoryMessage[] {
128
128
  const nextHistoryMessages = [...historyMessages]
129
- const chatSummary = typeof params.chatSummary === 'string' ? params.chatSummary.trim() : ''
130
- if (chatSummary) {
131
- nextHistoryMessages.push({ role: 'agent', content: `Compacted chat summary:\n${chatSummary}` })
129
+ const compactionSummary = typeof params.compactionSummary === 'string' ? params.compactionSummary.trim() : ''
130
+ if (compactionSummary) {
131
+ nextHistoryMessages.push({ role: 'agent', content: `Compacted chat summary:\n${compactionSummary}` })
132
132
  }
133
133
 
134
134
  if (params.persistedState !== undefined && params.persistedState !== null) {
@@ -61,7 +61,7 @@ class ContextCompactionService {
61
61
  )
62
62
 
63
63
  const result = await contextCompactionRuntime.compactHistory({
64
- summaryText: typeof workstream.chatSummary === 'string' ? workstream.chatSummary : '',
64
+ summaryText: typeof workstream.compactionSummary === 'string' ? workstream.compactionSummary : '',
65
65
  liveMessages,
66
66
  tailMessageCount: WORKSTREAM_RAW_TAIL_MESSAGES,
67
67
  contextSize: params.contextSize,
@@ -82,7 +82,11 @@ class ContextCompactionService {
82
82
  await databaseService.update(
83
83
  TABLES.WORKSTREAM,
84
84
  params.workstreamId,
85
- { chatSummary: result.summaryText, lastCompactedMessageId: result.lastCompactedMessageId, state: result.state },
85
+ {
86
+ compactionSummary: result.summaryText,
87
+ lastCompactedMessageId: result.lastCompactedMessageId,
88
+ state: result.state,
89
+ },
86
90
  WorkstreamSchema,
87
91
  )
88
92
 
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'
2
2
 
3
3
  import { chunkMarkdownDocument, chunkPagedDocument, chunkPlainTextDocument } from '../document/org-document-chunking'
4
4
  import type { ParsedDocumentChunk } from '../document/org-document-chunking'
5
- import { createDefaultEmbeddings } from '../embeddings/provider'
5
+ import { getDefaultEmbeddings } from '../embeddings/provider'
6
6
 
7
7
  type DocumentChunkEmbeddings = {
8
8
  embedDocuments(documents: string[]): Promise<number[][]>
@@ -10,7 +10,7 @@ type DocumentChunkEmbeddings = {
10
10
  }
11
11
 
12
12
  function createDocumentChunkEmbeddings(): DocumentChunkEmbeddings {
13
- const embeddings = createDefaultEmbeddings()
13
+ const embeddings = getDefaultEmbeddings()
14
14
 
15
15
  return {
16
16
  embedDocuments: async (documents) => await embeddings.embedDocuments(documents),