dsh-bailinghub 0.2.0 → 0.4.0
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/CHANGELOG.md +70 -0
- package/PRIVACY.md +108 -9
- package/README.md +129 -148
- package/SECURITY.md +114 -4
- package/docs/AGENT_CLIENT_CONTRACT.md +330 -23
- package/docs/COMPATIBILITY.md +75 -12
- package/docs/GETTING_STARTED.md +126 -0
- package/docs/GETTING_STARTED.zh-CN.md +113 -0
- package/docs/MIGRATION_VNEXT.md +106 -22
- package/docs/PROJECT_BOUNDARIES.md +4 -2
- package/docs/README.zh-CN.md +98 -132
- package/lib/authorizations.js +61 -0
- package/lib/conversation-archive-store.js +265 -0
- package/lib/conversation-outbox.js +230 -0
- package/lib/index.js +3 -0
- package/lib/runtime.js +1070 -64
- package/lib/session-scope-store.js +265 -0
- package/lib/session-scope.js +319 -0
- package/lib/transport.js +16 -2
- package/package.json +14 -4
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { createFileConversationArchiveStore } from './conversation-archive-store.js'
|
|
3
|
+
|
|
4
|
+
const SCHEMA = 'bailing.agent-conversation-outbox.v1'
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
6
|
+
const KEY = /^conn_[0-9a-f]{32}$/
|
|
7
|
+
const KINDS = new Set(['turn_start', 'user_message', 'assistant_message', 'run_link', 'turn_end'])
|
|
8
|
+
const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
9
|
+
const fail = (code = 'ARCHIVE_INVALID') => Object.assign(new Error(`Conversation archive unavailable (${code})`), { code })
|
|
10
|
+
|
|
11
|
+
function eventPayload(value) {
|
|
12
|
+
const copy = structuredClone(value)
|
|
13
|
+
if (!copy || !KINDS.has(copy.kind) || typeof copy.client_turn_id !== 'string' || !copy.client_turn_id || copy.client_turn_id.length > 256 ||
|
|
14
|
+
Object.keys(copy).some((key) => !['kind', 'client_turn_id', 'content', 'run_id', 'member_session_id', 'status'].includes(key))) throw fail()
|
|
15
|
+
const message = ['user_message', 'assistant_message'].includes(copy.kind)
|
|
16
|
+
// Preserve over-limit visible text locally. The SDK rejects a message above
|
|
17
|
+
// its remote limit; silently truncating it would fabricate a full transcript.
|
|
18
|
+
if (message ? typeof copy.content !== 'string' : Object.hasOwn(copy, 'content')) throw fail('ARCHIVE_MESSAGE_INVALID')
|
|
19
|
+
if (copy.kind === 'run_link' ? !UUID.test(copy.run_id ?? '') || !UUID.test(copy.member_session_id ?? '')
|
|
20
|
+
: Object.hasOwn(copy, 'run_id') || Object.hasOwn(copy, 'member_session_id')) throw fail()
|
|
21
|
+
if (copy.kind === 'turn_end' ? !['completed', 'failed', 'cancelled'].includes(copy.status) : Object.hasOwn(copy, 'status')) throw fail()
|
|
22
|
+
return copy
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function contextSnapshot(value) {
|
|
26
|
+
const copy = structuredClone(value)
|
|
27
|
+
if (typeof copy?.clientConversationId !== 'string' || !copy.clientConversationId || copy.clientConversationId.length > 256 ||
|
|
28
|
+
!copy.binding || typeof copy.binding.hubUrl !== 'string' || typeof copy.binding.clientAppId !== 'string' || typeof copy.binding.workspace !== 'string' ||
|
|
29
|
+
!Array.isArray(copy.members) || !copy.members.length || copy.members.length > 64 ||
|
|
30
|
+
Object.keys(copy).some((key) => !['clientConversationId', 'binding', 'members'].includes(key)) ||
|
|
31
|
+
Object.keys(copy.binding).some((key) => !['hubUrl', 'clientAppId', 'workspace'].includes(key))) throw fail('ARCHIVE_MEMBERS_INVALID')
|
|
32
|
+
const keys = new Set()
|
|
33
|
+
const identities = new Set()
|
|
34
|
+
for (const member of copy.members) {
|
|
35
|
+
if (!KEY.test(member?.connectionKey ?? '') || keys.has(member.connectionKey) || !UUID.test(member.expectedSessionId ?? '') ||
|
|
36
|
+
identities.has(member.expectedSessionId) || member.workspace !== copy.binding.workspace || typeof member.label !== 'string' || member.label.length > 128 ||
|
|
37
|
+
Object.keys(member).some((key) => !['connectionKey', 'workspace', 'expectedSessionId', 'label'].includes(key))) throw fail('ARCHIVE_MEMBERS_INVALID')
|
|
38
|
+
keys.add(member.connectionKey)
|
|
39
|
+
identities.add(member.expectedSessionId)
|
|
40
|
+
}
|
|
41
|
+
return copy
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validateRecord(record, sessionId, context) {
|
|
45
|
+
if (!record || record.schema !== SCHEMA || record.sessionId !== sessionId || !Number.isSafeInteger(record.revision) || record.revision < 1 ||
|
|
46
|
+
!UUID.test(record.clientArchiveId ?? '') || digest(contextSnapshot(record.context)) !== digest(context) ||
|
|
47
|
+
!Number.isSafeInteger(record.acknowledged) || record.acknowledged < 0 || !Array.isArray(record.events) || record.acknowledged > record.events.length ||
|
|
48
|
+
record.conversationId !== null && !UUID.test(record.conversationId ?? '') ||
|
|
49
|
+
Object.keys(record).some((key) => !['schema', 'sessionId', 'revision', 'clientArchiveId', 'context', 'acknowledged', 'conversationId', 'events', 'syncState'].includes(key)) ||
|
|
50
|
+
!['pending', 'synced', 'unsupported'].includes(record.syncState)) throw fail('ARCHIVE_BINDING_CHANGED')
|
|
51
|
+
const sources = new Set()
|
|
52
|
+
const ids = new Set()
|
|
53
|
+
const turns = new Map()
|
|
54
|
+
for (const [index, item] of record.events.entries()) {
|
|
55
|
+
const { event_id: id, sequence, ...payload } = item?.event ?? {}
|
|
56
|
+
const checked = eventPayload(payload)
|
|
57
|
+
if (Object.keys(item).some((key) => !['source', 'hash', 'event'].includes(key)) || !/^[a-f0-9]{64}$/.test(item.source ?? '') || sources.has(item.source) ||
|
|
58
|
+
!UUID.test(id ?? '') || ids.has(id) || sequence !== index + 1 || item.hash !== digest(checked)) throw fail('ARCHIVE_INVALID')
|
|
59
|
+
if (checked.kind === 'run_link' && !context.members.some((member) => member.expectedSessionId === checked.member_session_id)) throw fail('ARCHIVE_MEMBERS_INVALID')
|
|
60
|
+
const phase = turns.get(checked.client_turn_id)
|
|
61
|
+
if (checked.kind === 'turn_start' ? phase !== undefined
|
|
62
|
+
: checked.kind === 'run_link' ? !['started', 'ended'].includes(phase) : phase !== 'started') throw fail('ARCHIVE_EVENT_ORDER')
|
|
63
|
+
if (checked.kind === 'turn_start') turns.set(checked.client_turn_id, 'started')
|
|
64
|
+
if (checked.kind === 'turn_end') turns.set(checked.client_turn_id, 'ended')
|
|
65
|
+
sources.add(item.source)
|
|
66
|
+
ids.add(id)
|
|
67
|
+
}
|
|
68
|
+
return structuredClone(record)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One durable visible transcript, independent from per-authorization business runs. */
|
|
72
|
+
export class ConversationOutbox {
|
|
73
|
+
constructor({ store = createFileConversationArchiveStore() } = {}) {
|
|
74
|
+
if (typeof store?.load !== 'function' || typeof store?.save !== 'function') throw fail('ARCHIVE_STORE_INVALID')
|
|
75
|
+
this.store = store
|
|
76
|
+
this.entries = new Map()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
serialize(entry, action) {
|
|
80
|
+
const promise = (entry.queue ?? Promise.resolve()).then(action)
|
|
81
|
+
entry.queue = promise.catch(() => {})
|
|
82
|
+
return promise
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async load(entry) {
|
|
86
|
+
if (entry.record) return entry.record
|
|
87
|
+
const saved = await this.store.load(entry.sessionId)
|
|
88
|
+
if (saved) entry.record = validateRecord(saved, entry.sessionId, entry.context)
|
|
89
|
+
else {
|
|
90
|
+
const record = {
|
|
91
|
+
schema: SCHEMA, sessionId: entry.sessionId, revision: 1, clientArchiveId: randomUUID(), context: entry.context,
|
|
92
|
+
acknowledged: 0, conversationId: null, events: [], syncState: 'pending',
|
|
93
|
+
}
|
|
94
|
+
await this.store.save(entry.sessionId, record, null)
|
|
95
|
+
entry.record = structuredClone(record)
|
|
96
|
+
}
|
|
97
|
+
return entry.record
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
open(sessionId, value) {
|
|
101
|
+
const context = contextSnapshot(value)
|
|
102
|
+
let entry = this.entries.get(sessionId)
|
|
103
|
+
if (entry && digest(entry.context) !== digest(context)) throw fail('ARCHIVE_BINDING_CHANGED')
|
|
104
|
+
if (!entry) {
|
|
105
|
+
entry = { sessionId, context, pending: [], status: 'pending' }
|
|
106
|
+
this.entries.set(sessionId, entry)
|
|
107
|
+
}
|
|
108
|
+
return this.serialize(entry, async () => {
|
|
109
|
+
try { await this.load(entry); entry.status = entry.pending.length ? 'storage_error' : entry.record.syncState; return this.status(sessionId) }
|
|
110
|
+
catch (error) { entry.status = 'storage_error'; throw error }
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async save(entry, values) {
|
|
115
|
+
const previous = entry.record
|
|
116
|
+
const record = validateRecord({ ...previous, ...values, revision: previous.revision + 1 }, entry.sessionId, entry.context)
|
|
117
|
+
await this.store.save(entry.sessionId, record, previous.revision)
|
|
118
|
+
entry.record = record
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
append(sessionId, source, payload) {
|
|
122
|
+
const entry = this.entries.get(sessionId)
|
|
123
|
+
if (!entry) throw fail('ARCHIVE_NOT_OPEN')
|
|
124
|
+
// Capture before awaits; never retain mutable host messages or model chunks.
|
|
125
|
+
const item = { source: digest(String(source)), payload: eventPayload(payload) }
|
|
126
|
+
entry.pending.push(item)
|
|
127
|
+
return this.serialize(entry, () => this.persistPending(entry))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async persistPending(entry) {
|
|
131
|
+
try {
|
|
132
|
+
await this.load(entry)
|
|
133
|
+
const count = entry.pending.length
|
|
134
|
+
const events = structuredClone(entry.record.events)
|
|
135
|
+
for (const pending of entry.pending.slice(0, count)) {
|
|
136
|
+
const hash = digest(pending.payload)
|
|
137
|
+
const existing = events.find((event) => event.source === pending.source)
|
|
138
|
+
if (existing) {
|
|
139
|
+
if (existing.hash !== hash) throw fail('ARCHIVE_EVENT_CONFLICT')
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
events.push({ source: pending.source, hash, event: { event_id: randomUUID(), sequence: events.length + 1, ...pending.payload } })
|
|
143
|
+
}
|
|
144
|
+
if (events.length !== entry.record.events.length) await this.save(entry, { events, syncState: 'pending' })
|
|
145
|
+
entry.pending.splice(0, count)
|
|
146
|
+
entry.status = entry.record.syncState
|
|
147
|
+
return this.status(entry.sessionId)
|
|
148
|
+
} catch (error) { entry.status = 'storage_error'; throw error }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
status(sessionId) {
|
|
152
|
+
const entry = this.entries.get(sessionId)
|
|
153
|
+
if (!entry) return { state: 'inactive', pendingEvents: 0 }
|
|
154
|
+
return {
|
|
155
|
+
state: entry.status, pendingEvents: (entry.record?.events.length ?? 0) - (entry.record?.acknowledged ?? 0),
|
|
156
|
+
unsavedEvents: entry.pending.length, acknowledgedSequence: entry.record?.acknowledged ?? 0,
|
|
157
|
+
...(entry.record?.conversationId ? { conversationId: entry.record.conversationId } : {}),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
coverage(sessionId, expected) {
|
|
162
|
+
const events = this.entries.get(sessionId)?.record?.events ?? []
|
|
163
|
+
if (!Array.isArray(expected) || expected.length === 0 && events.length > 0) return { coverage: 'unverified' }
|
|
164
|
+
const available = new Map()
|
|
165
|
+
const signature = (event) => digest(['user_message', 'assistant_message'].includes(event.kind)
|
|
166
|
+
? { kind: event.kind, content: event.content }
|
|
167
|
+
: { kind: event.kind, client_turn_id: event.client_turn_id, ...(event.status ? { status: event.status } : {}) })
|
|
168
|
+
for (const { event } of events) {
|
|
169
|
+
if (event.kind === 'run_link') continue
|
|
170
|
+
const key = signature(event)
|
|
171
|
+
available.set(key, (available.get(key) ?? 0) + 1)
|
|
172
|
+
}
|
|
173
|
+
let missing = 0
|
|
174
|
+
for (const event of expected) {
|
|
175
|
+
const key = signature(event)
|
|
176
|
+
const count = available.get(key) ?? 0
|
|
177
|
+
if (count) available.set(key, count - 1)
|
|
178
|
+
else missing += 1
|
|
179
|
+
}
|
|
180
|
+
return missing ? { coverage: 'incomplete', missingVisibleEvents: missing } : { coverage: 'available_host_history_checked' }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
sync(sessionId, send) {
|
|
184
|
+
const entry = this.entries.get(sessionId)
|
|
185
|
+
if (!entry) return Promise.resolve(this.status(sessionId))
|
|
186
|
+
if (entry.syncing) return entry.syncing
|
|
187
|
+
entry.syncing = (async () => {
|
|
188
|
+
while (true) {
|
|
189
|
+
const snapshot = await this.serialize(entry, async () => {
|
|
190
|
+
await this.persistPending(entry)
|
|
191
|
+
return structuredClone(entry.record)
|
|
192
|
+
})
|
|
193
|
+
try {
|
|
194
|
+
const ack = await send({
|
|
195
|
+
clientArchiveId: snapshot.clientArchiveId, clientConversationId: snapshot.context.clientConversationId,
|
|
196
|
+
events: snapshot.events.slice(snapshot.acknowledged).map((item) => item.event),
|
|
197
|
+
}, { members: structuredClone(snapshot.context.members) })
|
|
198
|
+
if (ack?.schema !== 'bailing.agent-conversation-audit-ack.v1' || !UUID.test(ack.conversation_id ?? '') ||
|
|
199
|
+
!Number.isSafeInteger(ack.last_sequence) || ack.last_sequence < snapshot.acknowledged || ack.last_sequence > snapshot.events.length ||
|
|
200
|
+
snapshot.conversationId && ack.conversation_id !== snapshot.conversationId) throw fail('ARCHIVE_ACK_INVALID')
|
|
201
|
+
await this.serialize(entry, async () => {
|
|
202
|
+
await this.save(entry, {
|
|
203
|
+
acknowledged: ack.last_sequence, conversationId: ack.conversation_id,
|
|
204
|
+
syncState: ack.last_sequence === entry.record.events.length ? 'synced' : 'pending',
|
|
205
|
+
})
|
|
206
|
+
entry.status = entry.pending.length ? 'storage_error' : entry.record.syncState
|
|
207
|
+
})
|
|
208
|
+
// A final reply may arrive while an earlier batch is in flight. Drain
|
|
209
|
+
// newly captured events after success, without looping on failures.
|
|
210
|
+
await this.serialize(entry, () => this.persistPending(entry))
|
|
211
|
+
if (ack.last_sequence === snapshot.events.length && entry.record.events.length > snapshot.events.length) continue
|
|
212
|
+
} catch (error) {
|
|
213
|
+
await this.serialize(entry, async () => {
|
|
214
|
+
const state = error?.status === 404 || error?.statusCode === 404 || error?.code === 'ARCHIVE_UNSUPPORTED' ? 'unsupported' : 'pending'
|
|
215
|
+
try { await this.save(entry, { syncState: state }); entry.status = entry.pending.length ? 'storage_error' : state }
|
|
216
|
+
catch { entry.status = 'storage_error' }
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
return this.status(sessionId)
|
|
220
|
+
}
|
|
221
|
+
})().catch(() => { entry.status = 'storage_error'; return this.status(sessionId) }).finally(() => { entry.syncing = undefined })
|
|
222
|
+
return entry.syncing
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async drain(sessionId) {
|
|
226
|
+
const entry = this.entries.get(sessionId)
|
|
227
|
+
await entry?.queue
|
|
228
|
+
return this.status(sessionId)
|
|
229
|
+
}
|
|
230
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -33,6 +33,7 @@ export function createAgentClientPlugin(options = {}) {
|
|
|
33
33
|
config,
|
|
34
34
|
getTransport,
|
|
35
35
|
options.recovery,
|
|
36
|
+
{ scopeStore: options.scopeStore, archiveStore: options.archiveStore },
|
|
36
37
|
).install()
|
|
37
38
|
ctx.provide('bailingHubAgentClient', runtime)
|
|
38
39
|
},
|
|
@@ -44,6 +45,8 @@ export function apply(ctx, config) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export { BailingHubAgentClientRuntime } from './runtime.js'
|
|
48
|
+
export { createFileSessionScopeStore, createMemorySessionScopeStore } from './session-scope-store.js'
|
|
49
|
+
export { createFileConversationArchiveStore, createMemoryConversationArchiveStore } from './conversation-archive-store.js'
|
|
47
50
|
export {
|
|
48
51
|
DEFAULT_SDK_MODULE,
|
|
49
52
|
REQUIRED_METHODS,
|