@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.
- package/infrastructure/schema/00_thread.surql +53 -55
- package/infrastructure/schema/04_runtime_bootstrap.surql +1 -0
- package/package.json +2 -2
- package/src/create-runtime.ts +2 -2
- package/src/db/record-id.ts +21 -21
- package/src/db/service.ts +44 -40
- package/src/runtime/runtime-config.ts +14 -11
- package/src/services/plan-executor.service.ts +9 -2
- package/src/services/thread-turn-preparation.service.ts +17 -28
- package/src/services/thread.service.ts +243 -97
- package/src/system-agents/thread-router.agent.ts +62 -129
- package/src/utils/async.ts +6 -7
|
@@ -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
|
|
17
|
-
DEFINE FIELD activeStreamId
|
|
18
|
-
DEFINE FIELD memoryBlock
|
|
19
|
-
DEFINE FIELD memoryBlockSummary
|
|
20
|
-
DEFINE FIELD compactionSummary
|
|
21
|
-
DEFINE FIELD lastCompactedMessageId ON thread TYPE option<string>;
|
|
22
|
-
DEFINE FIELD isCompacting
|
|
23
|
-
DEFINE FIELD turnCount
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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",
|
package/src/create-runtime.ts
CHANGED
|
@@ -90,7 +90,7 @@ type ArchiveSdkThread = (
|
|
|
90
90
|
|
|
91
91
|
type UnarchiveSdkThread = (
|
|
92
92
|
threadId: Parameters<typeof threadServiceSingleton.updateStatus>[0],
|
|
93
|
-
status?: '
|
|
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 = '
|
|
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),
|
package/src/db/record-id.ts
CHANGED
|
@@ -8,13 +8,18 @@ export interface RecordIdShape {
|
|
|
8
8
|
id: RecordIdValue
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
33
|
-
if (typeof
|
|
34
|
-
return
|
|
36
|
+
const constructor = (value as { constructor?: unknown }).constructor
|
|
37
|
+
if (typeof constructor !== 'function') {
|
|
38
|
+
return false
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
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,
|
|
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 ===
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
263
|
-
? bootstrapConfig.completedDefaultAgents
|
|
264
|
-
: bootstrapConfig.onboardingDefaultAgents
|
|
265
|
-
|
|
266
|
-
const creations: Promise<{ record: ThreadRecord; created: boolean }>[] = []
|
|
294
|
+
const updates: Partial<ThreadRecord> = {}
|
|
267
295
|
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
createdResults = await Promise.all(creations)
|
|
306
|
+
if (Object.keys(updates).length === 0) {
|
|
307
|
+
return record
|
|
294
308
|
}
|
|
295
309
|
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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 {
|
|
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
|
|
12
|
+
const CheckResultObjectSchema = z.object({
|
|
15
13
|
done: z.boolean(),
|
|
16
|
-
agentId: z.string().
|
|
17
|
-
routingContext: z.string().
|
|
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 =
|
|
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
|
|
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
|
-
|
|
154
|
+
const THREAD_ROUTER_TIMEOUT_MS = 30_000
|
|
206
155
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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.
|
|
280
|
-
|
|
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.
|
|
284
|
-
|
|
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
|
-
|
|
289
|
-
return parsed
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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.
|
|
359
|
-
|
|
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
|
-
|
|
364
|
-
return parsed.
|
|
296
|
+
chatLogger.debug`[thread-router] check selected ${parsed.agentId}`
|
|
297
|
+
return { done: false, agentId: parsed.agentId, routingContext: parsed.routingContext ?? null }
|
|
365
298
|
}
|
package/src/utils/async.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
18
|
+
timeoutId = setTimeout(() => {
|
|
22
19
|
reject(new TimeoutError(operation, ms))
|
|
23
|
-
})
|
|
20
|
+
}, ms)
|
|
24
21
|
}),
|
|
25
22
|
])
|
|
26
23
|
} finally {
|
|
27
|
-
|
|
24
|
+
if (timeoutId) {
|
|
25
|
+
clearTimeout(timeoutId)
|
|
26
|
+
}
|
|
28
27
|
}
|
|
29
28
|
}
|
|
30
29
|
|