@lota-sdk/core 0.1.24 → 0.1.26

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 (74) hide show
  1. package/package.json +2 -2
  2. package/src/ai/definitions.ts +5 -59
  3. package/src/ai-gateway/ai-gateway.ts +36 -28
  4. package/src/ai-gateway/cache-headers.ts +9 -0
  5. package/src/config/model-constants.ts +6 -2
  6. package/src/create-runtime.ts +1 -17
  7. package/src/db/memory-types.ts +13 -8
  8. package/src/db/memory.ts +74 -53
  9. package/src/queues/autonomous-job.queue.ts +1 -8
  10. package/src/queues/context-compaction.queue.ts +2 -2
  11. package/src/queues/index.ts +2 -6
  12. package/src/queues/organization-learning.queue.ts +78 -0
  13. package/src/queues/plan-agent-heartbeat.queue.ts +10 -16
  14. package/src/queues/title-generation.queue.ts +62 -0
  15. package/src/runtime/agent-prompt-context.ts +0 -18
  16. package/src/runtime/agent-runtime-policy.ts +9 -2
  17. package/src/runtime/context-compaction-constants.ts +4 -2
  18. package/src/runtime/context-compaction.ts +135 -118
  19. package/src/runtime/memory-pipeline.ts +70 -1
  20. package/src/runtime/memory-prompts-fact.ts +16 -0
  21. package/src/runtime/plugin-resolution.ts +3 -2
  22. package/src/runtime/plugin-types.ts +1 -42
  23. package/src/runtime/post-turn-side-effects.ts +212 -0
  24. package/src/runtime/runtime-config.ts +0 -13
  25. package/src/runtime/runtime-extensions.ts +10 -16
  26. package/src/runtime/runtime-worker-registry.ts +8 -19
  27. package/src/runtime/social-chat-agent-runner.ts +119 -0
  28. package/src/runtime/social-chat-history.ts +110 -0
  29. package/src/runtime/social-chat-prompts.ts +58 -0
  30. package/src/runtime/social-chat.ts +104 -340
  31. package/src/runtime/specialist-runner.ts +18 -0
  32. package/src/runtime/workstream-chat-helpers.ts +19 -0
  33. package/src/runtime/workstream-plan-turn.ts +195 -0
  34. package/src/runtime/workstream-state.ts +11 -8
  35. package/src/runtime/workstream-turn-context.ts +183 -0
  36. package/src/services/autonomous-job.service.ts +1 -8
  37. package/src/services/execution-plan.service.ts +205 -334
  38. package/src/services/index.ts +1 -4
  39. package/src/services/memory.service.ts +54 -44
  40. package/src/services/ownership-dispatcher.service.ts +2 -19
  41. package/src/services/plan-completion-side-effects.ts +80 -0
  42. package/src/services/plan-event-delivery.service.ts +1 -1
  43. package/src/services/plan-executor.service.ts +42 -190
  44. package/src/services/plan-node-spec.ts +60 -0
  45. package/src/services/plan-run-data.ts +88 -0
  46. package/src/services/plan-validator.service.ts +10 -8
  47. package/src/services/workstream-constants.ts +2 -0
  48. package/src/services/workstream-title.service.ts +1 -1
  49. package/src/services/workstream-turn-preparation.service.ts +208 -715
  50. package/src/services/workstream.service.ts +162 -192
  51. package/src/services/workstream.types.ts +12 -44
  52. package/src/system-agents/regular-chat-memory-digest.agent.ts +3 -0
  53. package/src/tools/execution-plan.tool.ts +7 -6
  54. package/src/tools/remember-memory.tool.ts +7 -10
  55. package/src/tools/research-topic.tool.ts +1 -1
  56. package/src/tools/team-think.tool.ts +1 -1
  57. package/src/tools/user-questions.tool.ts +1 -1
  58. package/src/utils/autonomous-job-ids.ts +7 -0
  59. package/src/workers/organization-learning.worker.ts +31 -0
  60. package/src/workers/regular-chat-memory-digest.runner.ts +9 -3
  61. package/src/workers/skill-extraction.runner.ts +2 -2
  62. package/src/queues/recent-activity-title-refinement.queue.ts +0 -30
  63. package/src/queues/regular-chat-memory-digest.config.ts +0 -12
  64. package/src/queues/regular-chat-memory-digest.queue.ts +0 -34
  65. package/src/queues/skill-extraction.config.ts +0 -9
  66. package/src/queues/skill-extraction.queue.ts +0 -27
  67. package/src/queues/workstream-title-generation.queue.ts +0 -33
  68. package/src/services/context-enrichment.service.ts +0 -33
  69. package/src/services/coordination-registry.service.ts +0 -117
  70. package/src/services/domain-agent-executor.service.ts +0 -71
  71. package/src/services/memory-assessment.service.ts +0 -44
  72. package/src/services/playbook-registry.service.ts +0 -67
  73. package/src/workers/regular-chat-memory-digest.worker.ts +0 -22
  74. package/src/workers/skill-extraction.worker.ts +0 -22
@@ -25,6 +25,12 @@ export interface PlanAgentHeartbeatSweepJob {
25
25
  export type PlanAgentHeartbeatJob = PlanAgentHeartbeatWakeJob | PlanAgentHeartbeatSweepJob
26
26
 
27
27
  export const PLAN_AGENT_HEARTBEAT_QUEUE = 'plan-agent-heartbeat'
28
+ const PLAN_AGENT_HEARTBEAT_SWEEP_INTERVAL_MS = 30_000
29
+ const PLAN_AGENT_HEARTBEAT_SWEEP_JOB_ID = 'plan-agent-heartbeat-sweep'
30
+
31
+ async function enqueueDelayedPlanAgentHeartbeatSweep(delayMs = PLAN_AGENT_HEARTBEAT_SWEEP_INTERVAL_MS): Promise<void> {
32
+ await planAgentHeartbeatQueue.enqueue({ type: 'sweep' }, { delay: delayMs, jobId: PLAN_AGENT_HEARTBEAT_SWEEP_JOB_ID })
33
+ }
28
34
 
29
35
  async function processPlanAgentHeartbeatJob(job: Job<PlanAgentHeartbeatJob>): Promise<void> {
30
36
  await databaseService.connect()
@@ -35,6 +41,9 @@ async function processPlanAgentHeartbeatJob(job: Job<PlanAgentHeartbeatJob>): Pr
35
41
  }
36
42
 
37
43
  await planAgentHeartbeatService.sweep({ organizationId: job.data.organizationId })
44
+ if (!job.data.organizationId) {
45
+ await enqueueDelayedPlanAgentHeartbeatSweep()
46
+ }
38
47
  }
39
48
 
40
49
  const planAgentHeartbeatQueue = createQueueFactory<PlanAgentHeartbeatJob>({
@@ -43,8 +52,6 @@ const planAgentHeartbeatQueue = createQueueFactory<PlanAgentHeartbeatJob>({
43
52
  jobName: 'plan-agent-heartbeat-job',
44
53
  concurrency: 2,
45
54
  lockDuration: LONG_JOB_LOCK_DURATION_MS,
46
- stalledInterval: 120_000,
47
- maxStalledCount: 5,
48
55
  defaultJobOptions: { ...DEFAULT_JOB_RETENTION, attempts: 3, backoff: { type: 'exponential', delay: 5_000 } },
49
56
  processor: processPlanAgentHeartbeatJob,
50
57
  })
@@ -72,23 +79,10 @@ export async function enqueuePlanAgentHeartbeatWake(params: {
72
79
  await planAgentHeartbeatQueue.enqueue({ type: 'wake-node', ...params }, { jobId: buildWakeJobId(params) })
73
80
  }
74
81
 
75
- const PLAN_AGENT_HEARTBEAT_SCHEDULER_ID = 'plan-agent-heartbeat-sweep'
76
-
77
- export async function schedulePlanAgentHeartbeatSweep(params?: { everyMs?: number }): Promise<void> {
78
- const everyMs = params?.everyMs ?? 30_000
79
- await planAgentHeartbeatQueue
80
- .getQueue()
81
- .upsertJobScheduler(
82
- PLAN_AGENT_HEARTBEAT_SCHEDULER_ID,
83
- { every: everyMs },
84
- { name: 'plan-agent-heartbeat-job', data: { type: 'sweep' }, opts: { ...DEFAULT_JOB_RETENTION } },
85
- )
86
- }
87
-
88
82
  export function startPlanAgentHeartbeatWorker(options: { registerSignals?: boolean } = {}): WorkerHandle {
89
83
  const handle = planAgentHeartbeatQueue.startWorker(options)
90
84
 
91
- schedulePlanAgentHeartbeatSweep().catch((error: unknown) => {
85
+ enqueueDelayedPlanAgentHeartbeatSweep().catch((error: unknown) => {
92
86
  serverLogger.error`Plan agent heartbeat scheduler setup failed: ${error}`
93
87
  })
94
88
 
@@ -0,0 +1,62 @@
1
+ import type { Job } from 'bullmq'
2
+
3
+ import { ensureRecordId } from '../db/record-id'
4
+ import { databaseService } from '../db/service'
5
+ import { recentActivityTitleService } from '../services/recent-activity-title.service'
6
+ import { workstreamTitleService } from '../services/workstream-title.service'
7
+ import { createQueueFactory } from './queue-factory'
8
+
9
+ export const TITLE_GENERATION_QUEUE = 'title-generation'
10
+
11
+ // This queue merges workstream title generation and recent-activity title
12
+ // refinement because both are short-lived title synthesis jobs with the same
13
+ // operational shape.
14
+
15
+ interface WorkstreamTitleGenerationJob {
16
+ kind: 'workstream-title'
17
+ workstreamId: string
18
+ sourceText: string
19
+ }
20
+
21
+ interface RecentActivityTitleRefinementJob {
22
+ kind: 'recent-activity-title'
23
+ activityId: string
24
+ }
25
+
26
+ type TitleGenerationJob = WorkstreamTitleGenerationJob | RecentActivityTitleRefinementJob
27
+
28
+ async function processTitleGenerationJob(job: Job<TitleGenerationJob>): Promise<void> {
29
+ await databaseService.connect()
30
+ if (job.data.kind === 'workstream-title') {
31
+ await workstreamTitleService.generateAndPersistTitle(ensureRecordId(job.data.workstreamId), job.data.sourceText)
32
+ return
33
+ }
34
+
35
+ await recentActivityTitleService.refineRecentActivityTitle(job.data.activityId)
36
+ }
37
+
38
+ const titleGeneration = createQueueFactory<TitleGenerationJob>({
39
+ name: TITLE_GENERATION_QUEUE,
40
+ displayName: 'Title generation',
41
+ jobName: 'title-generation',
42
+ concurrency: 10,
43
+ lockDuration: 300_000,
44
+ defaultJobOptions: { attempts: 3, backoff: { type: 'exponential', delay: 2_000 } },
45
+ processor: processTitleGenerationJob,
46
+ })
47
+
48
+ export function enqueueWorkstreamTitleGeneration(job: Omit<WorkstreamTitleGenerationJob, 'kind'>) {
49
+ return titleGeneration.enqueue(
50
+ { kind: 'workstream-title', ...job },
51
+ { jobId: `workstream-title:${job.workstreamId}` },
52
+ )
53
+ }
54
+
55
+ export function enqueueRecentActivityTitleRefinement(job: Omit<RecentActivityTitleRefinementJob, 'kind'>) {
56
+ return titleGeneration.enqueue(
57
+ { kind: 'recent-activity-title', ...job },
58
+ { jobId: `recent-activity-title:${job.activityId}` },
59
+ )
60
+ }
61
+
62
+ export const startTitleGenerationWorker = titleGeneration.startWorker
@@ -1,7 +1,5 @@
1
1
  import { toOptionalIsoDateTimeString } from '../utils/date-time'
2
2
 
3
- type StructuredProfile = Record<string, unknown>
4
-
5
3
  interface AgentPromptContext {
6
4
  systemWorkspaceDetails: string
7
5
  }
@@ -18,7 +16,6 @@ interface DomainEventLike {
18
16
  interface BuildAgentPromptContextParams {
19
17
  workspaceName?: string
20
18
  summaryBlock?: string
21
- structuredProfile?: StructuredProfile
22
19
  promptSummary?: string
23
20
  userName?: string | null
24
21
  recentDomainEvents: DomainEventLike[]
@@ -39,18 +36,6 @@ function normalizeWorkspaceName(name?: string): string {
39
36
  return normalized && normalized.length > 0 ? normalized : 'Workspace'
40
37
  }
41
38
 
42
- function hasStructuredProfileData(profile?: StructuredProfile): boolean {
43
- return Boolean(profile && Object.keys(profile).length > 0)
44
- }
45
-
46
- function formatStructuredProfile(profile?: StructuredProfile): string {
47
- if (!hasStructuredProfileData(profile)) {
48
- return 'No structured workspace profile has been recorded yet.'
49
- }
50
-
51
- return JSON.stringify(profile, null, 2)
52
- }
53
-
54
39
  function formatDomainEvent(event: DomainEventLike, index: number): string {
55
40
  const summary = typeof event.summary === 'string' && event.summary.trim().length > 0 ? event.summary.trim() : null
56
41
  if (summary) {
@@ -95,7 +80,6 @@ function formatRecentDomainEvents(events: DomainEventLike[]): string {
95
80
  export function buildAgentPromptContext(params: BuildAgentPromptContextParams): AgentPromptContext {
96
81
  const workspaceName = normalizeWorkspaceName(params.workspaceName)
97
82
  const summaryBlock = normalizeSummaryBlock(params.summaryBlock)
98
- const structuredProfile = formatStructuredProfile(params.structuredProfile)
99
83
  const promptSummary = params.promptSummary?.trim()
100
84
  const userName = normalizeUserName(params.userName)
101
85
  const recentDomainEvents = formatRecentDomainEvents(params.recentDomainEvents)
@@ -105,8 +89,6 @@ export function buildAgentPromptContext(params: BuildAgentPromptContextParams):
105
89
  `name=${workspaceName}`,
106
90
  'summary-block:',
107
91
  summaryBlock,
108
- 'structured-profile-json:',
109
- structuredProfile,
110
92
  ...(promptSummary ? ['prompt-summary:', promptSummary] : []),
111
93
  '</workspace-profile>',
112
94
  ].join('\n')
@@ -17,6 +17,12 @@ export interface AgentRuntimeConfig<TAgent extends string> {
17
17
  maxSteps: number
18
18
  }
19
19
 
20
+ export interface AgentRuntimeRuleOptions {
21
+ includeExecutionPlanRule?: boolean
22
+ includeMemr3Rule?: boolean
23
+ includeDomainReasoningFallbackRule?: boolean
24
+ }
25
+
20
26
  export interface AgentToolPolicy<TSkill extends PropertyKey> {
21
27
  resolvedMode: ChatMode
22
28
  skills: TSkill[]
@@ -129,13 +135,14 @@ export function buildAgentRuntimeConfig<TAgent extends string, TSkill extends Pr
129
135
  responseGuardSection?: string
130
136
  learnedSkillsSection?: string
131
137
  additionalInstructionSections?: string[]
138
+ ruleOptions?: AgentRuntimeRuleOptions
132
139
  getAgentSkills: (agentId: TAgent, mode: ChatMode) => TSkill[]
133
- buildGlobalRuleInstructionSection: () => string
140
+ buildGlobalRuleInstructionSection: (options?: AgentRuntimeRuleOptions) => string
134
141
  buildSkillInstructionSection: (skills: TSkill[]) => string
135
142
  buildOnboardingPromptSection: () => string
136
143
  }): AgentRuntimeConfig<TAgent> {
137
144
  const mode = params.mode ?? toChatMode(params.workstreamMode)
138
- const rulesSection = params.buildGlobalRuleInstructionSection()
145
+ const rulesSection = params.buildGlobalRuleInstructionSection(params.ruleOptions)
139
146
  const skillsSection =
140
147
  params.skills && params.skills.length > 0 ? params.buildSkillInstructionSection(params.skills) : ''
141
148
  const onboardingPromptSection = params.onboardingActive ? params.buildOnboardingPromptSection() : ''
@@ -1,11 +1,13 @@
1
1
  export const CONTEXT_COMPACTION_THRESHOLD_RATIO = 0.7
2
2
  export const CONTEXT_OUTPUT_RESERVE_TOKENS = 32_000
3
3
  export const CONTEXT_SAFETY_MARGIN_TOKENS = 8_000
4
+ // Cap the output reserve against the model context window so oversized windows do not over-reserve.
5
+ export const CONTEXT_OUTPUT_RESERVE_MAX_RATIO = 0.35
6
+ // Keep a fixed guard band for provider-side overhead without letting it dominate larger context windows.
7
+ export const CONTEXT_SAFETY_MARGIN_MAX_RATIO = 0.1
4
8
  export const COMPACTION_CHUNK_MAX_CHARS = 120_000
5
9
  export const SUMMARY_ROLLUP_MAX_TOKENS = 80_000
6
10
  export const CONTEXT_COMPACTION_INCLUDED_TOOL_NAMES = ['userQuestions', 'proceedInOnboarding'] as const
7
11
  export const CONTEXT_COMPACTION_INCLUDED_TOOL_PREFIXES = ['linear'] as const
8
- export const MEMORY_BLOCK_COMPACTION_TRIGGER_ENTRIES = 15
9
- export const MEMORY_BLOCK_COMPACTION_CHUNK_ENTRIES = 10
10
12
  export const CONTEXT_WINDOW_TOKENS = 200_000
11
13
  export const WORKSTREAM_RAW_TAIL_MESSAGES = 6
@@ -5,8 +5,10 @@ import {
5
5
  COMPACTION_CHUNK_MAX_CHARS,
6
6
  CONTEXT_COMPACTION_INCLUDED_TOOL_NAMES,
7
7
  CONTEXT_COMPACTION_INCLUDED_TOOL_PREFIXES,
8
+ CONTEXT_OUTPUT_RESERVE_MAX_RATIO,
8
9
  CONTEXT_COMPACTION_THRESHOLD_RATIO,
9
10
  CONTEXT_OUTPUT_RESERVE_TOKENS,
11
+ CONTEXT_SAFETY_MARGIN_MAX_RATIO,
10
12
  CONTEXT_SAFETY_MARGIN_TOKENS,
11
13
  SUMMARY_ROLLUP_MAX_TOKENS,
12
14
  } from './context-compaction-constants'
@@ -94,74 +96,92 @@ function estimateTokens(text: string): number {
94
96
  return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
95
97
  }
96
98
 
97
- function sanitizeStateText(value: string): string | null {
99
+ function sanitizeStateText(value: string | null | undefined): string | null {
100
+ if (typeof value !== 'string') return null
98
101
  const normalized = compactWhitespace(value)
99
102
  if (!normalized) return null
100
103
  if (PROMPT_INJECTION_PATTERN.test(normalized)) return null
101
104
  return normalized
102
105
  }
103
106
 
107
+ function sanitizeAndMapStateField<T, U>(items: T[], mapItem: (item: T) => U | null): U[] {
108
+ return items.map(mapItem).filter((item): item is U => item !== null)
109
+ }
110
+
111
+ function sanitizeStateStrings(values: Array<string | null | undefined>): string[] {
112
+ return values.map((value) => sanitizeStateText(value)).filter((value): value is string => Boolean(value))
113
+ }
114
+
115
+ function sanitizeTextFieldItems<T extends Record<string, unknown>, K extends keyof T>(items: T[], textField: K): T[] {
116
+ return sanitizeAndMapStateField(items, (item) => {
117
+ const text = sanitizeStateText(item[textField] as string | null | undefined)
118
+ if (!text) return null
119
+ return { ...item, [textField]: text }
120
+ })
121
+ }
122
+
104
123
  function buildExistingWorkstreamStateForCompactionPrompt(state: WorkstreamState): Record<string, unknown> {
105
124
  const currentPlanText = state.currentPlan ? sanitizeStateText(state.currentPlan.text) : null
106
125
 
107
- const activeConstraints = state.activeConstraints
108
- .map((constraint) => ({
126
+ const activeConstraints = sanitizeAndMapStateField(state.activeConstraints, (constraint) => {
127
+ const text = sanitizeStateText(constraint.text)
128
+ if (!text) return null
129
+ return {
109
130
  id: constraint.id,
110
- text: sanitizeStateText(constraint.text),
131
+ text,
111
132
  source: constraint.source,
112
133
  approved: constraint.approved,
113
134
  sourceMessageIds: constraint.sourceMessageIds,
114
- }))
115
- .filter((constraint): constraint is typeof constraint & { text: string } => Boolean(constraint.text))
135
+ }
136
+ })
116
137
 
117
- const keyDecisions = state.keyDecisions
118
- .map((decision) => ({
138
+ const keyDecisions = sanitizeAndMapStateField(state.keyDecisions, (decision) => {
139
+ const normalizedDecision = sanitizeStateText(decision.decision)
140
+ const rationale = sanitizeStateText(decision.rationale)
141
+ if (!normalizedDecision || !rationale) return null
142
+ return {
119
143
  id: decision.id,
120
- decision: sanitizeStateText(decision.decision),
121
- rationale: sanitizeStateText(decision.rationale),
144
+ decision: normalizedDecision,
145
+ rationale,
122
146
  agent: decision.agent,
123
147
  sourceMessageIds: decision.sourceMessageIds,
124
148
  confidence: decision.confidence,
125
- }))
126
- .filter((decision): decision is typeof decision & { decision: string; rationale: string } =>
127
- Boolean(decision.decision && decision.rationale),
128
- )
149
+ }
150
+ })
129
151
 
130
- const tasks = state.tasks
131
- .map((task) => ({
152
+ const tasks = sanitizeAndMapStateField(state.tasks, (task) => {
153
+ const title = sanitizeStateText(task.title)
154
+ const owner = sanitizeStateText(task.owner)
155
+ if (!title || !owner) return null
156
+ return {
132
157
  id: task.id,
133
- title: sanitizeStateText(task.title),
158
+ title,
134
159
  status: task.status,
135
- owner: sanitizeStateText(task.owner),
160
+ owner,
136
161
  externalId: task.externalId,
137
162
  source: task.source,
138
163
  sourceMessageIds: task.sourceMessageIds,
139
- }))
140
- .filter((task): task is typeof task & { title: string; owner: string } => Boolean(task.title && task.owner))
141
-
142
- const openQuestions = state.openQuestions
143
- .map((question) => ({
144
- id: question.id,
145
- text: sanitizeStateText(question.text),
146
- source: question.source,
147
- sourceMessageIds: question.sourceMessageIds,
148
- }))
149
- .filter((question): question is typeof question & { text: string } => Boolean(question.text))
150
-
151
- const artifacts = state.artifacts
152
- .map((artifact) => ({
153
- id: artifact.id,
154
- name: sanitizeStateText(artifact.name),
155
- type: artifact.type,
156
- pointer: sanitizeStateText(artifact.pointer),
157
- }))
158
- .filter((artifact): artifact is typeof artifact & { name: string; pointer: string } =>
159
- Boolean(artifact.name && artifact.pointer),
160
- )
161
-
162
- const agentContributions = state.agentContributions
163
- .map((note) => ({ id: note.id, agent: note.agent, summary: sanitizeStateText(note.summary) }))
164
- .filter((note): note is typeof note & { summary: string } => Boolean(note.summary))
164
+ }
165
+ })
166
+
167
+ const openQuestions = sanitizeAndMapStateField(state.openQuestions, (question) => {
168
+ const text = sanitizeStateText(question.text)
169
+ if (!text) return null
170
+ return { id: question.id, text, source: question.source, sourceMessageIds: question.sourceMessageIds }
171
+ })
172
+
173
+ const artifacts = sanitizeAndMapStateField(state.artifacts, (artifact) => {
174
+ const name = sanitizeStateText(artifact.name)
175
+ const pointer = sanitizeStateText(artifact.pointer)
176
+ if (!name || !pointer) return null
177
+ return { id: artifact.id, name, type: artifact.type, pointer }
178
+ })
179
+
180
+ const agentContributions = sanitizeAndMapStateField(state.agentContributions, (note) => {
181
+ const summary = sanitizeStateText(note.summary)
182
+ if (!summary) return null
183
+ return { id: note.id, agent: note.agent, summary }
184
+ })
165
185
 
166
186
  return {
167
187
  currentPlan: currentPlanText
@@ -177,7 +197,7 @@ function buildExistingWorkstreamStateForCompactionPrompt(state: WorkstreamState)
177
197
  keyDecisions,
178
198
  tasks,
179
199
  openQuestions,
180
- risks: state.risks.map((risk) => sanitizeStateText(risk)).filter((risk): risk is string => Boolean(risk)),
200
+ risks: sanitizeStateStrings(state.risks),
181
201
  artifacts,
182
202
  agentContributions,
183
203
  approvedBy: state.approvedBy ? sanitizeStateText(state.approvedBy) : undefined,
@@ -323,24 +343,21 @@ function splitByCharBudget(messages: ContextMessage[], maxChars: number): Contex
323
343
  }
324
344
 
325
345
  function mergeDelta(base: WorkstreamStateDelta, next: WorkstreamStateDelta): WorkstreamStateDelta {
346
+ const mergedConstraints = appendUnique(base.newConstraints ?? [], next.newConstraints ?? [])
347
+ const mergedRisks = appendUnique(base.newRisks ?? [], next.newRisks ?? [])
348
+ const mergedQuestions = appendUnique(base.newQuestions ?? [], next.newQuestions ?? [])
349
+ const mergedResolvedQuestions = appendUnique(base.resolvedQuestions ?? [], next.resolvedQuestions ?? [])
350
+
326
351
  return {
327
352
  ...(next.currentPlan !== undefined
328
353
  ? { currentPlan: next.currentPlan }
329
354
  : base.currentPlan !== undefined
330
355
  ? { currentPlan: base.currentPlan }
331
356
  : {}),
332
- ...(appendUnique(base.newConstraints ?? [], next.newConstraints ?? []).length > 0
333
- ? { newConstraints: appendUnique(base.newConstraints ?? [], next.newConstraints ?? []) }
334
- : {}),
335
- ...(appendUnique(base.newRisks ?? [], next.newRisks ?? []).length > 0
336
- ? { newRisks: appendUnique(base.newRisks ?? [], next.newRisks ?? []) }
337
- : {}),
338
- ...(appendUnique(base.newQuestions ?? [], next.newQuestions ?? []).length > 0
339
- ? { newQuestions: appendUnique(base.newQuestions ?? [], next.newQuestions ?? []) }
340
- : {}),
341
- ...(appendUnique(base.resolvedQuestions ?? [], next.resolvedQuestions ?? []).length > 0
342
- ? { resolvedQuestions: appendUnique(base.resolvedQuestions ?? [], next.resolvedQuestions ?? []) }
343
- : {}),
357
+ ...(mergedConstraints.length > 0 ? { newConstraints: mergedConstraints } : {}),
358
+ ...(mergedRisks.length > 0 ? { newRisks: mergedRisks } : {}),
359
+ ...(mergedQuestions.length > 0 ? { newQuestions: mergedQuestions } : {}),
360
+ ...(mergedResolvedQuestions.length > 0 ? { resolvedQuestions: mergedResolvedQuestions } : {}),
344
361
  ...((base.newDecisions?.length ?? 0) + (next.newDecisions?.length ?? 0) > 0
345
362
  ? { newDecisions: [...(base.newDecisions ?? []), ...(next.newDecisions ?? [])] }
346
363
  : {}),
@@ -538,7 +555,7 @@ export function mergeStateDelta(
538
555
  state.approvedBy = sanitizeStateText(delta.approvedBy) ?? undefined
539
556
  }
540
557
  if (delta.approvedAt !== undefined) {
541
- state.approvedAt = delta.approvedAt
558
+ state.approvedAt = delta.approvedAt ?? undefined
542
559
  }
543
560
  if (delta.approvalMessageId !== undefined) {
544
561
  state.approvalMessageId = sanitizeStateText(delta.approvalMessageId) ?? undefined
@@ -678,54 +695,44 @@ export function createContextCompactionRuntime(
678
695
  const candidatePlan =
679
696
  state.currentPlan && !state.currentPlan.approved ? sanitizeStateText(state.currentPlan.text) : null
680
697
 
681
- const approvedConstraints = state.activeConstraints
682
- .filter((constraint) => constraint.approved)
683
- .map((constraint) => ({ ...constraint, text: sanitizeStateText(constraint.text) }))
684
- .filter((constraint): constraint is typeof constraint & { text: string } => Boolean(constraint.text))
685
-
686
- const candidateConstraints = state.activeConstraints
687
- .filter((constraint) => !constraint.approved)
688
- .map((constraint) => ({ ...constraint, text: sanitizeStateText(constraint.text) }))
689
- .filter((constraint): constraint is typeof constraint & { text: string } => Boolean(constraint.text))
690
-
691
- const openQuestions = state.openQuestions
692
- .map((question) => sanitizeStateText(question.text))
693
- .filter((question): question is string => Boolean(question))
694
-
695
- const decisions = state.keyDecisions
696
- .map((decision) => ({
697
- agent: decision.agent,
698
- decision: sanitizeStateText(decision.decision),
699
- rationale: sanitizeStateText(decision.rationale),
700
- confidence: decision.confidence,
701
- }))
702
- .filter((decision): decision is typeof decision & { decision: string; rationale: string } =>
703
- Boolean(decision.decision && decision.rationale),
704
- )
705
-
706
- const tasks = state.tasks
707
- .map((task) => ({
708
- title: sanitizeStateText(task.title),
709
- status: task.status,
710
- owner: sanitizeStateText(task.owner),
711
- externalId: task.externalId,
712
- source: task.source,
713
- }))
714
- .filter((task): task is typeof task & { title: string; owner: string } => Boolean(task.title && task.owner))
715
-
716
- const artifacts = state.artifacts
717
- .map((artifact) => ({
718
- name: sanitizeStateText(artifact.name),
719
- type: artifact.type,
720
- pointer: sanitizeStateText(artifact.pointer),
721
- }))
722
- .filter((artifact): artifact is typeof artifact & { name: string; pointer: string } =>
723
- Boolean(artifact.name && artifact.pointer),
724
- )
725
-
726
- const agentContributions = state.agentContributions
727
- .map((note) => ({ agent: note.agent, summary: sanitizeStateText(note.summary) }))
728
- .filter((note): note is typeof note & { summary: string } => Boolean(note.summary))
698
+ const approvedConstraints = sanitizeTextFieldItems(
699
+ state.activeConstraints.filter((constraint) => constraint.approved),
700
+ 'text',
701
+ )
702
+
703
+ const candidateConstraints = sanitizeTextFieldItems(
704
+ state.activeConstraints.filter((constraint) => !constraint.approved),
705
+ 'text',
706
+ )
707
+
708
+ const openQuestions = sanitizeStateStrings(state.openQuestions.map((question) => question.text))
709
+
710
+ const decisions = sanitizeAndMapStateField(state.keyDecisions, (decision) => {
711
+ const normalizedDecision = sanitizeStateText(decision.decision)
712
+ const rationale = sanitizeStateText(decision.rationale)
713
+ if (!normalizedDecision || !rationale) return null
714
+ return { agent: decision.agent, decision: normalizedDecision, rationale, confidence: decision.confidence }
715
+ })
716
+
717
+ const tasks = sanitizeAndMapStateField(state.tasks, (task) => {
718
+ const title = sanitizeStateText(task.title)
719
+ const owner = sanitizeStateText(task.owner)
720
+ if (!title || !owner) return null
721
+ return { title, status: task.status, owner, externalId: task.externalId, source: task.source }
722
+ })
723
+
724
+ const artifacts = sanitizeAndMapStateField(state.artifacts, (artifact) => {
725
+ const name = sanitizeStateText(artifact.name)
726
+ const pointer = sanitizeStateText(artifact.pointer)
727
+ if (!name || !pointer) return null
728
+ return { name, type: artifact.type, pointer }
729
+ })
730
+
731
+ const agentContributions = sanitizeAndMapStateField(state.agentContributions, (note) => {
732
+ const summary = sanitizeStateText(note.summary)
733
+ if (!summary) return null
734
+ return { agent: note.agent, summary }
735
+ })
729
736
 
730
737
  const payload = {
731
738
  policy: { approvedConstraintsAreBinding: true, candidateStateIsAdvisoryOnly: true },
@@ -747,7 +754,7 @@ export function createContextCompactionRuntime(
747
754
  },
748
755
  decisions,
749
756
  openQuestions,
750
- risks: state.risks.map((risk) => sanitizeStateText(risk)).filter((risk): risk is string => Boolean(risk)),
757
+ risks: sanitizeStateStrings(state.risks),
751
758
  tasks,
752
759
  artifacts,
753
760
  agentContributions,
@@ -777,9 +784,6 @@ export function createContextCompactionRuntime(
777
784
  return summaryMessage ? [summaryMessage, ...liveMessages] : [...liveMessages]
778
785
  }
779
786
 
780
- const CONTEXT_OUTPUT_RESERVE_MAX_RATIO = 0.35
781
- const CONTEXT_SAFETY_MARGIN_MAX_RATIO = 0.1
782
-
783
787
  const estimateThreshold = (contextSize = 256_000): number => {
784
788
  const reservedOutput = Math.min(outputReserveTokens, Math.floor(contextSize * CONTEXT_OUTPUT_RESERVE_MAX_RATIO))
785
789
  const safetyMargin = Math.min(safetyMarginTokens, Math.floor(contextSize * CONTEXT_SAFETY_MARGIN_MAX_RATIO))
@@ -827,6 +831,23 @@ export function createContextCompactionRuntime(
827
831
  return { summary, stateDelta: delta }
828
832
  }
829
833
 
834
+ const rollupSummaryIfOversized = async (
835
+ summary: string,
836
+ state: WorkstreamState,
837
+ ): Promise<{ summary: string; state: WorkstreamState; output: CompactionOutput }> => {
838
+ if (estimateTokens(summary) <= summaryRollupMaxTokens) {
839
+ return { summary, state, output: { summary, stateDelta: {} } }
840
+ }
841
+
842
+ const output = await compactContextMessages({
843
+ previousSummary: '',
844
+ existingState: state,
845
+ newMessages: [{ role: 'assistant', text: summary, sourceMessageId: 'summary-rollup' }],
846
+ })
847
+
848
+ return { summary: normalizeSummary(output.summary), state: mergeStateDelta(state, output.stateDelta, now), output }
849
+ }
850
+
830
851
  const compactHistory = async (params: CompactHistoryParams): Promise<CompactHistoryResult> => {
831
852
  let summaryText = normalizeSummary(params.summaryText)
832
853
  let remainingMessages = [...params.liveMessages]
@@ -891,14 +912,10 @@ export function createContextCompactionRuntime(
891
912
  let nextState = mergeStateDelta(state, compactionOutput.stateDelta, now)
892
913
 
893
914
  if (estimateTokens(nextSummary) > summaryRollupMaxTokens) {
894
- const rollupOutput = await compactContextMessages({
895
- previousSummary: '',
896
- existingState: nextState,
897
- newMessages: [{ role: 'assistant', text: nextSummary, sourceMessageId: 'summary-rollup' }],
898
- })
899
- nextSummary = normalizeSummary(rollupOutput.summary)
900
- nextState = mergeStateDelta(nextState, rollupOutput.stateDelta, now)
901
- compactionOutput = rollupOutput
915
+ const rollup = await rollupSummaryIfOversized(nextSummary, nextState)
916
+ nextSummary = rollup.summary
917
+ nextState = rollup.state
918
+ compactionOutput = rollup.output
902
919
  }
903
920
 
904
921
  if (nextSummary.length >= sourceText.length) {