@yeaft/webchat-agent 1.0.636 → 1.0.637
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +122 -116
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/person/capabilities.js +11 -3
- package/yeaft/person/contracts.js +2 -1
- package/yeaft/person/execution-goal.js +14 -2
- package/yeaft/person/execution-protocol.js +33 -5
- package/yeaft/person/inspection.js +1 -1
- package/yeaft/person/native-tools.js +13 -3
- package/yeaft/person/runtime.js +333 -202
- package/yeaft/person/service.js +13 -5
- package/yeaft/person/sqlite-repository.js +11 -0
- package/yeaft/person/sqlite-store.js +154 -20
- package/yeaft/person/tool-arguments.js +7 -2
package/yeaft/person/service.js
CHANGED
|
@@ -147,19 +147,27 @@ export function createPersonService(options = {}) {
|
|
|
147
147
|
object(payload, op === 'dream' ? ['clientMessageId'] : ['text', 'clientMessageId', 'files'], ['clientMessageId']);
|
|
148
148
|
identifier(payload.clientMessageId);
|
|
149
149
|
validateFiles(payload.files, payload.text ?? '', op);
|
|
150
|
+
const stopGeneration = autonomyStops.get(ownerId) ?? 0;
|
|
150
151
|
// Durable cancellation may already fence the old episode; do not start a
|
|
151
152
|
// new local activity while its host effects are still being joined.
|
|
152
|
-
if (runtime.isOwnerRunning(ownerId)) {
|
|
153
|
+
if (runtime.pendingCancellations.has(ownerId) || (op !== 'send' && runtime.isOwnerRunning(ownerId))) {
|
|
153
154
|
const receipt = await repository.receipt(ownerId, payload.clientMessageId);
|
|
154
155
|
if (!receipt.found) fail('BUSY');
|
|
155
156
|
}
|
|
156
157
|
const admitted = await repository.admit(ownerId, { kind: op, text: payload.text ?? '', clientMessageId: payload.clientMessageId,
|
|
157
158
|
workerId: runtime.workerId, budget: runtime.budget, files: payload.files });
|
|
158
|
-
if (!admitted.duplicate) {
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
if (!admitted.duplicate && !admitted.appended) {
|
|
160
|
+
const cancelled = (autonomyStops.get(ownerId) ?? 0) !== stopGeneration || runtime.pendingCancellations.has(ownerId);
|
|
161
|
+
if (closed || cancelled) {
|
|
162
|
+
// Admission can outlive the entire cancellation join. Fence only this
|
|
163
|
+
// new lease, never an appended input or a replacement job.
|
|
164
|
+
await repository.finish(admitted.episode, cancelled ? 'cancelled' : 'interrupted', cancelled ? 'CANCELLED' : 'INTERRUPTED');
|
|
165
|
+
const receipt = await repository.receipt(ownerId, payload.clientMessageId);
|
|
166
|
+
admitted.status = receipt.status; admitted.deliveryState = receipt.deliveryState;
|
|
167
|
+
} else runtime.start(admitted.episode);
|
|
161
168
|
}
|
|
162
|
-
return { episodeId: admitted.episodeId, duplicate: admitted.duplicate, status: admitted.status
|
|
169
|
+
return { episodeId: admitted.episodeId, duplicate: admitted.duplicate, status: admitted.status,
|
|
170
|
+
messageId: admitted.messageId, deliveryState: admitted.deliveryState };
|
|
163
171
|
}
|
|
164
172
|
case 'traces': await repository.recover(ownerId); return repository.list(ownerId, 'traces', page(payload));
|
|
165
173
|
case 'messages': return repository.list(ownerId, 'messages', page(payload));
|
|
@@ -93,6 +93,17 @@ export class SqlitePersonRepository {
|
|
|
93
93
|
async autonomyAdmit(ownerId, input) { return this.request('autonomyAdmit', [ownerId, input]); }
|
|
94
94
|
async admit(ownerId, input) { return this.request('admit', [ownerId, input]); }
|
|
95
95
|
async receipt(ownerId, clientMessageId, requestHash) { return this.request('receipt', [ownerId, clientMessageId, requestHash]); }
|
|
96
|
+
/** Read-only, live-owner check of stored received version against arrivals. */
|
|
97
|
+
async pendingInputs(episode) { return this.request('pendingInputs', [episode]); }
|
|
98
|
+
/** Atomically acknowledge at most four inputs with bounded textual context.
|
|
99
|
+
* Returns public messages, full attachment envelopes and the received version.
|
|
100
|
+
* The caller must set episode.inboxVersion to that version before new work. */
|
|
101
|
+
async receiveInputs(episode) { return this.request('receiveInputs', [episode]); }
|
|
102
|
+
/** Throws INPUT_PENDING without invalidating the lease or in-flight finalizers. */
|
|
103
|
+
async guardExecutionInput(episode) { return this.request('guardExecutionInput', [episode]); }
|
|
104
|
+
/** Exact consumed user targets; first publication requires its started
|
|
105
|
+
* Conversation.reply capability. Identical callId retries return the message. */
|
|
106
|
+
async publishReply(episode, data) { return this.request('publishReply', [episode, data]); }
|
|
96
107
|
async executionControl(episode, control) { return this.request('executionControl', [episode, control]); }
|
|
97
108
|
async heartbeat(episode) { return this.request('heartbeat', [episode]); }
|
|
98
109
|
async append(episode, kind, data) { return this.request('append', [episode, kind, data]); }
|
|
@@ -23,7 +23,7 @@ const scopeValues = s => [s.namespace, s.ownerId, s.personId];
|
|
|
23
23
|
const SEARCH_COLUMNS = Object.freeze({ messages: 'text', concepts: 'statement' });
|
|
24
24
|
const metadataDates = Object.freeze({
|
|
25
25
|
persons: ['createdAt', 'leaseUntil'], episodes: ['createdAt', 'endedAt', 'callFinalizeUntil'],
|
|
26
|
-
messages: ['createdAt'], states: ['updatedAt'], concepts: ['updatedAt'], concept_revisions: ['updatedAt'],
|
|
26
|
+
messages: ['createdAt', 'receivedAt'], states: ['updatedAt'], concepts: ['updatedAt'], concept_revisions: ['updatedAt'],
|
|
27
27
|
state_commits: ['createdAt'], traces: ['createdAt'],
|
|
28
28
|
});
|
|
29
29
|
const decode = (row, table) => {
|
|
@@ -93,8 +93,8 @@ const objectOutput = (record, data) => {
|
|
|
93
93
|
if (!mimes.includes(record.mimeType) || !(data instanceof Uint8Array) || data.length !== record.size) fail('INVALID_REQUEST');
|
|
94
94
|
}
|
|
95
95
|
};
|
|
96
|
-
const READS = new Set(['autonomyOwners', 'checkExecutionRead', 'executionRead', 'outputs', 'outputRead', 'checkOutputPublication', 'receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
|
|
97
|
-
const WRITES = new Set(['autonomyObserve', 'autonomyAdmit', 'executionControl', 'savePresentation', 'saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
|
|
96
|
+
const READS = new Set(['pendingInputs', 'guardExecutionInput', 'autonomyOwners', 'checkExecutionRead', 'executionRead', 'outputs', 'outputRead', 'checkOutputPublication', 'receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
|
|
97
|
+
const WRITES = new Set(['receiveInputs', 'publishReply', 'autonomyObserve', 'autonomyAdmit', 'executionControl', 'savePresentation', 'saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
|
|
98
98
|
const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
|
|
99
99
|
const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
|
|
100
100
|
const sequence = value => {
|
|
@@ -197,7 +197,7 @@ export class SqlitePersonStore {
|
|
|
197
197
|
p = this.doc(scope, {
|
|
198
198
|
name, soul: 'A continuous, curious and honest digital person. Preserve uncertainty, reconsider your judgments, distinguish imagination from experience, and respect permissions. You may disagree without acting without authority.',
|
|
199
199
|
soulRevision: 1, createdAt: this.now, settings: { autonomyEnabled: false, defaultModel: null },
|
|
200
|
-
epoch: 0, writeSerial: 0, inputWatermark: 0, controlVersion: 0, stateVersion: 0,
|
|
200
|
+
epoch: 0, writeSerial: 0, inputWatermark: 0, inboxVersion: 0, controlVersion: 0, stateVersion: 0,
|
|
201
201
|
traceSeq: 0, messageSeq: 0, activeEpisodeId: null, leaseOwner: null, leaseUntil: new Date(0),
|
|
202
202
|
});
|
|
203
203
|
this.put('persons', p, true);
|
|
@@ -236,6 +236,7 @@ export class SqlitePersonStore {
|
|
|
236
236
|
Object.assign(episode, { status: 'interrupted', terminalCode: 'INTERRUPTED', endedAt: this.now });
|
|
237
237
|
this.put('episodes', episode); this.abandonCall(ownerId, episode, 'INTERRUPTED');
|
|
238
238
|
}
|
|
239
|
+
this.stopInputs(scope, id);
|
|
239
240
|
this.trace(ownerId, id, 'interrupted', { code: 'INTERRUPTED', reason: 'lease-expired', baseStateVersion: p.stateVersion });
|
|
240
241
|
}
|
|
241
242
|
for (const episode of this.rows('episodes', scope, " AND status = ? AND callFinalizeUntil <= ? AND json_type(record, '$.openCall') IS NOT NULL", ['cancelled', this.now.getTime()])) {
|
|
@@ -245,8 +246,14 @@ export class SqlitePersonStore {
|
|
|
245
246
|
}
|
|
246
247
|
receipt(ownerId, clientMessageId, requestHash) {
|
|
247
248
|
identifier(clientMessageId);
|
|
248
|
-
const
|
|
249
|
-
|
|
249
|
+
const scope = this.scope(ownerId);
|
|
250
|
+
const message = this.one('messages', scope, " AND json_extract(record, '$.clientMessageId') = ? AND json_extract(record, '$.role') = 'user'", [clientMessageId]);
|
|
251
|
+
const episode = message ? this.one('episodes', scope, ' AND id = ?', [message.episodeId])
|
|
252
|
+
: this.one('episodes', scope, ' AND clientMessageId = ?', [clientMessageId]);
|
|
253
|
+
const receipt = admissionReceipt(episode && message ? { ...episode, kind: message.id === episode.messageId ? episode.kind : 'send',
|
|
254
|
+
messageId: message.id, text: message.text, attachments: message.attachments, requestHash: message.requestHash ?? episode.requestHash } : episode,
|
|
255
|
+
clientMessageId, requestHash);
|
|
256
|
+
return receipt.found ? { ...receipt, deliveryState: message?.deliveryState ?? (receipt.messageId ? 'received' : null) } : receipt;
|
|
250
257
|
}
|
|
251
258
|
/** Only explicit v1 opt-in is schedulable. Legacy settings never acquire consent. */
|
|
252
259
|
autonomyOwners({ after = '', limit = 50 } = {}) {
|
|
@@ -328,13 +335,27 @@ export class SqlitePersonStore {
|
|
|
328
335
|
identifier(clientMessageId); text(workerId, 256);
|
|
329
336
|
this.recover(ownerId);
|
|
330
337
|
const scope = this.scope(ownerId), requestHash = attachmentRequestHash(kind, input, validated);
|
|
331
|
-
const existing = this.
|
|
332
|
-
if (existing) {
|
|
333
|
-
|
|
334
|
-
return { episodeId: existing.id, duplicate: true, status: existing.status };
|
|
335
|
-
}
|
|
338
|
+
const existing = this.receipt(ownerId, clientMessageId, requestHash);
|
|
339
|
+
if (existing.found) return { episodeId: existing.episodeId, duplicate: true, status: existing.status,
|
|
340
|
+
messageId: existing.messageId, deliveryState: existing.deliveryState };
|
|
336
341
|
const p = this.getPerson(ownerId);
|
|
337
|
-
if (p.activeEpisodeId)
|
|
342
|
+
if (p.activeEpisodeId) {
|
|
343
|
+
if (kind !== 'send') fail('BUSY');
|
|
344
|
+
const active = this.one('episodes', scope, ' AND id = ?', [p.activeEpisodeId]);
|
|
345
|
+
if (!active || active.status !== 'running') fail('STALE');
|
|
346
|
+
// Arrival changes no execution lease/fence or cognitive revision. Only an
|
|
347
|
+
// explicit receive acknowledges this new instruction at a safe boundary.
|
|
348
|
+
p.inboxVersion = (p.inboxVersion ?? 0) + 1; p.messageSeq++; p.writeSerial++;
|
|
349
|
+
this.put('persons', p);
|
|
350
|
+
for (const file of validated) this.put('attachments', this.doc(scope, file));
|
|
351
|
+
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: active.id,
|
|
352
|
+
role: 'user', text: input, attachments, createdAt: this.now, clientMessageId, requestHash,
|
|
353
|
+
inboxVersion: p.inboxVersion, deliveryState: 'queued' });
|
|
354
|
+
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
355
|
+
this.trace(ownerId, active.id, 'input_queued', { messageId: message.id, inboxVersion: p.inboxVersion });
|
|
356
|
+
return { episodeId: active.id, duplicate: false, appended: true, status: active.status,
|
|
357
|
+
messageId: message.id, deliveryState: message.deliveryState };
|
|
358
|
+
}
|
|
338
359
|
if (kind !== 'autonomous' && p.settings.autonomyEnabled === true && p.autonomy?.version === 1) {
|
|
339
360
|
p.autonomy = { ...p.autonomy, backoffMs: 60000, nextAt: this.now.getTime() + 60000, lastFingerprint: null };
|
|
340
361
|
}
|
|
@@ -344,20 +365,93 @@ export class SqlitePersonStore {
|
|
|
344
365
|
// must never replace the latest substantive user instruction.
|
|
345
366
|
const hasMessage = kind === 'send' || (kind === 'think' && (input.trim().length > 0 || attachments.length > 0));
|
|
346
367
|
p.activeEpisodeId = id; p.leaseOwner = workerId; p.leaseUntil = new Date(this.now.getTime() + this.leaseMs);
|
|
347
|
-
p.epoch++; p.writeSerial++; p.inputWatermark++;
|
|
368
|
+
p.epoch++; p.writeSerial++; p.inputWatermark++;
|
|
369
|
+
p.inboxVersion = (p.inboxVersion ?? 0) + Number(hasMessage); if (hasMessage) p.messageSeq++;
|
|
348
370
|
this.put('persons', p);
|
|
349
371
|
let messageId = null;
|
|
350
372
|
for (const file of validated) this.put('attachments', this.doc(scope, file));
|
|
351
373
|
if (hasMessage) {
|
|
352
374
|
messageId = randomUUID();
|
|
353
|
-
const message = this.doc(scope, { id: messageId, revision: 1, seq: p.messageSeq, episodeId: id, role: 'user', text: input, attachments, createdAt: this.now, clientMessageId
|
|
375
|
+
const message = this.doc(scope, { id: messageId, revision: 1, seq: p.messageSeq, episodeId: id, role: 'user', text: input, attachments, createdAt: this.now, clientMessageId, requestHash,
|
|
376
|
+
inboxVersion: p.inboxVersion, deliveryState: 'received', receivedAt: this.now });
|
|
354
377
|
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
355
378
|
}
|
|
356
379
|
const episode = this.doc(scope, { id, clientMessageId, requestHash, kind, text: input, messageId, attachments, modelCandidates: [...(p.settings.modelCandidates ?? [])], defaultModel: p.settings.defaultModel ?? null, status: 'running', workerId, epoch: p.epoch,
|
|
357
|
-
baseStateVersion: p.stateVersion, inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, autonomyGeneration: p.settings.autonomyEnabled === true ? p.autonomy?.generation ?? null : null, budget, createdAt: this.now });
|
|
380
|
+
baseStateVersion: p.stateVersion, inboxVersion: p.inboxVersion, lastInputSeq: hasMessage ? p.messageSeq : 0, inputMessageIds: messageId ? [messageId] : [], inputWatermark: p.inputWatermark, controlVersion: p.controlVersion, autonomyGeneration: p.settings.autonomyEnabled === true ? p.autonomy?.generation ?? null : null, budget, createdAt: this.now });
|
|
358
381
|
this.put('episodes', episode, true);
|
|
359
382
|
this.trace(ownerId, id, 'accepted', { trigger: { kind, text: input, messageId, attachments }, baseStateVersion: p.stateVersion, budget });
|
|
360
|
-
return { episodeId: id, duplicate: false, status: 'running', episode };
|
|
383
|
+
return { episodeId: id, duplicate: false, status: 'running', messageId, deliveryState: hasMessage ? 'received' : null, episode };
|
|
384
|
+
}
|
|
385
|
+
inputEpisode(episode) {
|
|
386
|
+
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
387
|
+
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
388
|
+
if (!record || record.status !== 'running') fail('STALE');
|
|
389
|
+
return { p, scope, record };
|
|
390
|
+
}
|
|
391
|
+
pendingInputs(episode) {
|
|
392
|
+
const { p, record } = this.inputEpisode(episode);
|
|
393
|
+
return (record.inboxVersion ?? 0) < (p.inboxVersion ?? 0);
|
|
394
|
+
}
|
|
395
|
+
inputCurrent(episode, p, record) {
|
|
396
|
+
if ((record.inboxVersion ?? 0) < (p.inboxVersion ?? 0)) return false;
|
|
397
|
+
if (episode.inboxVersion !== undefined) return episode.inboxVersion === (record.inboxVersion ?? 0);
|
|
398
|
+
// Legacy callers may omit the version for an initial episode, but cannot
|
|
399
|
+
// silently claim instructions subsequently consumed by this same worker.
|
|
400
|
+
const initial = record.messageId ? this.one('messages', this.episodeScope(episode), ' AND id = ?', [record.messageId]) : null;
|
|
401
|
+
return (record.lastInputSeq ?? 0) <= (initial?.seq ?? 0);
|
|
402
|
+
}
|
|
403
|
+
guardExecutionInput(episode) {
|
|
404
|
+
const { p, record } = this.inputEpisode(episode);
|
|
405
|
+
if (!this.inputCurrent(episode, p, record)) fail('INPUT_PENDING');
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
inputFiles(scope, messages) {
|
|
409
|
+
const files = new Map();
|
|
410
|
+
for (const message of messages) for (const ref of message.attachments ?? []) {
|
|
411
|
+
const file = this.one('attachments', scope, ' AND id = ?', [ref.id]);
|
|
412
|
+
if (!file || file.sha256 !== ref.sha256) fail('STORAGE_UNAVAILABLE');
|
|
413
|
+
files.set(file.id, publicDoc(file));
|
|
414
|
+
}
|
|
415
|
+
return [...files.values()];
|
|
416
|
+
}
|
|
417
|
+
receiveInputs(episode) {
|
|
418
|
+
const { p, scope, record } = this.inputEpisode(episode);
|
|
419
|
+
const queued = this.rows('messages', scope,
|
|
420
|
+
" AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.deliveryState') = 'queued' ORDER BY seq ASC LIMIT 4", [episode.id]);
|
|
421
|
+
const messages = []; let size = 0;
|
|
422
|
+
for (const message of queued) {
|
|
423
|
+
// The textual context is <=64KiB; image bytes are separately bounded by
|
|
424
|
+
// attachment admission (and returned whole, never partially acknowledged).
|
|
425
|
+
const files = this.inputFiles(scope, [message]);
|
|
426
|
+
const cost = bytes(messageView(message)) + files.reduce((n, file) => n + bytes(file.content ?? ''), 0);
|
|
427
|
+
if (messages.length && size + cost > 64 * 1024) break;
|
|
428
|
+
size += cost;
|
|
429
|
+
message.deliveryState = 'received'; message.receivedAt = this.now; message.revision++;
|
|
430
|
+
this.put('messages', message); this.journal(scope, 'messages', message);
|
|
431
|
+
record.inboxVersion = message.inboxVersion; record.lastInputSeq = message.seq;
|
|
432
|
+
record.inputMessageIds = [...new Set([...(record.inputMessageIds ?? (record.messageId ? [record.messageId] : [])), message.id])];
|
|
433
|
+
messages.push(message);
|
|
434
|
+
}
|
|
435
|
+
if (messages.length) {
|
|
436
|
+
this.put('episodes', record); p.writeSerial++; this.put('persons', p);
|
|
437
|
+
this.trace(episode.ownerId, episode.id, 'inputs_received', { messageIds: messages.map(message => message.id), inboxVersion: record.inboxVersion });
|
|
438
|
+
}
|
|
439
|
+
return { messages: messages.map(messageView), attachments: this.inputFiles(scope, messages), inboxVersion: record.inboxVersion ?? 0 };
|
|
440
|
+
}
|
|
441
|
+
stopInputs(scope, episodeId) {
|
|
442
|
+
for (const message of this.rows('messages', scope,
|
|
443
|
+
" AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.deliveryState') = 'queued' ORDER BY seq ASC", [episodeId])) {
|
|
444
|
+
message.deliveryState = 'stopped'; message.revision++;
|
|
445
|
+
this.put('messages', message); this.journal(scope, 'messages', message);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
consumedMessageIds(scope, record, targets) {
|
|
449
|
+
const consumed = new Set(record.inputMessageIds ?? (record.messageId ? [record.messageId] : []));
|
|
450
|
+
return (targets ?? [...consumed].slice(-24)).filter(id => {
|
|
451
|
+
if (!consumed.has(id)) return false;
|
|
452
|
+
const message = this.one('messages', scope, ' AND id = ?', [id]);
|
|
453
|
+
return message?.role === 'user' && message.episodeId === record.id && (message.deliveryState ?? 'received') === 'received';
|
|
454
|
+
});
|
|
361
455
|
}
|
|
362
456
|
episodeAttachments(episode) {
|
|
363
457
|
this.own(episode);
|
|
@@ -405,7 +499,7 @@ export class SqlitePersonStore {
|
|
|
405
499
|
}
|
|
406
500
|
append(episode, kind, data) {
|
|
407
501
|
// Call proof/publication events are emitted only by their transactional methods.
|
|
408
|
-
if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published', 'presentation_published'].includes(kind)) fail('INVALID_REQUEST');
|
|
502
|
+
if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published', 'presentation_published', 'message_reply', 'input_queued', 'inputs_received'].includes(kind)) fail('INVALID_REQUEST');
|
|
409
503
|
const p = this.own(episode);
|
|
410
504
|
const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
|
|
411
505
|
if (!record || record.status !== 'running') fail('STALE');
|
|
@@ -416,6 +510,7 @@ export class SqlitePersonStore {
|
|
|
416
510
|
return this.trace(episode.ownerId, episode.id, kind, data);
|
|
417
511
|
}
|
|
418
512
|
startCall(episode, data) {
|
|
513
|
+
this.guardExecutionInput(episode);
|
|
419
514
|
identifier(data.callId);
|
|
420
515
|
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
421
516
|
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
@@ -467,6 +562,7 @@ export class SqlitePersonStore {
|
|
|
467
562
|
return started.modelCallId === undefined && started.toolUseId === undefined && started.toolIndex === undefined && !proof.output.protocol ? proof : null;
|
|
468
563
|
}
|
|
469
564
|
startCapability(episode, data) {
|
|
565
|
+
this.guardExecutionInput(episode);
|
|
470
566
|
identifier(data.callId);
|
|
471
567
|
if (data.modelCallId !== undefined) identifier(data.modelCallId);
|
|
472
568
|
if (!data.capability || typeof data.capability.id !== 'string' || !data.capability.args || typeof data.capability.args !== 'object') fail('INVALID_REQUEST');
|
|
@@ -540,6 +636,7 @@ export class SqlitePersonStore {
|
|
|
540
636
|
return this.rows('created_capabilities', this.episodeScope(episode), ' ORDER BY id ASC LIMIT 32').map(publicDoc);
|
|
541
637
|
}
|
|
542
638
|
saveCreatedCapability(episode, input) {
|
|
639
|
+
this.guardExecutionInput(episode);
|
|
543
640
|
const { definition, evidence, callId } = validateCreatedCapability(input);
|
|
544
641
|
this.own(episode);
|
|
545
642
|
const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
@@ -617,6 +714,7 @@ export class SqlitePersonStore {
|
|
|
617
714
|
* publishProgress's no-open-capability path. JSON carries the canonical payload;
|
|
618
715
|
* text remains the searchable/model/legacy fallback. phase is delivery intent. */
|
|
619
716
|
savePresentation(episode, { callId, args }) {
|
|
717
|
+
this.guardExecutionInput(episode);
|
|
620
718
|
identifier(callId);
|
|
621
719
|
const presentation = personPresentation(args);
|
|
622
720
|
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
@@ -662,10 +760,41 @@ export class SqlitePersonStore {
|
|
|
662
760
|
" AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episodeId])
|
|
663
761
|
.some(message => message.text.trim() === reply.trim());
|
|
664
762
|
}
|
|
763
|
+
/** A targeted user-facing delivery, not a state commit or an answered flag.
|
|
764
|
+
* Idempotency is scoped to this episode/call and the exact target/text payload. */
|
|
765
|
+
publishReply(episode, { callId, messageIds, text: reply }) {
|
|
766
|
+
this.guardExecutionInput(episode);
|
|
767
|
+
identifier(callId); text(reply, LIMITS.outputBytes);
|
|
768
|
+
if (!Array.isArray(messageIds) || !messageIds.length || messageIds.length > 24 ||
|
|
769
|
+
new Set(messageIds).size !== messageIds.length) fail('INVALID_REQUEST');
|
|
770
|
+
for (const id of messageIds) identifier(id);
|
|
771
|
+
const { p, scope, record } = this.inputEpisode(episode);
|
|
772
|
+
const consumed = new Set(this.consumedMessageIds(scope, record, messageIds));
|
|
773
|
+
if (messageIds.some(id => !consumed.has(id))) fail('INVALID_REQUEST');
|
|
774
|
+
const invocationHash = digest({ messageIds, text: reply });
|
|
775
|
+
const prior = this.one('messages', scope,
|
|
776
|
+
" AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.replyKind') = 'message-reply'", [episode.id, callId]);
|
|
777
|
+
if (prior) {
|
|
778
|
+
if (prior.publicationHash !== invocationHash) fail('INVALID_REQUEST');
|
|
779
|
+
return messageView(prior);
|
|
780
|
+
}
|
|
781
|
+
if (record.openCall || !record.openCapability || record.openCapability.callId !== callId || record.openCapability.capability.id !== 'Conversation.reply' ||
|
|
782
|
+
!isDeepStrictEqual(record.openCapability.capability.args, { messageIds, text: reply }) ||
|
|
783
|
+
!this.capabilityProof(scope, episode.id, record.openCapability)) fail('STALE');
|
|
784
|
+
p.messageSeq++; p.writeSerial++; this.put('persons', p);
|
|
785
|
+
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
|
|
786
|
+
callId, publicationHash: invocationHash, replyKind: 'message-reply', replyToMessageIds: [...messageIds], role: 'assistant', text: reply, createdAt: this.now });
|
|
787
|
+
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
788
|
+
record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
|
|
789
|
+
this.trace(episode.ownerId, episode.id, 'message_reply', { callId, messageId: message.id, replyToMessageIds: message.replyToMessageIds });
|
|
790
|
+
return messageView(message);
|
|
791
|
+
}
|
|
665
792
|
/** Publish only a validated, complete call's reply. This is communication, not
|
|
666
793
|
* a state/Concept commit. Fencing, dedupe and the memory journal share one transaction. */
|
|
667
794
|
publishProgress(episode, { callId, reply, final = false }) {
|
|
668
795
|
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
796
|
+
const input = this.inputEpisode(episode);
|
|
797
|
+
if (!this.inputCurrent(episode, input.p, input.record)) return null;
|
|
669
798
|
identifier(callId); text(reply, LIMITS.outputBytes);
|
|
670
799
|
if (typeof final !== 'boolean') fail('INVALID_REQUEST');
|
|
671
800
|
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
@@ -695,7 +824,7 @@ export class SqlitePersonStore {
|
|
|
695
824
|
sameReply || this.repeatsAutonomousReply(scope, episode, reply) || this.repeatsPresentation(scope, episode.id, reply)) return null;
|
|
696
825
|
p.messageSeq++; p.writeSerial++; this.put('persons', p);
|
|
697
826
|
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
|
|
698
|
-
callId, replyKind: final ? 'execution-final' : 'progress', role: 'assistant', text: reply, createdAt: this.now });
|
|
827
|
+
callId, replyKind: final ? 'execution-final' : 'progress', ...(final ? { replyToMessageIds: this.consumedMessageIds(scope, record).slice(-24) } : {}), role: 'assistant', text: reply, createdAt: this.now });
|
|
699
828
|
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
700
829
|
record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
|
|
701
830
|
this.trace(episode.ownerId, episode.id, 'progress_reply', { callId, messageId: message.id });
|
|
@@ -704,7 +833,9 @@ export class SqlitePersonStore {
|
|
|
704
833
|
commit(episode, proposal, selection, callId, reportedSources = new Map()) {
|
|
705
834
|
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
706
835
|
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
707
|
-
if (!record || record.status !== 'running'
|
|
836
|
+
if (!record || record.status !== 'running') fail('STALE');
|
|
837
|
+
if (!this.inputCurrent(episode, p, record)) return { inputPending: true };
|
|
838
|
+
if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
|
|
708
839
|
const repeatsExecutionReply = proposal.reply && this.rows('messages', scope,
|
|
709
840
|
" AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.replyKind') IN ('progress', 'execution-final')", [episode.id])
|
|
710
841
|
.some(message => message.text.trim() === proposal.reply.trim());
|
|
@@ -744,7 +875,7 @@ export class SqlitePersonStore {
|
|
|
744
875
|
this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
|
|
745
876
|
episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
|
|
746
877
|
if (publishReply) {
|
|
747
|
-
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, callId, replyKind: 'final', role: 'assistant', text: proposal.reply, createdAt: this.now });
|
|
878
|
+
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id, callId, replyKind: 'final', replyToMessageIds: this.consumedMessageIds(scope, record).slice(-24), role: 'assistant', text: proposal.reply, createdAt: this.now });
|
|
748
879
|
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
749
880
|
}
|
|
750
881
|
if (p.settings.autonomyEnabled === true && p.autonomy?.generation === record.autonomyGeneration) {
|
|
@@ -772,6 +903,7 @@ export class SqlitePersonStore {
|
|
|
772
903
|
delete record.executionControl;
|
|
773
904
|
Object.assign(record, { status, terminalCode: code, endedAt: this.now }); this.put('episodes', record); this.abandonCall(episode.ownerId, record, code);
|
|
774
905
|
}
|
|
906
|
+
this.stopInputs(this.episodeScope(episode), episode.id);
|
|
775
907
|
this.trace(episode.ownerId, episode.id, status, { code, baseStateVersion: episode.baseStateVersion });
|
|
776
908
|
return true;
|
|
777
909
|
}
|
|
@@ -795,6 +927,7 @@ export class SqlitePersonStore {
|
|
|
795
927
|
delete record.executionControl;
|
|
796
928
|
Object.assign(record, { status: 'cancelled', terminalCode: 'CANCELLED', endedAt: this.now, callFinalizeUntil: until }); this.put('episodes', record);
|
|
797
929
|
}
|
|
930
|
+
this.stopInputs(scope, id);
|
|
798
931
|
this.trace(ownerId, id, 'cancelled', { code: 'CANCELLED' });
|
|
799
932
|
return { cancelled: true, episodeId: id };
|
|
800
933
|
}
|
|
@@ -914,6 +1047,7 @@ export class SqlitePersonStore {
|
|
|
914
1047
|
return { items: docs.slice(0, limit).map(conceptView), nextCursor: docs.length > limit ? docs[limit - 1].id : null };
|
|
915
1048
|
}
|
|
916
1049
|
checkOutputPublication(episode, { callId, args }) {
|
|
1050
|
+
this.guardExecutionInput(episode);
|
|
917
1051
|
outputArgs(args); this.own(episode);
|
|
918
1052
|
const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
919
1053
|
if (!active || active.status !== 'running' || active.openCall || active.openCapability?.callId !== callId ||
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fail, identifier, object, page, PersonError, text } from './contracts.js';
|
|
1
|
+
import { fail, identifier, LIMITS, object, page, PersonError, text } from './contracts.js';
|
|
2
2
|
import { validateCreatedDefinition } from './created-capability-contract.js';
|
|
3
3
|
import { executionReadRequest } from './execution-evidence.js';
|
|
4
4
|
import { outputArgs } from './outputs.js';
|
|
@@ -51,7 +51,12 @@ export function validateToolArguments(value, schema) {
|
|
|
51
51
|
* callers must not wrap capability execution in the same catch. */
|
|
52
52
|
export function validateCapabilityArguments({ id, args }) {
|
|
53
53
|
try {
|
|
54
|
-
if (id === '
|
|
54
|
+
if (id === 'Conversation.reply') {
|
|
55
|
+
object(args, ['messageIds', 'text']);
|
|
56
|
+
if (!Array.isArray(args.messageIds) || args.messageIds.length < 1 || args.messageIds.length > 24 || new Set(args.messageIds).size !== args.messageIds.length) fail('INVALID_REQUEST');
|
|
57
|
+
args.messageIds.forEach(id => identifier(id));
|
|
58
|
+
text(args.text, LIMITS.outputBytes);
|
|
59
|
+
} else if (id === 'Execution.goal') validateExecutionGoal(args);
|
|
55
60
|
else if (id === 'Capability.create') validateCreatedDefinition(args);
|
|
56
61
|
else if (id === 'Execution.read') executionReadRequest(args);
|
|
57
62
|
else if (id === 'Output.publish') outputArgs(args);
|