@lota-sdk/core 0.3.0 → 0.3.2

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.
@@ -1,75 +1,73 @@
1
- DEFINE TABLE thread SCHEMAFULL;
1
+ DEFINE TABLE IF NOT EXISTS thread SCHEMAFULL;
2
2
 
3
- DEFINE FIELD organizationId ON thread TYPE record<organization>;
4
- DEFINE FIELD userId ON thread TYPE record<user>;
5
- DEFINE FIELD type ON thread TYPE string
3
+ DEFINE FIELD IF NOT EXISTS organizationId ON thread TYPE record<organization>;
4
+ DEFINE FIELD IF NOT EXISTS userId ON thread TYPE record<user>;
5
+ DEFINE FIELD IF NOT EXISTS type ON thread TYPE string
6
6
  ASSERT $value IN ['default', 'topic', 'thread', 'group']
7
7
  DEFAULT 'group';
8
- DEFINE FIELD agentId ON thread TYPE option<string>;
9
- DEFINE FIELD threadType ON thread TYPE option<string>;
10
- DEFINE FIELD members ON thread TYPE option<array<string>> DEFAULT [];
11
- DEFINE FIELD title ON thread TYPE option<string>;
12
- DEFINE FIELD nameGenerated ON thread TYPE bool DEFAULT false;
13
- DEFINE FIELD status ON thread TYPE string
8
+ DEFINE FIELD IF NOT EXISTS agentId ON thread TYPE option<string>;
9
+ DEFINE FIELD IF NOT EXISTS threadType ON thread TYPE option<string>;
10
+ DEFINE FIELD IF NOT EXISTS members ON thread TYPE option<array<string>> DEFAULT [];
11
+ DEFINE FIELD IF NOT EXISTS title ON thread TYPE option<string>;
12
+ DEFINE FIELD IF NOT EXISTS nameGenerated ON thread TYPE bool DEFAULT false;
13
+ DEFINE FIELD IF NOT EXISTS status ON thread TYPE string
14
14
  ASSERT $value IN ['active', 'archived']
15
15
  DEFAULT 'active';
16
- DEFINE FIELD activeRunId ON thread TYPE option<string>;
17
- DEFINE FIELD activeStreamId ON thread TYPE option<string>;
18
- DEFINE FIELD memoryBlock ON thread TYPE option<string>;
19
- DEFINE FIELD memoryBlockSummary ON thread TYPE option<string>;
20
- DEFINE FIELD compactionSummary ON thread TYPE option<string>;
21
- DEFINE FIELD lastCompactedMessageId ON thread TYPE option<string>;
22
- DEFINE FIELD isCompacting ON thread TYPE bool DEFAULT false;
23
- DEFINE FIELD turnCount ON thread TYPE int DEFAULT 0;
24
- DEFINE FIELD createdAt ON thread TYPE datetime DEFAULT time::now();
25
- DEFINE FIELD updatedAt ON thread TYPE datetime DEFAULT time::now() VALUE time::now();
16
+ DEFINE FIELD IF NOT EXISTS activeRunId ON thread TYPE option<string>;
17
+ DEFINE FIELD IF NOT EXISTS activeStreamId ON thread TYPE option<string>;
18
+ DEFINE FIELD IF NOT EXISTS memoryBlock ON thread TYPE option<string>;
19
+ DEFINE FIELD IF NOT EXISTS memoryBlockSummary ON thread TYPE option<string>;
20
+ DEFINE FIELD IF NOT EXISTS compactionSummary ON thread TYPE option<string>;
21
+ DEFINE FIELD IF NOT EXISTS lastCompactedMessageId ON thread TYPE option<string>;
22
+ DEFINE FIELD IF NOT EXISTS isCompacting ON thread TYPE bool DEFAULT false;
23
+ DEFINE FIELD IF NOT EXISTS turnCount ON thread TYPE int DEFAULT 0;
24
+ DEFINE FIELD IF NOT EXISTS createdAt ON thread TYPE datetime DEFAULT time::now() READONLY;
25
+ DEFINE FIELD IF NOT EXISTS updatedAt ON thread TYPE datetime DEFAULT time::now() VALUE time::now();
26
26
 
27
- DEFINE INDEX threadDefaultUniqueIdx ON thread
28
- FIELDS userId, organizationId, agentId
29
- WHERE type = 'default' UNIQUE;
27
+ DEFINE INDEX IF NOT EXISTS threadDefaultLookupIdx ON thread
28
+ FIELDS type, userId, organizationId, agentId UNIQUE;
30
29
 
31
- DEFINE INDEX threadThreadUniqueIdx ON thread
32
- FIELDS userId, organizationId, threadType
33
- WHERE type = 'thread' UNIQUE;
30
+ DEFINE INDEX IF NOT EXISTS threadThreadLookupIdx ON thread
31
+ FIELDS type, userId, organizationId, threadType UNIQUE;
34
32
 
35
- DEFINE INDEX threadOrgIdx ON thread FIELDS organizationId;
36
- DEFINE INDEX threadUserIdx ON thread FIELDS userId;
37
- DEFINE INDEX threadUserOrgTypeUpdatedIdx ON thread
33
+ DEFINE INDEX IF NOT EXISTS threadOrgIdx ON thread FIELDS organizationId;
34
+ DEFINE INDEX IF NOT EXISTS threadUserIdx ON thread FIELDS userId;
35
+ DEFINE INDEX IF NOT EXISTS threadUserOrgTypeUpdatedIdx ON thread
38
36
  FIELDS userId, organizationId, type, updatedAt;
39
- DEFINE INDEX threadUserOrgStatusTypeUpdatedIdx ON thread
37
+ DEFINE INDEX IF NOT EXISTS threadUserOrgStatusTypeUpdatedIdx ON thread
40
38
  FIELDS userId, organizationId, status, type, updatedAt;
41
39
 
42
40
  # Thread Message table (AI SDK UIMessage persistence).
43
41
  # parts uses OVERWRITE on the wildcard to override the implicit non-FLEXIBLE
44
42
  # definition that array<object> creates — this is the only way to allow
45
43
  # arbitrary nested object shapes inside the array on SCHEMAFULL tables.
46
- DEFINE TABLE threadMessage SCHEMAFULL;
47
- DEFINE FIELD threadId ON TABLE threadMessage TYPE record<thread> REFERENCE ON DELETE CASCADE;
48
- DEFINE FIELD messageId ON TABLE threadMessage TYPE string;
49
- DEFINE FIELD role ON TABLE threadMessage TYPE string;
50
- DEFINE FIELD parts ON TABLE threadMessage TYPE array<object> FLEXIBLE;
44
+ DEFINE TABLE IF NOT EXISTS threadMessage SCHEMAFULL;
45
+ DEFINE FIELD IF NOT EXISTS threadId ON TABLE threadMessage TYPE record<thread> REFERENCE ON DELETE CASCADE;
46
+ DEFINE FIELD IF NOT EXISTS messageId ON TABLE threadMessage TYPE string;
47
+ DEFINE FIELD IF NOT EXISTS role ON TABLE threadMessage TYPE string;
48
+ DEFINE FIELD IF NOT EXISTS parts ON TABLE threadMessage TYPE array<object> FLEXIBLE;
51
49
  DEFINE FIELD OVERWRITE parts.* ON TABLE threadMessage TYPE object FLEXIBLE;
52
- DEFINE FIELD metadata ON TABLE threadMessage TYPE option<object> FLEXIBLE;
53
- DEFINE FIELD createdAt ON TABLE threadMessage TYPE datetime DEFAULT time::now() READONLY;
54
- DEFINE FIELD updatedAt ON TABLE threadMessage TYPE datetime VALUE time::now();
50
+ DEFINE FIELD IF NOT EXISTS metadata ON TABLE threadMessage TYPE option<object> FLEXIBLE;
51
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE threadMessage TYPE datetime DEFAULT time::now() READONLY;
52
+ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE threadMessage TYPE datetime VALUE time::now();
55
53
 
56
- DEFINE INDEX threadMessageThreadIdx ON TABLE threadMessage COLUMNS threadId;
57
- DEFINE INDEX threadMessageThreadCreatedIdx ON TABLE threadMessage COLUMNS threadId, createdAt;
58
- DEFINE INDEX threadMessageThreadMessageUniqueIdx ON TABLE threadMessage COLUMNS threadId, messageId UNIQUE;
54
+ DEFINE INDEX IF NOT EXISTS threadMessageThreadIdx ON TABLE threadMessage COLUMNS threadId;
55
+ DEFINE INDEX IF NOT EXISTS threadMessageThreadCreatedIdx ON TABLE threadMessage COLUMNS threadId, createdAt;
56
+ DEFINE INDEX IF NOT EXISTS threadMessageThreadMessageUniqueIdx ON TABLE threadMessage COLUMNS threadId, messageId UNIQUE;
59
57
 
60
58
  # Thread attachments.
61
- DEFINE TABLE threadAttachment SCHEMAFULL;
62
- DEFINE FIELD threadId ON TABLE threadAttachment TYPE record<thread> REFERENCE ON DELETE CASCADE;
63
- DEFINE FIELD messageId ON TABLE threadAttachment TYPE record<threadMessage> REFERENCE ON DELETE CASCADE;
64
- DEFINE FIELD attachmentType ON TABLE threadAttachment TYPE string;
65
- DEFINE FIELD name ON TABLE threadAttachment TYPE string;
66
- DEFINE FIELD contentType ON TABLE threadAttachment TYPE string;
67
- DEFINE FIELD storageKey ON TABLE threadAttachment TYPE option<string>;
68
- DEFINE FIELD sizeBytes ON TABLE threadAttachment TYPE option<int>;
69
- DEFINE FIELD url ON TABLE threadAttachment TYPE option<string>;
70
- DEFINE FIELD createdAt ON TABLE threadAttachment TYPE datetime DEFAULT time::now() READONLY;
71
- DEFINE FIELD updatedAt ON TABLE threadAttachment TYPE datetime VALUE time::now();
59
+ DEFINE TABLE IF NOT EXISTS threadAttachment SCHEMAFULL;
60
+ DEFINE FIELD IF NOT EXISTS threadId ON TABLE threadAttachment TYPE record<thread> REFERENCE ON DELETE CASCADE;
61
+ DEFINE FIELD IF NOT EXISTS messageId ON TABLE threadAttachment TYPE record<threadMessage> REFERENCE ON DELETE CASCADE;
62
+ DEFINE FIELD IF NOT EXISTS attachmentType ON TABLE threadAttachment TYPE string;
63
+ DEFINE FIELD IF NOT EXISTS name ON TABLE threadAttachment TYPE string;
64
+ DEFINE FIELD IF NOT EXISTS contentType ON TABLE threadAttachment TYPE string;
65
+ DEFINE FIELD IF NOT EXISTS storageKey ON TABLE threadAttachment TYPE option<string>;
66
+ DEFINE FIELD IF NOT EXISTS sizeBytes ON TABLE threadAttachment TYPE option<int>;
67
+ DEFINE FIELD IF NOT EXISTS url ON TABLE threadAttachment TYPE option<string>;
68
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE threadAttachment TYPE datetime DEFAULT time::now() READONLY;
69
+ DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE threadAttachment TYPE datetime VALUE time::now();
72
70
 
73
- DEFINE INDEX threadAttachmentThreadIdx ON TABLE threadAttachment COLUMNS threadId;
74
- DEFINE INDEX threadAttachmentMessageIdx ON TABLE threadAttachment COLUMNS messageId;
75
- DEFINE INDEX threadAttachmentThreadMessageIdx ON TABLE threadAttachment COLUMNS threadId, messageId;
71
+ DEFINE INDEX IF NOT EXISTS threadAttachmentThreadIdx ON TABLE threadAttachment COLUMNS threadId;
72
+ DEFINE INDEX IF NOT EXISTS threadAttachmentMessageIdx ON TABLE threadAttachment COLUMNS messageId;
73
+ DEFINE INDEX IF NOT EXISTS threadAttachmentThreadMessageIdx ON TABLE threadAttachment COLUMNS threadId, messageId;
@@ -2,6 +2,7 @@
2
2
  DEFINE TABLE IF NOT EXISTS runtimeBootstrap SCHEMAFULL;
3
3
  DEFINE FIELD IF NOT EXISTS key ON TABLE runtimeBootstrap TYPE string;
4
4
  DEFINE FIELD IF NOT EXISTS schemaFingerprint ON TABLE runtimeBootstrap TYPE string;
5
+ DEFINE FIELD IF NOT EXISTS createdAt ON TABLE runtimeBootstrap TYPE datetime DEFAULT time::now() READONLY;
5
6
  DEFINE FIELD IF NOT EXISTS readyAt ON TABLE runtimeBootstrap TYPE datetime;
6
7
  DEFINE FIELD IF NOT EXISTS updatedAt ON TABLE runtimeBootstrap TYPE datetime VALUE time::now();
7
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -32,7 +32,7 @@
32
32
  "@chat-adapter/slack": "^4.23.0",
33
33
  "@chat-adapter/state-ioredis": "^4.23.0",
34
34
  "@logtape/logtape": "^2.0.5",
35
- "@lota-sdk/shared": "0.3.0",
35
+ "@lota-sdk/shared": "0.3.2",
36
36
  "@mendable/firecrawl-js": "^4.18.1",
37
37
  "@surrealdb/node": "^3.0.3",
38
38
  "ai": "^6.0.145",
@@ -90,7 +90,7 @@ type ArchiveSdkThread = (
90
90
 
91
91
  type UnarchiveSdkThread = (
92
92
  threadId: Parameters<typeof threadServiceSingleton.updateStatus>[0],
93
- status?: 'regular',
93
+ status?: 'active',
94
94
  ) => ReturnType<typeof threadServiceSingleton.updateStatus>
95
95
 
96
96
  let activeRuntimeToken: symbol | null = null
@@ -317,7 +317,7 @@ export async function createLotaRuntime(config: LotaRuntimeConfig): Promise<Lota
317
317
  get: threadServiceSingleton.getThread.bind(threadServiceSingleton),
318
318
  update: threadServiceSingleton.updateTitle.bind(threadServiceSingleton),
319
319
  archive: async (threadId, status = 'archived') => await threadServiceSingleton.updateStatus(threadId, status),
320
- unarchive: async (threadId, status = 'regular') => await threadServiceSingleton.updateStatus(threadId, status),
320
+ unarchive: async (threadId, status = 'active') => await threadServiceSingleton.updateStatus(threadId, status),
321
321
  delete: threadServiceSingleton.deleteThread.bind(threadServiceSingleton),
322
322
  stop: threadServiceSingleton.stopActiveRun.bind(threadServiceSingleton),
323
323
  listMessages: threadMessageServiceSingleton.listMessageHistoryPage.bind(threadMessageServiceSingleton),
@@ -8,13 +8,18 @@ export interface RecordIdShape {
8
8
  id: RecordIdValue
9
9
  }
10
10
 
11
- export interface StringableRecordId {
11
+ export type RecordIdInput = string | RecordId | StringRecordId | RecordIdShape
12
+ export type RecordIdRef = RecordIdInput
13
+
14
+ const SURREAL_RECORD_ID_CLASS_NAMES = new Set(['RecordId', 'StringRecordId'])
15
+
16
+ interface SurrealRecordIdLike {
12
17
  toString(): string
13
18
  }
14
19
 
15
- export type RecordIdInput = string | RecordId | StringRecordId | RecordIdShape | StringableRecordId
16
-
17
- export type RecordIdRef = RecordIdInput
20
+ interface NamedConstructor {
21
+ name?: unknown
22
+ }
18
23
 
19
24
  class InvalidRecordIdError extends BadRequestError {
20
25
  constructor(message: string) {
@@ -23,18 +28,18 @@ class InvalidRecordIdError extends BadRequestError {
23
28
  }
24
29
  }
25
30
 
26
- export function readCustomStringValue(value: object): string | null {
27
- const toStringValue: unknown = Reflect.get(value, 'toString')
28
- if (typeof toStringValue !== 'function' || toStringValue === Object.prototype.toString) {
29
- return null
31
+ export function isSurrealRecordIdValue(value: unknown): boolean {
32
+ if (!value || typeof value !== 'object') {
33
+ return false
30
34
  }
31
35
 
32
- const stringValue = Reflect.apply(toStringValue as (this: object) => unknown, value, [])
33
- if (typeof stringValue !== 'string' || stringValue.length === 0) {
34
- return null
36
+ const constructor = (value as { constructor?: unknown }).constructor
37
+ if (typeof constructor !== 'function') {
38
+ return false
35
39
  }
36
40
 
37
- return stringValue
41
+ const constructorName = (constructor as NamedConstructor).name
42
+ return typeof constructorName === 'string' && SURREAL_RECORD_ID_CLASS_NAMES.has(constructorName)
38
43
  }
39
44
 
40
45
  export function ensureRecordId(value: RecordIdInput, fallbackTable?: string): RecordId {
@@ -42,8 +47,8 @@ export function ensureRecordId(value: RecordIdInput, fallbackTable?: string): Re
42
47
  return value
43
48
  }
44
49
 
45
- if (value instanceof StringRecordId) {
46
- return ensureRecordId(value.toString(), fallbackTable)
50
+ if (value instanceof StringRecordId || isSurrealRecordIdValue(value)) {
51
+ return ensureRecordId((value as SurrealRecordIdLike).toString(), fallbackTable)
47
52
  }
48
53
 
49
54
  if (typeof value === 'string') {
@@ -58,16 +63,11 @@ export function ensureRecordId(value: RecordIdInput, fallbackTable?: string): Re
58
63
  return new RecordId(fallbackTable, value)
59
64
  }
60
65
 
61
- if (typeof value === 'object') {
66
+ if (typeof value === 'object' && Object.keys(value).length === 2) {
62
67
  const record = value as { tb?: string; id?: RecordIdValue }
63
- if (record.tb && record.id !== undefined) {
68
+ if (typeof record.tb === 'string' && record.id !== undefined) {
64
69
  return new RecordId(record.tb, record.id)
65
70
  }
66
-
67
- const stringValue = readCustomStringValue(value)
68
- if (stringValue && stringValue !== '[object Object]') {
69
- return ensureRecordId(stringValue, fallbackTable)
70
- }
71
71
  }
72
72
 
73
73
  throw new InvalidRecordIdError('Invalid record id value')
package/src/db/service.ts CHANGED
@@ -16,7 +16,7 @@ import { serverLogger } from '../config/logger'
16
16
  import { withTimeout } from '../utils/async'
17
17
  import { isRecord } from '../utils/string'
18
18
  import type { RecordIdInput } from './record-id'
19
- import { ensureRecordId, readCustomStringValue } from './record-id'
19
+ import { ensureRecordId, isSurrealRecordIdValue } from './record-id'
20
20
  import type { DatabaseTable } from './tables'
21
21
 
22
22
  export class SurrealDBError extends Error {
@@ -103,19 +103,6 @@ function isBoundQueryLike(value: unknown): value is BoundQueryLike {
103
103
  return value.bindings === undefined || isRecord(value.bindings)
104
104
  }
105
105
 
106
- function toStringLikeValue(value: unknown): string | null {
107
- if (!value || typeof value !== 'object') {
108
- return null
109
- }
110
-
111
- const stringValue = readCustomStringValue(value)
112
- if (typeof stringValue !== 'string' || stringValue.length === 0 || stringValue === '[object Object]') {
113
- return null
114
- }
115
-
116
- return stringValue
117
- }
118
-
119
106
  const CONNECT_MAX_ATTEMPTS = 5
120
107
  const CONNECT_RETRY_BASE_DELAY_MS = 100
121
108
  const CONNECT_RETRY_JITTER_MS = 50
@@ -353,6 +340,10 @@ export class SurrealDBService {
353
340
  return value.map((entry) => this.normalizeParseValue(entry))
354
341
  }
355
342
 
343
+ if (isSurrealRecordIdValue(value)) {
344
+ return ensureRecordId(value as RecordIdInput)
345
+ }
346
+
356
347
  if (!isRecord(value)) {
357
348
  return value
358
349
  }
@@ -413,10 +404,6 @@ export class SurrealDBService {
413
404
  return new BoundQuery(query.query, this.normalizeBindings(query.bindings))
414
405
  }
415
406
 
416
- private isSerializedRecordId(value: string): boolean {
417
- return /^[a-zA-Z][a-zA-Z0-9_]*:(?:⟨.+⟩|.+)$/.test(value)
418
- }
419
-
420
407
  private normalizeRuntimeValue(value: unknown): unknown {
421
408
  if (value === null || value === undefined) {
422
409
  return value
@@ -435,17 +422,16 @@ export class SurrealDBService {
435
422
  return value.map((entry) => this.normalizeRuntimeValue(entry))
436
423
  }
437
424
 
425
+ if (isSurrealRecordIdValue(value)) {
426
+ return ensureRecordId(value as RecordIdInput)
427
+ }
428
+
438
429
  if (!isRecord(value)) {
439
430
  return value
440
431
  }
441
432
 
442
433
  if ('tb' in value && 'id' in value && Object.keys(value).length === 2) {
443
- return ensureRecordId(value as RecordIdInput)
444
- }
445
-
446
- const stringValue = toStringLikeValue(value)
447
- if (stringValue && this.isSerializedRecordId(stringValue)) {
448
- return ensureRecordId(stringValue)
434
+ return ensureRecordId(value as unknown as RecordIdInput)
449
435
  }
450
436
 
451
437
  const entries = Object.entries(value)
@@ -467,17 +453,23 @@ export class SurrealDBService {
467
453
  }
468
454
 
469
455
  private normalizeMutationFieldValue(value: unknown): unknown {
470
- if (value === null || value === undefined) {
456
+ if (value === undefined) {
471
457
  return undefined
472
458
  }
473
459
 
460
+ if (value === null) {
461
+ return null
462
+ }
463
+
474
464
  return this.normalizeRuntimeValue(value)
475
465
  }
476
466
 
477
467
  // Cast is safe: normalizeRuntimeValue preserves Record shape when input is a Record
478
468
  private normalizeMutationData(data: Record<string, unknown>): Record<string, unknown> {
479
469
  return Object.fromEntries(
480
- Object.entries(data).map(([key, value]) => [key, this.normalizeMutationFieldValue(value)]),
470
+ Object.entries(data)
471
+ .map(([key, value]) => [key, this.normalizeMutationFieldValue(value)] as const)
472
+ .filter((entry): entry is readonly [string, unknown] => entry[1] !== undefined),
481
473
  ) as Record<string, unknown>
482
474
  }
483
475
 
@@ -490,11 +482,6 @@ export class SurrealDBService {
490
482
  return new Table(value)
491
483
  }
492
484
 
493
- const stringValue = toStringLikeValue(value)
494
- if (stringValue) {
495
- return new Table(stringValue)
496
- }
497
-
498
485
  throw new SurrealDBError('Invalid table value')
499
486
  }
500
487
 
@@ -503,20 +490,17 @@ export class SurrealDBService {
503
490
  return true
504
491
  }
505
492
 
493
+ if (isSurrealRecordIdValue(value)) {
494
+ return true
495
+ }
496
+
506
497
  if (typeof value === 'string') {
507
498
  return /^[a-zA-Z][a-zA-Z0-9_]*:/.test(value)
508
499
  }
509
500
 
510
501
  if (value && typeof value === 'object') {
511
502
  const record = value as { tb?: unknown; id?: unknown }
512
- if (typeof record.tb === 'string' && record.id !== undefined && Object.keys(value).length === 2) {
513
- return true
514
- }
515
-
516
- const stringValue = toStringLikeValue(value)
517
- if (stringValue) {
518
- return /^[a-zA-Z][a-zA-Z0-9_]*:/.test(stringValue)
519
- }
503
+ return typeof record.tb === 'string' && record.id !== undefined && Object.keys(value).length === 2
520
504
  }
521
505
 
522
506
  return false
@@ -689,7 +673,11 @@ export class SurrealDBService {
689
673
  for (const key of filterKeys) {
690
674
  this.assertValidIdentifier(key, 'filter field')
691
675
  }
692
- const orderDir = options?.orderDir ?? 'ASC'
676
+ const rawOrderDir: unknown = options?.orderDir
677
+ if (rawOrderDir !== undefined && rawOrderDir !== 'ASC' && rawOrderDir !== 'DESC') {
678
+ throw new SurrealDBError(`Invalid orderDir value: ${this.describeInvalidValue(rawOrderDir)}`)
679
+ }
680
+ const orderDir = rawOrderDir ?? 'ASC'
693
681
  const limit = options?.limit
694
682
  const offset = options?.offset
695
683
  const vars: Record<string, unknown> = this.normalizeMutationData(filter)
@@ -730,6 +718,22 @@ export class SurrealDBService {
730
718
  }
731
719
  }
732
720
 
721
+ private describeInvalidValue(value: unknown): string {
722
+ if (typeof value === 'string') {
723
+ return value
724
+ }
725
+
726
+ try {
727
+ const serialized = JSON.stringify(value)
728
+ if (typeof serialized === 'string') {
729
+ return serialized
730
+ }
731
+ return Object.prototype.toString.call(value)
732
+ } catch {
733
+ return Object.prototype.toString.call(value)
734
+ }
735
+ }
736
+
733
737
  async create<T extends z.ZodTypeAny>(
734
738
  table: DatabaseTable,
735
739
  data: Record<string, unknown>,
@@ -151,17 +151,20 @@ const threadBootstrapWelcomeConfigSchema = z.object({
151
151
  }),
152
152
  })
153
153
 
154
- const threadConfigSchema = z.object({
155
- bootstrap: z
156
- .object({
157
- onboardingDirectAgents: z.array(z.string().trim().min(1)).optional(),
158
- completedDirectAgents: z.array(z.string().trim().min(1)).optional(),
159
- coreTypesAfterOnboarding: z.array(z.string().trim().min(1)).optional(),
160
- ensureDefaultGroupOnCompleted: z.boolean().optional(),
161
- onboardingWelcome: threadBootstrapWelcomeConfigSchema.optional(),
162
- })
163
- .optional(),
164
- })
154
+ const threadConfigSchema = z
155
+ .object({
156
+ bootstrap: z
157
+ .object({
158
+ onboardingDefaultAgents: z.array(z.string().trim().min(1)).optional(),
159
+ completedDefaultAgents: z.array(z.string().trim().min(1)).optional(),
160
+ threadTypesAfterOnboarding: z.array(z.string().trim().min(1)).optional(),
161
+ ensureDefaultGroupOnCompleted: z.boolean().optional(),
162
+ onboardingWelcome: threadBootstrapWelcomeConfigSchema.optional(),
163
+ })
164
+ .strict()
165
+ .optional(),
166
+ })
167
+ .strict()
165
168
 
166
169
  const agentsConfigSchema = z
167
170
  .object({
@@ -21,7 +21,7 @@ import {
21
21
  STRUCTURAL_NODE_TYPES as STRUCTURAL_NODE_TYPE_VALUES,
22
22
  PlanValidationIssueSchema,
23
23
  } from '@lota-sdk/shared'
24
- import { RecordId } from 'surrealdb'
24
+ import { RecordId, StringRecordId } from 'surrealdb'
25
25
 
26
26
  import { aiLogger } from '../config/logger'
27
27
  import type { RecordIdInput } from '../db/record-id'
@@ -1865,7 +1865,14 @@ class PlanExecutorService {
1865
1865
  run: PlanRunRecord,
1866
1866
  checkpoint: RecordIdInput | { id: RecordIdInput },
1867
1867
  ) {
1868
- const checkpointId = checkpoint && typeof checkpoint === 'object' && 'id' in checkpoint ? checkpoint.id : checkpoint
1868
+ const checkpointId =
1869
+ checkpoint &&
1870
+ typeof checkpoint === 'object' &&
1871
+ !(checkpoint instanceof RecordId) &&
1872
+ !(checkpoint instanceof StringRecordId) &&
1873
+ 'id' in checkpoint
1874
+ ? (checkpoint as { id: RecordIdInput }).id
1875
+ : checkpoint
1869
1876
 
1870
1877
  await tx
1871
1878
  .update(ensureRecordId(run.id, TABLES.PLAN_RUN))
@@ -122,12 +122,13 @@ function writeMultiAgentEvent(
122
122
  ): void {
123
123
  if (!writer) return
124
124
 
125
- writer.write({
125
+ const chunk: ChatStreamChunk = {
126
126
  type: 'data-multi-agent-event',
127
127
  id: `multi-agent-${Bun.randomUUIDv7()}`,
128
128
  data: event,
129
129
  transient: true,
130
- } as unknown as ChatStreamChunk)
130
+ }
131
+ writer.write(chunk)
131
132
  }
132
133
 
133
134
  function applyPlanTurnToolPolicy(tools: ToolSet, nodeSpec: PlanNodeSpecRecord): ToolSet {
@@ -342,32 +343,20 @@ async function streamAgentResponse(
342
343
  const agentAbortSignal = streamParams.abortSignal ?? ctx.runAbortSignal
343
344
  agentTimer.step('agent-construction')
344
345
 
345
- const MAX_STREAM_RETRIES = 3
346
346
  let result: unknown
347
- for (let attempt = 0; ; attempt++) {
348
- try {
349
- result = await streamParams.observer.run(() =>
350
- agent.stream({ messages: modelMessages, abortSignal: agentAbortSignal }),
351
- )
352
- agentTimer.step('agent.stream()-resolved')
353
- break
354
- } catch (error) {
355
- if (agentAbortSignal.aborted) {
356
- streamParams.observer.recordAbort(error)
357
- throw error
358
- }
359
- const errorMessage = error instanceof Error ? error.message : String(error)
360
- const isTransient =
361
- errorMessage.includes('client disconnected') ||
362
- errorMessage.includes('ECONNRESET') ||
363
- errorMessage.includes('socket hang up') ||
364
- errorMessage.includes('fetch failed')
365
- if (!isTransient || attempt >= MAX_STREAM_RETRIES - 1) {
366
- streamParams.observer.recordError(error)
367
- throw error
368
- }
369
- aiLogger.warn`Transient stream error (attempt ${attempt + 1}/${MAX_STREAM_RETRIES}): ${errorMessage} — retrying`
347
+ try {
348
+ result = await streamParams.observer.run(() =>
349
+ agent.stream({ messages: modelMessages, abortSignal: agentAbortSignal }),
350
+ )
351
+ agentTimer.step('agent.stream()-resolved')
352
+ } catch (error) {
353
+ if (agentAbortSignal.aborted) {
354
+ streamParams.observer.recordAbort(error)
355
+ throw error
370
356
  }
357
+
358
+ streamParams.observer.recordError(error)
359
+ throw error
371
360
  }
372
361
  if (!hasUIMessageStream(result)) {
373
362
  throw new Error(`Agent run for ${resolvedAgentId} did not expose a UI message stream.`)
@@ -982,7 +971,7 @@ export async function prepareThreadRunCore(params: ThreadRunCoreParams): Promise
982
971
  phase: 'waiting-for-agent',
983
972
  agentId: checkResult.agentId,
984
973
  agentName: resolveRuntimeAgentDisplayName(agentIdentityOverrides, checkResult.agentId),
985
- note: checkResult.routingContext,
974
+ note: checkResult.routingContext ?? undefined,
986
975
  })
987
976
 
988
977
  // Insert hidden bridge message between agent turns
@@ -1003,7 +992,7 @@ export async function prepareThreadRunCore(params: ThreadRunCoreParams): Promise
1003
992
  throwIfRunAborted()
1004
993
 
1005
994
  lastResponse = await runGroupAgent(checkResult.agentId, {
1006
- routingContext: checkResult.routingContext,
995
+ routingContext: checkResult.routingContext ?? undefined,
1007
996
  })
1008
997
  respondedAgents.push(checkResult.agentId)
1009
998
  throwIfRunAborted()
@@ -29,6 +29,12 @@ import type { NormalizedThread, PublicThread, ThreadRecord } from './thread.type
29
29
  const THREAD_ACTIVE_RUN_LOCK_TTL_MS = 90_000
30
30
  const THREAD_ACTIVE_RUN_LOCK_MAX_WAIT_MS = 750
31
31
  const THREAD_ACTIVE_RUN_LOCK_RETRY_DELAY_MS = 75
32
+ const THREAD_BOOTSTRAP_LOCK_TTL_MS = 15_000
33
+ const THREAD_BOOTSTRAP_LOCK_REFRESH_INTERVAL_MS = 5_000
34
+ const THREAD_BOOTSTRAP_LOCK_MAX_WAIT_MS = 5_000
35
+ const THREAD_BOOTSTRAP_LOCK_RETRY_DELAY_MS = 100
36
+ const THREAD_UNIQUE_LOOKUP_MAX_ATTEMPTS = 20
37
+ const THREAD_UNIQUE_LOOKUP_RETRY_DELAY_MS = 100
32
38
 
33
39
  function isRecordIdInput(value: unknown): value is RecordIdInput {
34
40
  if (typeof value === 'string' || value instanceof RecordId || value instanceof StringRecordId) {
@@ -51,6 +57,10 @@ function buildActiveRunLockKey(threadId: RecordIdRef): string {
51
57
  return `thread-active-run:${recordIdToString(ensureRecordId(threadId, TABLES.THREAD), TABLES.THREAD)}`
52
58
  }
53
59
 
60
+ function buildBootstrapThreadsLockKey(userId: RecordIdRef, orgId: RecordIdRef): string {
61
+ return `thread-bootstrap:${recordIdToString(ensureRecordId(userId, TABLES.USER), TABLES.USER)}:${recordIdToString(ensureRecordId(orgId, TABLES.ORGANIZATION), TABLES.ORGANIZATION)}`
62
+ }
63
+
54
64
  function buildListThreadsQuery(options: {
55
65
  includeArchived: boolean
56
66
  paginate: boolean
@@ -94,6 +104,35 @@ function isUniqueConstraintError(error: unknown): boolean {
94
104
  return error.message.includes('already contains')
95
105
  }
96
106
 
107
+ function requireExistingThread(
108
+ record: ThreadRecord | null,
109
+ params: {
110
+ type: 'default' | 'thread'
111
+ organizationId: RecordIdRef
112
+ userId: RecordIdRef
113
+ agentId?: string
114
+ threadType?: string
115
+ },
116
+ ): ThreadRecord {
117
+ if (record) {
118
+ return record
119
+ }
120
+
121
+ const scope = {
122
+ organizationId: recordIdToString(params.organizationId, TABLES.ORGANIZATION),
123
+ userId: recordIdToString(params.userId, TABLES.USER),
124
+ ...(params.agentId ? { agentId: params.agentId } : {}),
125
+ ...(params.threadType ? { threadType: params.threadType } : {}),
126
+ }
127
+ throw new Error(`Thread lookup failed after duplicate ${params.type} thread create: ${JSON.stringify(scope)}`)
128
+ }
129
+
130
+ type UniqueThreadLookupParams = Parameters<typeof requireExistingThread>[1]
131
+
132
+ function haveSameMembers(left: string[], right: string[]): boolean {
133
+ return left.length === right.length && left.every((value, index) => value === right[index])
134
+ }
135
+
97
136
  export class ActiveThreadRunConflictError extends Error {
98
137
  constructor() {
99
138
  super('A chat run is already active.')
@@ -135,6 +174,30 @@ class ThreadService extends BaseService<typeof ThreadSchema> {
135
174
  }
136
175
 
137
176
  const title = input.title ?? THREAD.DEFAULT_TITLE
177
+ const nameGenerated = input.title !== undefined && input.title !== THREAD.DEFAULT_TITLE
178
+
179
+ if (input.type === 'default') {
180
+ const agentId = input.agentId
181
+ if (!agentId) {
182
+ throw new Error('Default threads require agentId')
183
+ }
184
+ const { record } = await this.getOrCreateDefault(input.organizationId, input.userId, agentId)
185
+ return await this.toNormalizedThread(record)
186
+ }
187
+
188
+ if (input.type === 'thread') {
189
+ const threadType = input.threadType
190
+ if (!threadType) {
191
+ throw new Error('Thread threads require threadType')
192
+ }
193
+ const { record } = await this.getOrCreateThread(input.organizationId, input.userId, threadType, {
194
+ members: input.members ?? [...agentRoster],
195
+ title,
196
+ nameGenerated,
197
+ })
198
+ return await this.toNormalizedThread(record)
199
+ }
200
+
138
201
  const thread = await this.create({
139
202
  userId: input.userId,
140
203
  organizationId: input.organizationId,
@@ -144,7 +207,7 @@ class ThreadService extends BaseService<typeof ThreadSchema> {
144
207
  members: input.members ?? [...agentRoster],
145
208
  title,
146
209
  status: 'active',
147
- nameGenerated: input.title !== undefined && input.title !== THREAD.DEFAULT_TITLE,
210
+ nameGenerated,
148
211
  isCompacting: false,
149
212
  turnCount: 0,
150
213
  })
@@ -156,13 +219,13 @@ class ThreadService extends BaseService<typeof ThreadSchema> {
156
219
  orgId: RecordIdRef,
157
220
  userId: RecordIdRef,
158
221
  agentId: string,
222
+ config?: { title?: string; nameGenerated?: boolean },
159
223
  ): Promise<{ record: ThreadRecord; created: boolean }> {
160
- const existing = await this.databaseService.findOne(
161
- this.table,
162
- { type: 'default', organizationId: orgId, userId, agentId },
163
- ThreadSchema,
164
- )
165
- if (existing) return { record: existing, created: false }
224
+ const lookup = { type: 'default' as const, organizationId: orgId, userId, agentId }
225
+ const existing = await this.findThreadByUniqueLookup(lookup)
226
+ if (existing) {
227
+ return { record: await this.syncThreadConfig(existing, config), created: false }
228
+ }
166
229
 
167
230
  try {
168
231
  const record = await this.create({
@@ -171,21 +234,16 @@ class ThreadService extends BaseService<typeof ThreadSchema> {
171
234
  userId,
172
235
  agentId,
173
236
  members: [agentId],
174
- title: getAgentDisplayName(agentId),
237
+ title: config?.title ?? getAgentDisplayName(agentId),
175
238
  status: 'active',
176
- nameGenerated: false,
239
+ nameGenerated: config?.nameGenerated ?? false,
177
240
  isCompacting: false,
178
241
  turnCount: 0,
179
242
  })
180
243
  return { record, created: true }
181
244
  } catch (e) {
182
245
  if (isUniqueConstraintError(e)) {
183
- const retried = await this.databaseService.findOne(
184
- this.table,
185
- { type: 'default', organizationId: orgId, userId, agentId },
186
- ThreadSchema,
187
- )
188
- return { record: retried!, created: false }
246
+ return { record: await this.syncThreadConfig(await this.waitForExistingThread(lookup), config), created: false }
189
247
  }
190
248
  throw e
191
249
  }
@@ -195,14 +253,13 @@ class ThreadService extends BaseService<typeof ThreadSchema> {
195
253
  orgId: RecordIdRef,
196
254
  userId: RecordIdRef,
197
255
  threadType: string,
198
- config: { members: string[]; title: string },
256
+ config: { members: string[]; title: string; nameGenerated?: boolean },
199
257
  ): Promise<{ record: ThreadRecord; created: boolean }> {
200
- const existing = await this.databaseService.findOne(
201
- this.table,
202
- { type: 'thread', organizationId: orgId, userId, threadType },
203
- ThreadSchema,
204
- )
205
- if (existing) return { record: existing, created: false }
258
+ const lookup = { type: 'thread' as const, organizationId: orgId, userId, threadType }
259
+ const existing = await this.findThreadByUniqueLookup(lookup)
260
+ if (existing) {
261
+ return { record: await this.syncThreadConfig(existing, config), created: false }
262
+ }
206
263
 
207
264
  try {
208
265
  const record = await this.create({
@@ -213,104 +270,193 @@ class ThreadService extends BaseService<typeof ThreadSchema> {
213
270
  members: config.members,
214
271
  title: config.title,
215
272
  status: 'active',
216
- nameGenerated: false,
273
+ nameGenerated: config.nameGenerated ?? false,
217
274
  isCompacting: false,
218
275
  turnCount: 0,
219
276
  })
220
277
  return { record, created: true }
221
278
  } catch (e) {
222
279
  if (isUniqueConstraintError(e)) {
223
- const retried = await this.databaseService.findOne(
224
- this.table,
225
- { type: 'thread', organizationId: orgId, userId, threadType },
226
- ThreadSchema,
227
- )
228
- return { record: retried!, created: false }
280
+ return { record: await this.syncThreadConfig(await this.waitForExistingThread(lookup), config), created: false }
229
281
  }
230
282
  throw e
231
283
  }
232
284
  }
233
285
 
234
- async ensureBootstrapThreads(
235
- userId: RecordIdRef,
236
- orgId: RecordIdRef,
237
- options?: { onboardStatus?: string; userName?: string | null },
238
- ): Promise<void> {
239
- const onboardStatus = options?.onboardStatus ?? 'completed'
240
- const onboardingCompleted = onboardStatus === 'completed'
241
- const bootstrapConfig = getThreadBootstrapConfig()
242
-
243
- const existingThreads = await databaseService.findMany(
244
- TABLES.THREAD,
245
- { userId, organizationId: orgId },
246
- ThreadSchema,
247
- )
248
-
249
- const hasGroupThread = existingThreads.some((t) => t.type === 'group')
250
- const defaultThreadsByAgent = new Map<string, ThreadRecord>()
251
- const threadThreadsByType = new Map<string, ThreadRecord>()
252
-
253
- for (const thread of existingThreads) {
254
- if (thread.type === 'default' && thread.agentId) {
255
- defaultThreadsByAgent.set(thread.agentId, thread)
256
- }
257
- if (thread.type === 'thread' && typeof thread.threadType === 'string') {
258
- threadThreadsByType.set(thread.threadType, thread)
259
- }
286
+ private async syncThreadConfig(
287
+ record: ThreadRecord,
288
+ config?: { title?: string; nameGenerated?: boolean; members?: string[] },
289
+ ): Promise<ThreadRecord> {
290
+ if (!config) {
291
+ return record
260
292
  }
261
293
 
262
- const requiredDefaultAgents = onboardingCompleted
263
- ? bootstrapConfig.completedDefaultAgents
264
- : bootstrapConfig.onboardingDefaultAgents
265
-
266
- const creations: Promise<{ record: ThreadRecord; created: boolean }>[] = []
294
+ const updates: Partial<ThreadRecord> = {}
267
295
 
268
- for (const agentId of requiredDefaultAgents) {
269
- if (defaultThreadsByAgent.has(agentId)) continue
270
- creations.push(this.getOrCreateDefault(orgId, userId, agentId))
296
+ if (config.title !== undefined && record.title !== config.title) {
297
+ updates.title = config.title
271
298
  }
272
-
273
- if (onboardingCompleted && bootstrapConfig.ensureDefaultGroupOnCompleted && !hasGroupThread) {
274
- creations.push(
275
- this.createThread({ userId, organizationId: orgId, type: 'group', title: THREAD.DEFAULT_TITLE }).then(
276
- (normalized) => ({ record: normalized as unknown as ThreadRecord, created: true }),
277
- ),
278
- )
299
+ if (config.nameGenerated !== undefined && record.nameGenerated !== config.nameGenerated) {
300
+ updates.nameGenerated = config.nameGenerated
279
301
  }
280
-
281
- if (onboardingCompleted) {
282
- for (const wsType of bootstrapConfig.threadTypesAfterOnboarding) {
283
- if (threadThreadsByType.has(wsType)) continue
284
- const profile = getCoreThreadProfile(wsType)
285
- creations.push(
286
- this.getOrCreateThread(orgId, userId, wsType, { members: [...profile.members], title: profile.config.title }),
287
- )
288
- }
302
+ if (config.members !== undefined && !haveSameMembers(record.members, config.members)) {
303
+ updates.members = config.members
289
304
  }
290
305
 
291
- let createdResults: { record: ThreadRecord; created: boolean }[] = []
292
- if (creations.length > 0) {
293
- createdResults = await Promise.all(creations)
306
+ if (Object.keys(updates).length === 0) {
307
+ return record
294
308
  }
295
309
 
296
- const onboardingWelcome = bootstrapConfig.onboardingWelcome
297
- if (!onboardingCompleted && onboardingWelcome) {
298
- const createdOwnerThread = createdResults.find(
299
- (r) => r.created && r.record.type === 'default' && r.record.agentId === onboardingWelcome.defaultAgentId,
300
- )
301
- const existingOwnerThread = defaultThreadsByAgent.get(onboardingWelcome.defaultAgentId)
310
+ return await this.update(record.id, updates)
311
+ }
302
312
 
303
- const ownerThreadId = createdOwnerThread?.record.id ?? existingOwnerThread?.id
313
+ private async findThreadByUniqueLookup(params: UniqueThreadLookupParams): Promise<ThreadRecord | null> {
314
+ return await this.databaseService.findOne(
315
+ this.table,
316
+ {
317
+ type: params.type,
318
+ organizationId: params.organizationId,
319
+ userId: params.userId,
320
+ ...(params.agentId ? { agentId: params.agentId } : {}),
321
+ ...(params.threadType ? { threadType: params.threadType } : {}),
322
+ },
323
+ ThreadSchema,
324
+ )
325
+ }
304
326
 
305
- if (ownerThreadId) {
306
- const ownerThreadRef = ensureRecordId(ownerThreadId, TABLES.THREAD)
307
- await threadMessageService.ensureBootstrapWelcomeMessage({
308
- threadId: ownerThreadRef,
309
- agentId: onboardingWelcome.defaultAgentId,
310
- text: onboardingWelcome.buildMessageText({ userName: options?.userName }),
311
- })
327
+ private async waitForExistingThread(params: UniqueThreadLookupParams): Promise<ThreadRecord> {
328
+ for (let attempt = 0; attempt < THREAD_UNIQUE_LOOKUP_MAX_ATTEMPTS; attempt += 1) {
329
+ const record = await this.findThreadByUniqueLookup(params)
330
+ if (record) {
331
+ return record
332
+ }
333
+ if (attempt < THREAD_UNIQUE_LOOKUP_MAX_ATTEMPTS - 1) {
334
+ await Bun.sleep(THREAD_UNIQUE_LOOKUP_RETRY_DELAY_MS)
312
335
  }
313
336
  }
337
+
338
+ return requireExistingThread(null, params)
339
+ }
340
+
341
+ async ensureBootstrapThreads(
342
+ userId: RecordIdRef,
343
+ orgId: RecordIdRef,
344
+ options?: { onboardStatus?: string; userName?: string | null },
345
+ ): Promise<void> {
346
+ await withRedisLeaseLock(
347
+ {
348
+ redis: getRedisConnection(),
349
+ lockKey: buildBootstrapThreadsLockKey(userId, orgId),
350
+ lockTtlMs: THREAD_BOOTSTRAP_LOCK_TTL_MS,
351
+ refreshIntervalMs: THREAD_BOOTSTRAP_LOCK_REFRESH_INTERVAL_MS,
352
+ retryDelayMs: THREAD_BOOTSTRAP_LOCK_RETRY_DELAY_MS,
353
+ maxWaitMs: THREAD_BOOTSTRAP_LOCK_MAX_WAIT_MS,
354
+ label: 'thread bootstrap',
355
+ logger: serverLogger,
356
+ },
357
+ async (signal) => {
358
+ const throwIfAborted = () => {
359
+ if (signal.aborted) {
360
+ throw signal.reason instanceof Error ? signal.reason : new Error('Thread bootstrap lease was aborted.')
361
+ }
362
+ }
363
+
364
+ throwIfAborted()
365
+ const onboardStatus = options?.onboardStatus ?? 'completed'
366
+ const onboardingCompleted = onboardStatus === 'completed'
367
+ const bootstrapConfig = getThreadBootstrapConfig()
368
+
369
+ const existingThreads = await databaseService.findMany(
370
+ TABLES.THREAD,
371
+ { userId, organizationId: orgId },
372
+ ThreadSchema,
373
+ )
374
+ throwIfAborted()
375
+
376
+ const hasGroupThread = existingThreads.some((t) => t.type === 'group')
377
+ const defaultThreadsByAgent = new Map<string, ThreadRecord>()
378
+ const threadThreadsByType = new Map<string, ThreadRecord>()
379
+
380
+ for (const thread of existingThreads) {
381
+ if (thread.type === 'default' && thread.agentId) {
382
+ defaultThreadsByAgent.set(thread.agentId, thread)
383
+ }
384
+ if (thread.type === 'thread' && typeof thread.threadType === 'string') {
385
+ threadThreadsByType.set(thread.threadType, thread)
386
+ }
387
+ }
388
+
389
+ const requiredDefaultAgents = onboardingCompleted
390
+ ? bootstrapConfig.completedDefaultAgents
391
+ : bootstrapConfig.onboardingDefaultAgents
392
+
393
+ const creationTasks: Array<() => Promise<{ record: ThreadRecord; created: boolean }>> = []
394
+
395
+ for (const agentId of requiredDefaultAgents) {
396
+ if (defaultThreadsByAgent.has(agentId)) continue
397
+ creationTasks.push(async () => await this.getOrCreateDefault(orgId, userId, agentId))
398
+ }
399
+
400
+ if (onboardingCompleted && bootstrapConfig.ensureDefaultGroupOnCompleted && !hasGroupThread) {
401
+ creationTasks.push(
402
+ async () =>
403
+ await this.createThread({
404
+ userId,
405
+ organizationId: orgId,
406
+ type: 'group',
407
+ title: THREAD.DEFAULT_TITLE,
408
+ }).then(async (normalized) => ({
409
+ record: await this.getById(ensureRecordId(normalized.id, TABLES.THREAD)),
410
+ created: true,
411
+ })),
412
+ )
413
+ }
414
+
415
+ if (onboardingCompleted) {
416
+ for (const wsType of bootstrapConfig.threadTypesAfterOnboarding) {
417
+ if (threadThreadsByType.has(wsType)) continue
418
+ const profile = getCoreThreadProfile(wsType)
419
+ creationTasks.push(
420
+ async () =>
421
+ await this.getOrCreateThread(orgId, userId, wsType, {
422
+ members: [...profile.members],
423
+ title: profile.config.title,
424
+ }),
425
+ )
426
+ }
427
+ }
428
+
429
+ let createdResults: { record: ThreadRecord; created: boolean }[] = []
430
+ if (creationTasks.length > 0) {
431
+ for (const runCreation of creationTasks) {
432
+ throwIfAborted()
433
+ createdResults.push(await runCreation())
434
+ throwIfAborted()
435
+ }
436
+ }
437
+
438
+ const onboardingWelcome = bootstrapConfig.onboardingWelcome
439
+ if (!onboardingCompleted && onboardingWelcome) {
440
+ const createdOwnerThread = createdResults.find(
441
+ (r) => r.created && r.record.type === 'default' && r.record.agentId === onboardingWelcome.defaultAgentId,
442
+ )
443
+ const existingOwnerThread = defaultThreadsByAgent.get(onboardingWelcome.defaultAgentId)
444
+
445
+ const ownerThreadId = createdOwnerThread?.record.id ?? existingOwnerThread?.id
446
+
447
+ if (ownerThreadId) {
448
+ throwIfAborted()
449
+ const ownerThreadRef = ensureRecordId(ownerThreadId, TABLES.THREAD)
450
+ await threadMessageService.ensureBootstrapWelcomeMessage({
451
+ threadId: ownerThreadRef,
452
+ agentId: onboardingWelcome.defaultAgentId,
453
+ text: onboardingWelcome.buildMessageText({ userName: options?.userName }),
454
+ })
455
+ throwIfAborted()
456
+ }
457
+ }
458
+ },
459
+ )
314
460
  }
315
461
 
316
462
  async listThreads(
@@ -675,7 +821,7 @@ class ThreadService extends BaseService<typeof ThreadSchema> {
675
821
  title: thread.title ?? this.getDefaultTitle(thread),
676
822
  status,
677
823
  memoryBlock: this.formatMemoryBlockForPrompt(thread),
678
- members: thread.members ?? [],
824
+ members: thread.members,
679
825
  createdAt: toIsoDateTimeString(thread.createdAt),
680
826
  updatedAt: toIsoDateTimeString(thread.updatedAt),
681
827
  })
@@ -1,30 +1,23 @@
1
- import { ToolLoopAgent } from 'ai'
1
+ import { generateObject } from 'ai'
2
2
  import { z } from 'zod'
3
3
 
4
4
  import { aiGatewayChatModel } from '../ai-gateway/ai-gateway'
5
5
  import { buildAiGatewayDirectCacheHeaders } from '../ai-gateway/cache-headers'
6
6
  import { agentDescriptions, agentDisplayNames, agentShortDisplayNames, routerModelId } from '../config/agent-defaults'
7
-
8
- // ---------------------------------------------------------------------------
9
- // Schemas
10
- // ---------------------------------------------------------------------------
7
+ import { chatLogger } from '../config/logger'
8
+ import { withTimeout } from '../utils/async'
11
9
 
12
10
  const TriageResultSchema = z.object({ agentId: z.string(), routingContext: z.string() })
13
11
 
14
- const CheckResultSchema = z.object({
12
+ const CheckResultObjectSchema = z.object({
15
13
  done: z.boolean(),
16
- agentId: z.string().optional(),
17
- routingContext: z.string().optional(),
14
+ agentId: z.string().nullable(),
15
+ routingContext: z.string().nullable(),
18
16
  })
19
-
20
- const ROUTER_OUTPUT_PREVIEW_CHARS = 300
17
+ type RouterCheckContinueResult = { done: false; agentId: string; routingContext: string | null }
21
18
 
22
19
  export type RouterTriageResult = z.infer<typeof TriageResultSchema>
23
- export type RouterCheckResult = z.infer<typeof CheckResultSchema>
24
-
25
- // ---------------------------------------------------------------------------
26
- // Helpers
27
- // ---------------------------------------------------------------------------
20
+ export type RouterCheckResult = { done: true } | RouterCheckContinueResult
28
21
 
29
22
  interface RouterDisplayOptions {
30
23
  displayNamesById?: Partial<Record<string, string>>
@@ -136,56 +129,13 @@ function extractExplicitAgentTargets(
136
129
  })
137
130
  }
138
131
 
139
- function extractJson(text: string): unknown {
140
- const match = text.match(/\{[\s\S]*\}/)
141
- if (!match) return null
142
- try {
143
- return JSON.parse(match[0])
144
- } catch {
145
- return null
146
- }
147
- }
148
-
149
- /** Extract usable text from agent result — reasoning-only models put output in reasoning tokens */
150
- function extractResultText(result: { text?: string; reasoning?: unknown }): string {
151
- const text = typeof result.text === 'string' ? result.text : ''
152
- if (text.trim()) return text
153
- // Reasoning can be a string or an array of { type, text } objects
154
- const reasoning = result.reasoning
155
- if (typeof reasoning === 'string') return reasoning
156
- if (Array.isArray(reasoning)) {
157
- return reasoning
158
- .map((r) => {
159
- if (typeof r === 'string') return r
160
- if (typeof r !== 'object' || r === null || !('text' in r)) return ''
161
-
162
- const text = (r as { text?: unknown }).text
163
- return typeof text === 'string' ? text : ''
164
- })
165
- .join('')
166
- }
167
- return ''
168
- }
169
-
170
- function logRouterRaw(label: 'triage' | 'check', text: string): void {
171
- const preview = text.trim().slice(0, ROUTER_OUTPUT_PREVIEW_CHARS)
172
- if (!preview) return
173
- console.log(`[thread-router] ${label} raw:`, preview)
174
- }
175
-
176
- // ---------------------------------------------------------------------------
177
- // Prompts
178
- // ---------------------------------------------------------------------------
179
-
180
132
  const TRIAGE_SYSTEM_PROMPT = `You are a thread message router. Decide which team member should respond FIRST to the user message.
181
133
 
182
134
  Rules:
183
135
  - Pick the single best-fit agent from the members list based on domain expertise.
184
136
  - If the user explicitly addresses an agent by name or role (e.g. "CTO: ..." or "CMO: ..."), route to that agent.
185
137
  - If no specialist clearly matches (general chat, greetings, coordination), respond with agentId "".
186
- - Be decisive. Reply with ONLY a JSON object, no other text.
187
-
188
- Format: {"agentId":"<id>","routingContext":"<1-sentence instruction>"}`
138
+ - Be decisive.`
189
139
 
190
140
  const CHECK_SYSTEM_PROMPT = `You decide if another team member should ALSO respond after the previous agent's response.
191
141
 
@@ -199,25 +149,37 @@ Rules:
199
149
  - If the user explicitly addressed multiple agents (e.g. "CTO: ... CMO: ...") and one hasn't responded yet, they MUST respond. Return done:false.
200
150
  - If the last agent's response explicitly defers to or recommends another specialist, that specialist SHOULD respond. Return done:false.
201
151
  - If there is a clearly separate dimension of the user's question not yet covered by any responded agent, add the best-fit remaining agent.
202
- - Do NOT add agents just for agreement, acknowledgement, or minor additions.
203
- - Reply with ONLY a JSON object, no other text.
152
+ - Do NOT add agents just for agreement, acknowledgement, or minor additions.`
204
153
 
205
- Format: {"done":true} or {"done":false,"agentId":"<id>","routingContext":"<1-sentence>"}`
154
+ const THREAD_ROUTER_TIMEOUT_MS = 30_000
206
155
 
207
- // ---------------------------------------------------------------------------
208
- // Agent functions
209
- // ---------------------------------------------------------------------------
210
-
211
- function createRouterAgent(systemPrompt: string) {
156
+ async function generateRouterObject<TSchema extends z.ZodTypeAny>(params: {
157
+ schema: TSchema
158
+ system: string
159
+ prompt: string
160
+ label: 'triage' | 'check'
161
+ }): Promise<z.infer<TSchema> | null> {
212
162
  const modelId = routerModelId ?? 'openai/gpt-5.4-nano'
213
- return new ToolLoopAgent({
214
- id: 'thread-router',
215
- model: aiGatewayChatModel(modelId),
216
- headers: buildAiGatewayDirectCacheHeaders('thread-router'),
217
- providerOptions: { openai: { reasoningEffort: 'low' } },
218
- instructions: systemPrompt,
219
- maxOutputTokens: 256,
220
- })
163
+
164
+ try {
165
+ const { object } = await withTimeout(
166
+ generateObject({
167
+ model: aiGatewayChatModel(modelId),
168
+ headers: buildAiGatewayDirectCacheHeaders('thread-router'),
169
+ providerOptions: { openai: { reasoningEffort: 'low' } },
170
+ schema: params.schema,
171
+ system: params.system,
172
+ prompt: params.prompt,
173
+ maxOutputTokens: 256,
174
+ }),
175
+ THREAD_ROUTER_TIMEOUT_MS,
176
+ `thread-router ${params.label}`,
177
+ )
178
+ return params.schema.parse(object)
179
+ } catch (error) {
180
+ chatLogger.error`[thread-router] ${params.label} failed: ${error instanceof Error ? error.message : String(error)}`
181
+ return null
182
+ }
221
183
  }
222
184
 
223
185
  export async function triageThreadMessage(params: {
@@ -253,40 +215,27 @@ export async function triageThreadMessage(params: {
253
215
  .filter(Boolean)
254
216
  .join('\n\n')
255
217
 
256
- const agent = createRouterAgent(TRIAGE_SYSTEM_PROMPT)
257
- let result: Awaited<ReturnType<typeof agent.generate>>
258
- try {
259
- result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
260
- } catch (error) {
261
- console.error('[thread-router] triage failed:', error instanceof Error ? error.message : error)
262
- return null
263
- }
218
+ const parsed = await generateRouterObject({
219
+ schema: TriageResultSchema,
220
+ system: TRIAGE_SYSTEM_PROMPT,
221
+ prompt,
222
+ label: 'triage',
223
+ })
264
224
 
265
- const effectiveText = extractResultText(result as { text?: string; reasoning?: unknown })
266
- logRouterRaw('triage', effectiveText)
267
- const json = extractJson(effectiveText)
268
- if (json === null) {
269
- if (effectiveText.trim()) {
270
- console.log('[thread-router] triage ignored non-json output')
271
- }
272
- return null
273
- }
274
- const parsed = TriageResultSchema.safeParse(json)
275
- if (!parsed.success) {
276
- console.log('[thread-router] triage parse failed:', JSON.stringify(parsed.error.issues))
225
+ if (!parsed) {
277
226
  return null
278
227
  }
279
- if (!parsed.data.agentId) {
280
- console.log('[thread-router] triage returned empty agentId — fallback to owner')
228
+ if (!parsed.agentId) {
229
+ chatLogger.debug`[thread-router] triage returned empty agentId`
281
230
  return null
282
231
  }
283
- if (!params.members.includes(parsed.data.agentId)) {
284
- console.log('[thread-router] triage returned unknown agent:', parsed.data.agentId)
232
+ if (!params.members.includes(parsed.agentId)) {
233
+ chatLogger.warn`[thread-router] triage returned unknown agent: ${parsed.agentId}`
285
234
  return null
286
235
  }
287
236
 
288
- console.log('[thread-router] triage routed to:', parsed.data.agentId)
289
- return parsed.data
237
+ chatLogger.debug`[thread-router] triage routed to ${parsed.agentId}`
238
+ return parsed
290
239
  }
291
240
 
292
241
  export async function checkForNextAgent(params: {
@@ -328,38 +277,22 @@ export async function checkForNextAgent(params: {
328
277
  `Last agent response:\n"${params.lastResponseSummary}"`,
329
278
  ].join('\n\n')
330
279
 
331
- const agent = createRouterAgent(CHECK_SYSTEM_PROMPT)
332
- let result: Awaited<ReturnType<typeof agent.generate>>
333
- try {
334
- result = await agent.generate({ messages: [{ role: 'user', content: prompt }], timeout: { totalMs: 30_000 } })
335
- } catch (error) {
336
- console.error('[thread-router] check failed:', error instanceof Error ? error.message : error)
337
- return { done: true }
338
- }
280
+ const parsed = await generateRouterObject({
281
+ schema: CheckResultObjectSchema,
282
+ system: CHECK_SYSTEM_PROMPT,
283
+ prompt,
284
+ label: 'check',
285
+ })
339
286
 
340
- const effectiveText = extractResultText(result as { text?: string; reasoning?: unknown })
341
- logRouterRaw('check', effectiveText)
342
- const json = extractJson(effectiveText)
343
- if (json === null) {
344
- if (effectiveText.trim()) {
345
- console.log('[thread-router] check ignored non-json output')
346
- }
347
- return { done: true }
348
- }
349
- const parsed = CheckResultSchema.safeParse(json)
350
- if (!parsed.success) {
351
- console.log('[thread-router] check parse failed:', JSON.stringify(parsed.error.issues))
352
- return { done: true }
353
- }
354
- if (parsed.data.done) {
355
- console.log('[thread-router] check: done, no more agents needed')
287
+ if (!parsed || parsed.done) {
288
+ chatLogger.debug`[thread-router] check finished without another agent`
356
289
  return { done: true }
357
290
  }
358
- if (!parsed.data.agentId || !remainingMembers.includes(parsed.data.agentId)) {
359
- console.log('[thread-router] check: invalid agentId:', parsed.data.agentId)
291
+ if (!parsed.agentId || !remainingMembers.includes(parsed.agentId)) {
292
+ chatLogger.warn`[thread-router] check returned invalid agent: ${parsed.agentId ?? 'missing'}`
360
293
  return { done: true }
361
294
  }
362
295
 
363
- console.log('[thread-router] check: next agent:', parsed.data.agentId)
364
- return parsed.data
296
+ chatLogger.debug`[thread-router] check selected ${parsed.agentId}`
297
+ return { done: false, agentId: parsed.agentId, routingContext: parsed.routingContext ?? null }
365
298
  }
@@ -9,22 +9,21 @@ class TimeoutError extends Error {
9
9
  }
10
10
 
11
11
  export async function withTimeout<T>(promise: Promise<T>, ms: number, operation: string): Promise<T> {
12
- const controller = new AbortController()
13
- const timeoutId = setTimeout(() => {
14
- controller.abort()
15
- }, ms)
12
+ let timeoutId: ReturnType<typeof setTimeout> | undefined
16
13
 
17
14
  try {
18
15
  return await Promise.race([
19
16
  promise,
20
17
  new Promise<never>((_, reject) => {
21
- controller.signal.addEventListener('abort', () => {
18
+ timeoutId = setTimeout(() => {
22
19
  reject(new TimeoutError(operation, ms))
23
- })
20
+ }, ms)
24
21
  }),
25
22
  ])
26
23
  } finally {
27
- clearTimeout(timeoutId)
24
+ if (timeoutId) {
25
+ clearTimeout(timeoutId)
26
+ }
28
27
  }
29
28
  }
30
29