@yeaft/webchat-agent 1.0.622 → 1.0.624
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/server/handlers/client-person.js +10 -0
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +286 -129
- 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/workbench/file-ops.js +34 -8
- package/yeaft/cli-session-runner.js +1 -0
- package/yeaft/cli.js +4 -0
- package/yeaft/conversation/persist.js +39 -18
- package/yeaft/engine.js +82 -5
- package/yeaft/person/capabilities.js +13 -4
- package/yeaft/person/contracts.js +2 -2
- package/yeaft/person/inspection.js +5 -0
- package/yeaft/person/presentation.js +32 -0
- package/yeaft/person/provider.js +1 -0
- package/yeaft/person/runtime.js +174 -53
- package/yeaft/person/service.js +22 -6
- package/yeaft/person/sqlite-repository.js +2 -0
- package/yeaft/person/sqlite-store.js +84 -17
- package/yeaft/person/task-host.js +20 -5
- package/yeaft/person/turn-diagnostics.js +7 -3
- package/yeaft/response-presentation.js +81 -0
- package/yeaft/stdio-protocol.js +6 -0
- package/yeaft/tools/activation.js +1 -0
- package/yeaft/tools/index.js +2 -0
- package/yeaft/tools/present-response.js +27 -0
- package/yeaft/web-bridge.js +71 -3
|
@@ -10,6 +10,7 @@ import { attachmentMetadata, attachmentRequestHash, validateFiles } from './atta
|
|
|
10
10
|
import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
|
|
11
11
|
import { chronologicalCursor, conceptView, inspectionCursor, inspectionPage, inspectRequest, messageView, personName, searchRequest, settingsView, stateView } from './inspection.js';
|
|
12
12
|
import { TOKEN_FIELDS, turnsPage, turnView } from './turn-diagnostics.js';
|
|
13
|
+
import { personPresentation } from './presentation.js';
|
|
13
14
|
import { inspectCapabilities } from './capabilities.js';
|
|
14
15
|
import { OUTPUT_LIMITS, outputArgs, outputMime, outputReadRequest, outputsPage, outputUrl, outputView } from './outputs.js';
|
|
15
16
|
|
|
@@ -65,7 +66,7 @@ const objectOutput = (record, data) => {
|
|
|
65
66
|
}
|
|
66
67
|
};
|
|
67
68
|
const READS = new Set(['outputs', 'outputRead', 'checkOutputPublication', 'receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
|
|
68
|
-
const WRITES = new Set(['saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
|
|
69
|
+
const WRITES = new Set(['executionControl', 'savePresentation', 'saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
|
|
69
70
|
const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
|
|
70
71
|
const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
|
|
71
72
|
const sequence = value => {
|
|
@@ -199,6 +200,7 @@ export class SqlitePersonStore {
|
|
|
199
200
|
this.put('persons', p);
|
|
200
201
|
const episode = this.one('episodes', scope, ' AND id = ?', [id]);
|
|
201
202
|
if (episode?.status === 'running') {
|
|
203
|
+
delete episode.executionControl;
|
|
202
204
|
Object.assign(episode, { status: 'interrupted', terminalCode: 'INTERRUPTED', endedAt: this.now });
|
|
203
205
|
this.put('episodes', episode); this.abandonCall(ownerId, episode, 'INTERRUPTED');
|
|
204
206
|
}
|
|
@@ -267,6 +269,14 @@ export class SqlitePersonStore {
|
|
|
267
269
|
(!withLease || p.leaseUntil > this.now) ? p : null;
|
|
268
270
|
}
|
|
269
271
|
own(episode) { const p = this.fenced(episode); if (!p) fail('STALE'); return p; }
|
|
272
|
+
executionControl(episode, control) {
|
|
273
|
+
this.own(episode);
|
|
274
|
+
const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
|
|
275
|
+
if (!record || record.status !== 'running' || !['running', 'pausing', 'paused'].includes(control.status)) fail('STALE');
|
|
276
|
+
record.executionControl = { status: control.status, updatedAt: this.now.toISOString() };
|
|
277
|
+
this.put('episodes', record);
|
|
278
|
+
return { episodeId: episode.id, ...record.executionControl };
|
|
279
|
+
}
|
|
270
280
|
heartbeat(episode) {
|
|
271
281
|
const p = this.own(episode); p.leaseUntil = new Date(this.now.getTime() + this.leaseMs); p.writeSerial++; this.put('persons', p);
|
|
272
282
|
const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
|
|
@@ -275,7 +285,7 @@ export class SqlitePersonStore {
|
|
|
275
285
|
// Heartbeats are at most 5s apart: publish the fallback between 55–60s.
|
|
276
286
|
// This updates one snapshot field, never adds messages or schedules cognition.
|
|
277
287
|
if (this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 || record.feedback) {
|
|
278
|
-
const phase = record.openCall ? 'model' : record.openCapability ? 'capability' : 'preparing';
|
|
288
|
+
const phase = record.executionControl?.status === 'paused' ? 'paused' : record.openCall ? 'model' : record.openCapability ? 'capability' : 'preparing';
|
|
279
289
|
record.feedback = { at: this.now.getTime() - lastAt >= FEEDBACK.maxMs - 5000 ? this.now.toISOString() : record.feedback.at, phase };
|
|
280
290
|
if (phase === 'capability') record.feedback.capabilityId = record.openCapability.capability?.id;
|
|
281
291
|
this.put('episodes', record);
|
|
@@ -283,7 +293,7 @@ export class SqlitePersonStore {
|
|
|
283
293
|
}
|
|
284
294
|
append(episode, kind, data) {
|
|
285
295
|
// Call proof/publication events are emitted only by their transactional methods.
|
|
286
|
-
if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published'].includes(kind)) fail('INVALID_REQUEST');
|
|
296
|
+
if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published', 'presentation_published'].includes(kind)) fail('INVALID_REQUEST');
|
|
287
297
|
const p = this.own(episode);
|
|
288
298
|
const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
|
|
289
299
|
if (!record || record.status !== 'running') fail('STALE');
|
|
@@ -301,7 +311,7 @@ export class SqlitePersonStore {
|
|
|
301
311
|
this.put('episodes', record);
|
|
302
312
|
return this.trace(episode.ownerId, episode.id, 'call_started', data);
|
|
303
313
|
}
|
|
304
|
-
finalizeCall(episode, { callId, effective, output, code = null }) {
|
|
314
|
+
finalizeCall(episode, { callId, effective, output, requestElapsedMs, code = null }) {
|
|
305
315
|
const scope = this.episodeScope(episode), record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
306
316
|
if (!record || record.workerId !== episode.workerId || record.epoch !== episode.epoch || record.openCall?.callId !== callId) return false;
|
|
307
317
|
const live = record.status === 'running' && Boolean(this.fenced(episode));
|
|
@@ -315,6 +325,8 @@ export class SqlitePersonStore {
|
|
|
315
325
|
delete record.openCall; delete record.callFinalizeUntil; this.put('episodes', record);
|
|
316
326
|
this.trace(episode.ownerId, episode.id, failed ? 'call_failed' : 'call_output', {
|
|
317
327
|
callId, requested: call.requested, effective: safeEffective, output: publicOutput(output, failed),
|
|
328
|
+
// Actual provider time excludes any pause between journal start and dispatch.
|
|
329
|
+
...(Number.isSafeInteger(requestElapsedMs) && requestElapsedMs >= 0 ? { requestElapsedMs } : {}),
|
|
318
330
|
...(!failed && call.manifest ? { manifest: call.manifest } : {}), ...(failed ? { code: new PersonError(terminalCode).code } : {}),
|
|
319
331
|
});
|
|
320
332
|
return live;
|
|
@@ -389,6 +401,48 @@ export class SqlitePersonStore {
|
|
|
389
401
|
state: stateView(state), messages: this.rows('messages', scope, ' ORDER BY seq DESC LIMIT 12').reverse().map(messageView),
|
|
390
402
|
concepts: [...new Map([...focused, ...recent].map(c => [c.id, conceptView(c)])).values()] };
|
|
391
403
|
}
|
|
404
|
+
/** Atomic capability publication: exact started invocation + live lease, never
|
|
405
|
+
* publishProgress's no-open-capability path. JSON carries the canonical payload;
|
|
406
|
+
* text remains the searchable/model/legacy fallback. phase is delivery intent. */
|
|
407
|
+
savePresentation(episode, { callId, args }) {
|
|
408
|
+
identifier(callId);
|
|
409
|
+
const presentation = personPresentation(args);
|
|
410
|
+
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
411
|
+
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
412
|
+
const invocationHash = digest({ id: 'PresentResponse', args });
|
|
413
|
+
const prior = this.rows('messages', scope, " AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episode.id]);
|
|
414
|
+
const receipt = this.one('traces', scope,
|
|
415
|
+
" AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'presentation_published' AND json_extract(record, '$.callId') = ?", [episode.id, callId]);
|
|
416
|
+
if (receipt) {
|
|
417
|
+
if (receipt.invocationHash !== invocationHash) fail('INVALID_REQUEST');
|
|
418
|
+
const message = prior.find(m => m.id === receipt.messageId);
|
|
419
|
+
if (!message) fail('STORAGE_UNAVAILABLE');
|
|
420
|
+
return messageView(message);
|
|
421
|
+
}
|
|
422
|
+
const proof = this.one('traces', scope,
|
|
423
|
+
" AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') = 'call_output' AND json_extract(record, '$.callId') = ? AND json_extract(record, '$.output.complete') = 1", [episode.id, callId]);
|
|
424
|
+
if (!record || record.status !== 'running' || record.openCall || !proof || record.openCapability?.callId !== callId ||
|
|
425
|
+
record.openCapability.invocationHash !== invocationHash) fail('STALE');
|
|
426
|
+
// Repeating an identical delivery in this episode adds no new visible answer.
|
|
427
|
+
const repeated = prior.find(m => digest(m.presentation) === digest(presentation));
|
|
428
|
+
if (repeated) {
|
|
429
|
+
this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: repeated.id, phase: presentation.phase, duplicate: true });
|
|
430
|
+
return messageView(repeated);
|
|
431
|
+
}
|
|
432
|
+
p.messageSeq++; p.writeSerial++; this.put('persons', p);
|
|
433
|
+
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
|
|
434
|
+
callId, publicationHash: invocationHash, replyKind: 'presentation', role: 'assistant',
|
|
435
|
+
text: presentation.summary, presentation, createdAt: this.now });
|
|
436
|
+
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
437
|
+
record.lastReplyAt = this.now.toISOString(); delete record.feedback; this.put('episodes', record);
|
|
438
|
+
this.trace(episode.ownerId, episode.id, 'presentation_published', { callId, invocationHash, messageId: message.id, phase: presentation.phase });
|
|
439
|
+
return messageView(message);
|
|
440
|
+
}
|
|
441
|
+
repeatsPresentation(scope, episodeId, reply) {
|
|
442
|
+
return typeof reply === 'string' && this.rows('messages', scope,
|
|
443
|
+
" AND json_extract(record, '$.episodeId') = ? AND json_type(record, '$.presentation') = 'object'", [episodeId])
|
|
444
|
+
.some(message => message.text.trim() === reply.trim());
|
|
445
|
+
}
|
|
392
446
|
/** Publish only a validated, complete call's reply. This is communication, not
|
|
393
447
|
* a state/Concept commit. Fencing, dedupe and the memory journal share one transaction. */
|
|
394
448
|
publishProgress(episode, { callId, reply }) {
|
|
@@ -406,7 +460,7 @@ export class SqlitePersonStore {
|
|
|
406
460
|
if (!accepted || !proposal.next || proposal.reply !== reply) fail('INVALID_REQUEST');
|
|
407
461
|
// Provider latency counts too: a call started before 30s can finish after it.
|
|
408
462
|
if (this.now.getTime() - new Date(record.lastReplyAt ?? record.createdAt).getTime() < FEEDBACK.minMs ||
|
|
409
|
-
prior.some(m => m.text.trim() === reply.trim())) return null;
|
|
463
|
+
prior.some(m => m.text.trim() === reply.trim()) || this.repeatsPresentation(scope, episode.id, reply)) return null;
|
|
410
464
|
p.messageSeq++; p.writeSerial++; this.put('persons', p);
|
|
411
465
|
const message = this.doc(scope, { id: randomUUID(), revision: 1, seq: p.messageSeq, episodeId: episode.id,
|
|
412
466
|
callId, replyKind: 'progress', role: 'assistant', text: reply, createdAt: this.now });
|
|
@@ -418,7 +472,8 @@ export class SqlitePersonStore {
|
|
|
418
472
|
commit(episode, proposal, selection, callId, reportedSources = new Map()) {
|
|
419
473
|
const p = this.own(episode), scope = this.episodeScope(episode);
|
|
420
474
|
if (proposal.baseStateVersion !== episode.baseStateVersion) fail('STALE');
|
|
421
|
-
|
|
475
|
+
const publishReply = proposal.reply && !this.repeatsPresentation(scope, episode.id, proposal.reply);
|
|
476
|
+
p.stateVersion++; p.writeSerial++; if (publishReply) p.messageSeq++;
|
|
422
477
|
p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0);
|
|
423
478
|
this.put('persons', p);
|
|
424
479
|
const revisions = [];
|
|
@@ -441,12 +496,13 @@ export class SqlitePersonStore {
|
|
|
441
496
|
this.put('states', state);
|
|
442
497
|
this.put('state_commits', this.doc(scope, { id: randomUUID(), version: p.stateVersion, parentVersion: episode.baseStateVersion,
|
|
443
498
|
episodeId: episode.id, callId, conceptRevisions: revisions, state: stateView(state), createdAt: this.now }), true);
|
|
444
|
-
if (
|
|
499
|
+
if (publishReply) {
|
|
445
500
|
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 });
|
|
446
501
|
this.put('messages', message, true); this.journal(scope, 'messages', message);
|
|
447
502
|
}
|
|
448
503
|
const record = this.one('episodes', scope, ' AND id = ?', [episode.id]);
|
|
449
504
|
if (!record || record.status !== 'running') fail('STALE');
|
|
505
|
+
delete record.executionControl;
|
|
450
506
|
Object.assign(record, { status: 'completed', endedAt: this.now, stateVersion: p.stateVersion }); this.put('episodes', record);
|
|
451
507
|
this.trace(episode.ownerId, episode.id, 'committed', { callId, parentVersion: episode.baseStateVersion, stateVersion: p.stateVersion, conceptRevisions: revisions, decision: proposal.decision });
|
|
452
508
|
return { state: stateView(state) };
|
|
@@ -457,6 +513,7 @@ export class SqlitePersonStore {
|
|
|
457
513
|
p.writeSerial++; p.epoch++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0); this.put('persons', p);
|
|
458
514
|
const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
|
|
459
515
|
if (record?.status === 'running') {
|
|
516
|
+
delete record.executionControl;
|
|
460
517
|
Object.assign(record, { status, terminalCode: code, endedAt: this.now }); this.put('episodes', record); this.abandonCall(episode.ownerId, record, code);
|
|
461
518
|
}
|
|
462
519
|
this.trace(episode.ownerId, episode.id, status, { code, baseStateVersion: episode.baseStateVersion });
|
|
@@ -469,6 +526,7 @@ export class SqlitePersonStore {
|
|
|
469
526
|
p.epoch++; p.controlVersion++; p.writeSerial++; p.activeEpisodeId = null; p.leaseOwner = null; p.leaseUntil = new Date(0); this.put('persons', p);
|
|
470
527
|
const record = this.one('episodes', scope, ' AND id = ?', [id]);
|
|
471
528
|
if (record?.status === 'running') {
|
|
529
|
+
delete record.executionControl;
|
|
472
530
|
Object.assign(record, { status: 'cancelled', terminalCode: 'CANCELLED', endedAt: this.now, callFinalizeUntil: until }); this.put('episodes', record);
|
|
473
531
|
}
|
|
474
532
|
this.trace(ownerId, id, 'cancelled', { code: 'CANCELLED' });
|
|
@@ -511,8 +569,10 @@ export class SqlitePersonStore {
|
|
|
511
569
|
const { cursor, limit } = turnsPage(options);
|
|
512
570
|
const scope = this.scope(ownerId);
|
|
513
571
|
if (!this.sql(`SELECT 1 FROM persons WHERE ${SCOPE}`).get(...scopeValues(scope))) fail('NOT_OPEN');
|
|
514
|
-
const project = fields => `json_object(${Object.entries(fields).map(([key, path]) => `'${key}',
|
|
515
|
-
|
|
572
|
+
const project = fields => `json_object(${Object.entries(fields).map(([key, path]) => `'${key}', CASE json_type(record, '$.${path}')
|
|
573
|
+
WHEN 'text' THEN substr(json_extract(record, '$.${path}'), 1, 1000)
|
|
574
|
+
WHEN 'integer' THEN json_extract(record, '$.${path}') WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE NULL END`).join(', ')})`;
|
|
575
|
+
const episodes = this.sql(`SELECT ${project({ id: 'id', seq: 'inputWatermark', kind: 'kind', status: 'status', createdAt: 'createdAt', endedAt: 'endedAt', terminalCode: 'terminalCode', controlStatus: 'executionControl.status', calls: 'budget.calls', timeoutMs: 'budget.timeoutMs' })} AS metadata
|
|
516
576
|
FROM episodes WHERE ${SCOPE} AND (? IS NULL OR inputWatermark < ?) ORDER BY inputWatermark DESC LIMIT ?`)
|
|
517
577
|
.all(...scopeValues(scope), cursor, cursor, limit + 1).map(row => JSON.parse(row.metadata));
|
|
518
578
|
const fields = { kind: 'kind', callId: 'callId', callIndex: 'callIndex', createdAt: 'createdAt',
|
|
@@ -529,20 +589,27 @@ export class SqlitePersonStore {
|
|
|
529
589
|
omittedConcepts: ['manifest.contextSources.omittedConcepts', 24], recallCount: ['manifest.contextSources.recall.count', 5] })
|
|
530
590
|
.map(([key, args]) => `'${key}', ${number(...args)}`).join(', ');
|
|
531
591
|
const recallKind = `CASE json_extract(record, '$.manifest.contextSources.recall.kind') WHEN 'messages' THEN 'messages' WHEN 'concepts' THEN 'concepts' ELSE NULL END`;
|
|
532
|
-
const usage = `json_object(${TOKEN_FIELDS.map(key => `'${key}',
|
|
533
|
-
'accountingVersion',
|
|
592
|
+
const usage = `json_object(${TOKEN_FIELDS.map(key => `'${key}', ${number(`output.usage.${key}`)}`).join(', ')},
|
|
593
|
+
'accountingVersion', ${number('output.usage.accountingVersion', 1)},
|
|
534
594
|
'accountingIncomplete', json(CASE json_extract(record, '$.output.usage.accountingIncomplete') WHEN 1 THEN 'true' ELSE 'false' END),
|
|
535
595
|
'cacheTokensAreIncludedInInput', json(CASE json_extract(record, '$.output.usage.cacheTokensAreIncludedInInput') WHEN 1 THEN 'true' WHEN 0 THEN 'false' ELSE 'null' END))`;
|
|
536
|
-
const metadata = `${project(fields).slice(0, -1)}, 'capabilityId', COALESCE(json_extract(record, '$.capability.id'), json_extract(record, '$.capabilityId')),
|
|
537
|
-
${contextMetadata}, 'recallKind', ${recallKind}, 'usage', ${usage})`;
|
|
596
|
+
const metadata = `${project(fields).slice(0, -1)}, 'capabilityId', substr(COALESCE(CASE WHEN json_type(record, '$.capability.id') = 'text' THEN json_extract(record, '$.capability.id') END, CASE WHEN json_type(record, '$.capabilityId') = 'text' THEN json_extract(record, '$.capabilityId') END), 1, 128),
|
|
597
|
+
${contextMetadata}, 'requestElapsedMs', ${number('requestElapsedMs')}, 'recallKind', ${recallKind}, 'usage', ${usage})`;
|
|
538
598
|
const items = episodes.slice(0, limit).map(episode => {
|
|
539
|
-
const
|
|
599
|
+
const starts = this.sql(`SELECT seq, ${number('callIndex')} AS callIndex FROM traces WHERE ${SCOPE} AND json_extract(record, '$.episodeId') = ?
|
|
600
|
+
AND json_extract(record, '$.kind') = 'call_started' ORDER BY seq DESC LIMIT 33`)
|
|
601
|
+
.all(...scopeValues(scope), episode.id);
|
|
602
|
+
const oldest = starts[Math.min(starts.length, 32) - 1]?.seq ?? 0;
|
|
603
|
+
const events = this.sql(`SELECT ${metadata} AS metadata FROM traces WHERE ${SCOPE} AND seq >= ?
|
|
540
604
|
AND json_extract(record, '$.episodeId') = ? AND json_extract(record, '$.kind') IN
|
|
541
|
-
('call_started', 'call_output', 'call_failed', 'proposal_rejected', 'capability_started', 'capability_result', 'capability_failed', 'capability_finalized') ORDER BY seq
|
|
542
|
-
.all(...scopeValues(scope), episode.id).map(row => {
|
|
605
|
+
('call_started', 'call_output', 'call_failed', 'proposal_rejected', 'capability_started', 'capability_result', 'capability_failed', 'capability_finalized') ORDER BY seq DESC LIMIT 257`)
|
|
606
|
+
.all(...scopeValues(scope), oldest, episode.id).map(row => {
|
|
543
607
|
const event = JSON.parse(row.metadata); event.effortObserved = event.effortObserved === 1; return event;
|
|
544
608
|
});
|
|
545
|
-
return turnView(episode, events)
|
|
609
|
+
return { ...turnView(episode, events.slice(0, 256).reverse()),
|
|
610
|
+
loopCount: starts[0]?.callIndex != null ? starts[0].callIndex + 1 : starts.length > 32 ? null : starts.length,
|
|
611
|
+
callsTruncated: starts.length > 32 || events.length > 256, callWindowLimit: 32,
|
|
612
|
+
usageScope: starts.length > 32 || events.length > 256 ? 'latest-call-window' : 'episode' };
|
|
546
613
|
});
|
|
547
614
|
return { items, nextCursor: episodes.length > limit ? items.at(-1).seq : null };
|
|
548
615
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, realpathSync, readFileSync, writeFileSync, renameSync, readdirSync, existsSync, lstatSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, realpathSync, readFileSync, writeFileSync, renameSync, readdirSync, opendirSync, existsSync, lstatSync } from 'node:fs';
|
|
2
2
|
import { resolve, join, relative, isAbsolute, sep } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { TaskManager } from '../tasks/manager.js';
|
|
@@ -96,8 +96,23 @@ function atomicJSON(path, value) {
|
|
|
96
96
|
renameSync(temporary, path);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
function jsonFiles(directory) {
|
|
99
|
+
function jsonFiles(directory, { limit } = {}) {
|
|
100
100
|
if (!existsSync(directory)) return [];
|
|
101
|
+
if (limit != null) {
|
|
102
|
+
// Main cognition reads a bounded latest window, not every durable result at once.
|
|
103
|
+
// Iterate directory entries without assembling an unbounded filename array.
|
|
104
|
+
const handle = opendirSync(directory), records = [];
|
|
105
|
+
const time = record => new Date(record.updatedAt ?? record.task?.updatedAt ?? record.createdAt ?? 0).getTime() || 0;
|
|
106
|
+
try {
|
|
107
|
+
for (let entry; (entry = handle.readSync());) {
|
|
108
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
109
|
+
try { records.push(JSON.parse(readFileSync(join(directory, entry.name), 'utf8'))); } catch { continue; }
|
|
110
|
+
records.sort((a, b) => time(b) - time(a));
|
|
111
|
+
if (records.length > limit) records.pop();
|
|
112
|
+
}
|
|
113
|
+
} finally { handle.closeSync(); }
|
|
114
|
+
return records;
|
|
115
|
+
}
|
|
101
116
|
return readdirSync(directory).filter(name => name.endsWith('.json')).flatMap(name => {
|
|
102
117
|
try { return [JSON.parse(readFileSync(join(directory, name), 'utf8'))]; } catch { return []; }
|
|
103
118
|
});
|
|
@@ -562,9 +577,9 @@ class ScopedPersonTaskHost {
|
|
|
562
577
|
this.#persistAgents();
|
|
563
578
|
const recent = items => items.sort((a, b) => String(b.updatedAt ?? b.task?.updatedAt ?? '').localeCompare(String(a.updatedAt ?? a.task?.updatedAt ?? ''))).slice(0, limit);
|
|
564
579
|
return { namespace: this.scope.key, sessionId: this.sessionId,
|
|
565
|
-
tasks: recent(jsonFiles(this.#manager.store.sessionDir(this.sessionId))),
|
|
566
|
-
agents: clone([...this.#agents.values()].slice(-limit)), completions: recent(jsonFiles(join(this.dataRoot, 'completions'))),
|
|
567
|
-
toolResults: recent(jsonFiles(join(this.dataRoot, 'tool-results'))).map(result => ({ ...result,
|
|
580
|
+
tasks: recent(jsonFiles(this.#manager.store.sessionDir(this.sessionId), { limit })),
|
|
581
|
+
agents: clone([...this.#agents.values()].slice(-limit)), completions: recent(jsonFiles(join(this.dataRoot, 'completions'), { limit })),
|
|
582
|
+
toolResults: recent(jsonFiles(join(this.dataRoot, 'tool-results'), { limit })).map(result => ({ ...result,
|
|
568
583
|
output: result.output.slice(0, 8192), rawPath: join(this.dataRoot, 'tool-results', `${result.id}.json`) })) };
|
|
569
584
|
}
|
|
570
585
|
|
|
@@ -51,7 +51,7 @@ export function callUsage(raw, completed) {
|
|
|
51
51
|
return { ...usage, reportedCalls: Number(reported), missingCalls: Number(!reported), complete: completed && usage.totalTokens !== null };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function sumUsage(calls, terminal) {
|
|
54
|
+
export function sumUsage(calls, terminal) {
|
|
55
55
|
const fields = [...TOKEN_FIELDS, 'inputTotalTokens', 'totalTokens'];
|
|
56
56
|
const usage = Object.fromEntries(fields.map(key => {
|
|
57
57
|
const known = calls.map(call => call.usage[key]).filter(value => value !== null);
|
|
@@ -84,7 +84,11 @@ export function turnView(episode, events) {
|
|
|
84
84
|
if (!call) continue;
|
|
85
85
|
if (event.kind === 'call_output' || event.kind === 'call_failed') {
|
|
86
86
|
call.status = event.kind === 'call_output' ? 'completed' : ['CANCELLED', 'INTERRUPTED'].includes(event.code) ? event.code.toLowerCase() : 'failed';
|
|
87
|
-
call.endedAt = event.createdAt;
|
|
87
|
+
call.endedAt = event.createdAt;
|
|
88
|
+
// Legacy call lifecycle timestamps include queue/pause time. Do not label
|
|
89
|
+
// them as provider duration when no actual dispatch measurement was stored.
|
|
90
|
+
call.elapsedMs = token(event.requestElapsedMs);
|
|
91
|
+
call.code = string(event.code);
|
|
88
92
|
call.effective.effort = event.effortObserved === true ? string(event.effectiveEffort) : null;
|
|
89
93
|
call.usage = callUsage(event.usage, event.kind === 'call_output');
|
|
90
94
|
} else if (event.kind === 'proposal_rejected') {
|
|
@@ -97,7 +101,7 @@ export function turnView(episode, events) {
|
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
const items = [...calls.values()].sort((a, b) => a.index - b.index);
|
|
100
|
-
return { id: episode.id, seq: episode.seq, kind: episode.kind, status: episode.status, createdAt: episode.createdAt,
|
|
104
|
+
return { id: episode.id, seq: episode.seq, kind: episode.kind, status: episode.status, controlStatus: episode.status === 'running' ? string(episode.controlStatus) : null, createdAt: episode.createdAt,
|
|
101
105
|
endedAt: episode.endedAt, terminalCode: episode.terminalCode, budget: { calls: token(episode.calls), timeoutMs: token(episode.timeoutMs) },
|
|
102
106
|
models: [...new Set(items.map(call => call.dispatched.model).filter(Boolean))], usage: sumUsage(items, episode.status !== 'running'), calls: items };
|
|
103
107
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Shared, bounded user-visible response contract for native Sessions and Persons.
|
|
2
|
+
* Contains no executable markup, filesystem access or network requests. Runtime
|
|
3
|
+
* owns identity/phase; summary remains useful without the rich client.
|
|
4
|
+
*/
|
|
5
|
+
const str = (maxLength = 2000) => ({ type: 'string', maxLength });
|
|
6
|
+
const arr = (items, maxItems, minItems = 1) => ({ type: 'array', items, minItems, maxItems });
|
|
7
|
+
const obj = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
8
|
+
const block = (type, properties, required) => obj({ type: { type: 'string', enum: [type] }, title: str(160), ...properties }, ['type', ...required]);
|
|
9
|
+
export const PRESENTATION_SCHEMA = obj({
|
|
10
|
+
title: str(160),
|
|
11
|
+
summary: str(8000),
|
|
12
|
+
phase: { type: 'string', enum: ['progress', 'final'] },
|
|
13
|
+
blocks: arr({ anyOf: [
|
|
14
|
+
block('markdown', { text: str(12000) }, ['text']),
|
|
15
|
+
block('cards', { items: arr(obj({ title: str(160), text: str(2000), label: str(80) }, ['title', 'text']), 8) }, ['items']),
|
|
16
|
+
block('metrics', { items: arr(obj({ label: str(120), value: str(120), detail: str(240) }, ['label', 'value']), 8) }, ['items']),
|
|
17
|
+
block('table', { columns: arr(str(120), 8), rows: arr(arr(str(1000), 8, 1), 50) }, ['columns', 'rows']),
|
|
18
|
+
block('chart', { style: { type: 'string', enum: ['bar', 'line'] }, unit: str(60), series: arr(obj({ label: str(120), values: arr({ type: 'number' }, 40) }, ['label', 'values']), 4), labels: arr(str(120), 40) }, ['style', 'series', 'labels']),
|
|
19
|
+
block('timeline', { items: arr(obj({ label: str(120), title: str(160), text: str(2000) }, ['label', 'title', 'text']), 16) }, ['items']),
|
|
20
|
+
block('diagram', { nodes: arr(obj({ id: str(40), label: str(160) }, ['id', 'label']), 12), edges: arr(obj({ from: str(40), to: str(40), label: str(120) }, ['from', 'to']), 24, 0) }, ['nodes', 'edges']),
|
|
21
|
+
block('image', { url: str(2000), alt: str(240), caption: str(1000) }, ['url', 'alt']),
|
|
22
|
+
block('link', { url: str(2000), text: str(1000) }, ['url', 'text']),
|
|
23
|
+
block('map', { points: arr(obj({ label: str(160), latitude: { type: 'number', minimum: -90, maximum: 90 }, longitude: { type: 'number', minimum: -180, maximum: 180 }, detail: str(1000) }, ['label', 'latitude', 'longitude']), 20) }, ['points']),
|
|
24
|
+
] }, 12),
|
|
25
|
+
}, ['summary', 'blocks']);
|
|
26
|
+
|
|
27
|
+
export const PRESENT_RESPONSE_DESCRIPTION = `Publish a rich user-visible response inside this turn: cards, metrics, comparison tables, bar/line charts, timelines, relationship diagrams, images, artifact links or coordinate maps. Optional: use normal text for simple answers. Supply a self-contained summary for copying, search, model history and older clients; do not repeat the whole rich answer afterwards. Use only verified data, mark uncertainty, and never fabricate chart values or coordinates. No HTML/JavaScript, arbitrary SVG, styling or executable content. Image/link URLs must be credential-free HTTP(S); images require the reader to load external content. Maps are coordinate sketches, not navigation maps, and do not fetch tiles. phase=progress continues work; phase=final is a delivery intent, not proof of task success. Runtime still owns persistence, cancellation and turn termination. Validation failure publishes nothing; correct the payload or answer in text.`;
|
|
28
|
+
|
|
29
|
+
function check(value, schema, path) {
|
|
30
|
+
if (schema.anyOf) {
|
|
31
|
+
const candidate = schema.anyOf.find(s => value?.type === s.properties.type.enum[0]);
|
|
32
|
+
if (!candidate) throw new Error(`${path}: unsupported block type`);
|
|
33
|
+
return check(value, candidate, path);
|
|
34
|
+
}
|
|
35
|
+
if (schema.type === 'object') {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${path}: expected object`);
|
|
37
|
+
for (const key of Object.keys(value)) if (!Object.hasOwn(schema.properties, key)) throw new Error(`${path}.${key}: unknown field`);
|
|
38
|
+
for (const key of schema.required) if (!Object.hasOwn(value, key)) throw new Error(`${path}.${key}: required`);
|
|
39
|
+
for (const [key, item] of Object.entries(value)) check(item, schema.properties[key], `${path}.${key}`);
|
|
40
|
+
} else if (schema.type === 'array') {
|
|
41
|
+
if (!Array.isArray(value) || value.length < schema.minItems || value.length > schema.maxItems) throw new Error(`${path}: invalid item count`);
|
|
42
|
+
value.forEach((item, i) => check(item, schema.items, `${path}[${i}]`));
|
|
43
|
+
} else if (schema.type === 'string') {
|
|
44
|
+
if (typeof value !== 'string' || !value.trim() || value.length > schema.maxLength) throw new Error(`${path}: expected non-empty bounded text`);
|
|
45
|
+
if (schema.enum && !schema.enum.includes(value)) throw new Error(`${path}: unsupported value`);
|
|
46
|
+
} else if (schema.type === 'number') {
|
|
47
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || Math.abs(value) > 1e12 || (schema.minimum !== undefined && value < schema.minimum) || (schema.maximum !== undefined && value > schema.maximum)) throw new Error(`${path}: invalid finite number`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Return a canonical JSON-only snapshot; rejects rather than partially publishing. */
|
|
52
|
+
export function normalizePresentation(input) {
|
|
53
|
+
check(input, PRESENTATION_SCHEMA, 'response');
|
|
54
|
+
const encoded = JSON.stringify(input);
|
|
55
|
+
if (new TextEncoder().encode(encoded).length > 49152) throw new Error('response: exceeds 48 KiB');
|
|
56
|
+
const value = JSON.parse(encoded);
|
|
57
|
+
for (const b of value.blocks) {
|
|
58
|
+
if (b.type === 'table' && b.rows.some(row => row.length !== b.columns.length)) throw new Error('table: every row must match the columns');
|
|
59
|
+
if (b.type === 'chart' && b.series.some(series => series.values.length !== b.labels.length)) throw new Error('chart: values must match labels');
|
|
60
|
+
if (b.type === 'diagram') {
|
|
61
|
+
const ids = new Set(b.nodes.map(n => n.id));
|
|
62
|
+
if (ids.size !== b.nodes.length || b.edges.some(e => !ids.has(e.from) || !ids.has(e.to))) throw new Error('diagram: unique nodes and existing edge endpoints required');
|
|
63
|
+
}
|
|
64
|
+
if (b.type === 'image' || b.type === 'link') b.url = safePresentationUrl(b.url);
|
|
65
|
+
}
|
|
66
|
+
const canonical = { version: 1, title: value.title || '', summary: value.summary, phase: value.phase || 'final', blocks: value.blocks };
|
|
67
|
+
if (new TextEncoder().encode(JSON.stringify(canonical)).length > 49152) throw new Error('response: exceeds 48 KiB');
|
|
68
|
+
return canonical;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** External URLs are metadata only. Never fetch server-side or accept credentials. */
|
|
72
|
+
export function safePresentationUrl(value) {
|
|
73
|
+
if (typeof value !== 'string' || /[\u0000-\u0020\u007f]/.test(value)) throw new Error('response: invalid URL');
|
|
74
|
+
let url;
|
|
75
|
+
try { url = new URL(value); } catch { throw new Error('response: invalid absolute URL'); }
|
|
76
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new Error('response: unsafe URL');
|
|
77
|
+
// Canonical URLs are encoded, so input length alone is not a safe bound.
|
|
78
|
+
// Persisted snapshots must pass exactly the same limits on replay.
|
|
79
|
+
if (url.href.length > 2000) throw new Error('response: URL exceeds 2000 characters');
|
|
80
|
+
return url.href;
|
|
81
|
+
}
|
package/yeaft/stdio-protocol.js
CHANGED
|
@@ -319,6 +319,12 @@ export function createStreamProjector({
|
|
|
319
319
|
state.resultText += event.text || '';
|
|
320
320
|
write({ ...base, type: 'assistant', subtype: 'text_delta', delta: { type: 'text_delta', text: event.text || '' } });
|
|
321
321
|
break;
|
|
322
|
+
case 'response_presentation':
|
|
323
|
+
state.resultText += event.message?.content || '';
|
|
324
|
+
write({ ...base, type: 'assistant', subtype: 'response_presentation',
|
|
325
|
+
message: event.message, content: [{ type: 'text', text: event.message?.content || '' }],
|
|
326
|
+
presentation: event.message?.presentation });
|
|
327
|
+
break;
|
|
322
328
|
case 'thinking_delta':
|
|
323
329
|
write({ ...base, type: 'assistant', subtype: 'thinking_delta', delta: { type: 'thinking_delta', thinking: event.text || '' } });
|
|
324
330
|
break;
|
package/yeaft/tools/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import exitWorktree from './exit-worktree.js';
|
|
|
25
25
|
|
|
26
26
|
// --- P0 Core tools ---
|
|
27
27
|
import askUser from './ask-user.js';
|
|
28
|
+
import presentResponse from './present-response.js';
|
|
28
29
|
import webSearch from './web-search.js';
|
|
29
30
|
import webFetch from './web-fetch.js';
|
|
30
31
|
import historySearch from './history-search.js';
|
|
@@ -92,6 +93,7 @@ export const allTools = [
|
|
|
92
93
|
|
|
93
94
|
// P0 Core
|
|
94
95
|
askUser,
|
|
96
|
+
presentResponse,
|
|
95
97
|
webSearch,
|
|
96
98
|
webFetch,
|
|
97
99
|
historySearch,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineTool } from './types.js';
|
|
2
|
+
import { PRESENTATION_SCHEMA, normalizePresentation, PRESENT_RESPONSE_DESCRIPTION } from '../response-presentation.js';
|
|
3
|
+
|
|
4
|
+
export default defineTool({
|
|
5
|
+
name: 'PresentResponse',
|
|
6
|
+
description: PRESENT_RESPONSE_DESCRIPTION,
|
|
7
|
+
parameters: PRESENTATION_SCHEMA,
|
|
8
|
+
isReadOnly: () => false,
|
|
9
|
+
isConcurrencySafe: () => false,
|
|
10
|
+
cacheWithinQuery: false,
|
|
11
|
+
duplicateCallPolicy: () => 'allow',
|
|
12
|
+
errorOutput: 'json-error-envelope',
|
|
13
|
+
async execute(input, ctx) {
|
|
14
|
+
try {
|
|
15
|
+
// Validate the entire snapshot before invoking any host-side publication.
|
|
16
|
+
const presentation = normalizePresentation(input);
|
|
17
|
+
if (ctx?.signal?.aborted) throw new Error('Response publication was cancelled.');
|
|
18
|
+
if (typeof ctx?.publishResponse !== 'function') {
|
|
19
|
+
throw new Error('Response publication is unavailable in this runtime. Use normal text.');
|
|
20
|
+
}
|
|
21
|
+
const identity = await ctx.publishResponse(presentation);
|
|
22
|
+
return JSON.stringify({ accepted: true, ...identity, message: 'Response accepted for delivery. Continue work if needed; do not repeat the summary. The normal turn boundary still applies.' });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return JSON.stringify({ error: error.message });
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
});
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -1396,6 +1396,8 @@ function projectPersistedToHistoryEntry(m, { includeReflections = false } = {})
|
|
|
1396
1396
|
entry.threadId = m.threadId || m.turnId || 'main';
|
|
1397
1397
|
if (m.turnId) entry.turnId = m.turnId;
|
|
1398
1398
|
if (m.imageAssetAnchor) entry.imageAssetAnchor = true;
|
|
1399
|
+
if (m.presentation) entry.presentation = m.presentation;
|
|
1400
|
+
if (m.presentationCallId) entry.presentationCallId = m.presentationCallId;
|
|
1399
1401
|
if (m.responseKind === 'progress' || m.responseKind === 'result') entry.responseKind = m.responseKind;
|
|
1400
1402
|
if (Number.isInteger(m.llmCallCount) && m.llmCallCount > 0) entry.llmCallCount = m.llmCallCount;
|
|
1401
1403
|
for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'totalMs']) {
|
|
@@ -1541,6 +1543,8 @@ function projectVisibleHistoryChunkMessages(messages = []) {
|
|
|
1541
1543
|
threadId: m.threadId || m.turnId || 'main',
|
|
1542
1544
|
...(m.turnId ? { turnId: m.turnId } : {}),
|
|
1543
1545
|
...(m.imageAssetAnchor === true ? { imageAssetAnchor: true } : {}),
|
|
1546
|
+
...(m.presentation ? { presentation: m.presentation } : {}),
|
|
1547
|
+
...(m.presentationCallId ? { presentationCallId: m.presentationCallId } : {}),
|
|
1544
1548
|
...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments, m.sessionId) } : {}),
|
|
1545
1549
|
...(m.quote ? { quote: m.quote } : {}),
|
|
1546
1550
|
...(Array.isArray(m.images) && m.images.length > 0 ? { images: m.images } : {}),
|
|
@@ -1615,6 +1619,7 @@ function emitLegacyHistoryOutputFrames(replayEntries) {
|
|
|
1615
1619
|
type: 'assistant',
|
|
1616
1620
|
message: {
|
|
1617
1621
|
id: entry.id || null,
|
|
1622
|
+
...(entry.presentation ? { presentation: entry.presentation, presentationCallId: entry.presentationCallId, responseKind: entry.responseKind } : {}),
|
|
1618
1623
|
content: [
|
|
1619
1624
|
...(entry.content ? [{ type: 'text', text: entry.content }] : []),
|
|
1620
1625
|
...((entry.toolCalls || []).map(toolCall => ({ type: 'tool_use', ...toolCall, history: true }))),
|
|
@@ -4156,6 +4161,14 @@ function handleEngineEvent(event, hctx) {
|
|
|
4156
4161
|
}
|
|
4157
4162
|
if ((event.type === 'text_delta' || event.type === 'thinking_delta')
|
|
4158
4163
|
&& (typeof event.text !== 'string' || event.text.length === 0)) return;
|
|
4164
|
+
// Reject stale publications before touching timers, queued text or history.
|
|
4165
|
+
if (event.type === 'response_presentation') {
|
|
4166
|
+
const message = event.message;
|
|
4167
|
+
if (hctx.signal?.aborted || !message?.id || !message.presentation
|
|
4168
|
+
|| message.sessionId !== hctx.sessionId || message.turnId !== hctx.turnId
|
|
4169
|
+
|| message.speakerVpId !== hctx.vpId || message.threadId !== (hctx.threadId || 'main')
|
|
4170
|
+
|| hctx.presentations?.some(row => row.id === message.id)) return;
|
|
4171
|
+
}
|
|
4159
4172
|
const terminalTurnEnd = event.type === 'turn_end' && event.terminal === true;
|
|
4160
4173
|
const managesQueryTimer = terminalTurnEnd
|
|
4161
4174
|
|| event.type === 'aborted'
|
|
@@ -4190,6 +4203,27 @@ function handleEngineEvent(event, hctx) {
|
|
|
4190
4203
|
maybeTransitionVpStatus(hctx, 'streaming');
|
|
4191
4204
|
break;
|
|
4192
4205
|
|
|
4206
|
+
case 'response_presentation': {
|
|
4207
|
+
const message = event.message;
|
|
4208
|
+
if (!hctx.presentations) hctx.presentations = [];
|
|
4209
|
+
hctx.presentations.push({ ...message, _textPartsCount: (hctx.assistantTextParts || []).length });
|
|
4210
|
+
hctx.presentationTextPartsCount = (hctx.assistantTextParts || []).length;
|
|
4211
|
+
if (!hctx.skipPartialHistory) {
|
|
4212
|
+
const prompts = hctx.includeInitialPrompt ? [hctx.prompt, ...(hctx.appendedUserPrompts || [])] : (hctx.appendedUserPrompts || []);
|
|
4213
|
+
appendTurnToSessionHistory(hctx.sessionId, hctx.threadId || 'main', hctx.vpId,
|
|
4214
|
+
prompts, hctx.assistantTextParts || [], hctx.toolCallsAccum || [],
|
|
4215
|
+
hctx.toolResultsAccum || [], hctx.thinkingBlocksAccum || [],
|
|
4216
|
+
{ turnId: hctx.turnId, partial: true, presentations: hctx.presentations });
|
|
4217
|
+
}
|
|
4218
|
+
sendSessionOutputFrame({
|
|
4219
|
+
type: 'assistant',
|
|
4220
|
+
message: { id: message.id, content: message.content, presentation: message.presentation,
|
|
4221
|
+
presentationCallId: message.presentationCallId, responseKind: 'progress' },
|
|
4222
|
+
ts: message.time || null,
|
|
4223
|
+
}, envelope);
|
|
4224
|
+
break;
|
|
4225
|
+
}
|
|
4226
|
+
|
|
4193
4227
|
case 'thinking_delta':
|
|
4194
4228
|
sendSessionEvent({ type: 'thinking_delta', text: event.text }, envelope);
|
|
4195
4229
|
break;
|
|
@@ -4308,7 +4342,7 @@ function handleEngineEvent(event, hctx) {
|
|
|
4308
4342
|
hctx.toolCallsAccum || [],
|
|
4309
4343
|
hctx.toolResultsAccum || [],
|
|
4310
4344
|
hctx.thinkingBlocksAccum || [],
|
|
4311
|
-
{ turnId: hctx.turnId, partial: true },
|
|
4345
|
+
{ turnId: hctx.turnId, partial: true, presentations: hctx.presentations },
|
|
4312
4346
|
);
|
|
4313
4347
|
}
|
|
4314
4348
|
sendSessionOutputFrame({
|
|
@@ -4376,6 +4410,22 @@ function handleEngineEvent(event, hctx) {
|
|
|
4376
4410
|
// stop the user-query silence watchdog before that best-effort work.
|
|
4377
4411
|
if (event.terminal) {
|
|
4378
4412
|
(hctx.stopQueryTimer || hctx.pauseQueryTimer)?.();
|
|
4413
|
+
const publications = hctx.presentations || [];
|
|
4414
|
+
for (const row of publications) {
|
|
4415
|
+
row.responseKind = 'progress';
|
|
4416
|
+
row.stopReason = event.stopReason;
|
|
4417
|
+
if (event.stopReason !== 'end_turn') row.incomplete = true;
|
|
4418
|
+
}
|
|
4419
|
+
const last = publications.at(-1);
|
|
4420
|
+
if (event.stopReason === 'end_turn' && last?.presentation.phase === 'final'
|
|
4421
|
+
&& hctx.presentationTextPartsCount === (hctx.assistantTextParts || []).length) last.responseKind = 'result';
|
|
4422
|
+
if (publications.length && !hctx.skipPartialHistory) {
|
|
4423
|
+
const prompts = hctx.includeInitialPrompt ? [hctx.prompt, ...(hctx.appendedUserPrompts || [])] : (hctx.appendedUserPrompts || []);
|
|
4424
|
+
appendTurnToSessionHistory(hctx.sessionId, hctx.threadId || 'main', hctx.vpId,
|
|
4425
|
+
prompts, hctx.assistantTextParts || [], hctx.toolCallsAccum || [],
|
|
4426
|
+
hctx.toolResultsAccum || [], hctx.thinkingBlocksAccum || [],
|
|
4427
|
+
{ turnId: hctx.turnId, partial: true, presentations: publications });
|
|
4428
|
+
}
|
|
4379
4429
|
}
|
|
4380
4430
|
// A normal tool_use stop means "run tools, then call the adapter again",
|
|
4381
4431
|
// so it must NOT end the VP's visible turn. An aborted turn is terminal
|
|
@@ -5668,6 +5718,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
|
|
|
5668
5718
|
pauseQueryTimer,
|
|
5669
5719
|
touchQueryTimer: watchdog.touch,
|
|
5670
5720
|
stopQueryTimer: watchdog.stop,
|
|
5721
|
+
signal: vpAbort.signal,
|
|
5671
5722
|
sessionId,
|
|
5672
5723
|
vpId,
|
|
5673
5724
|
turnId,
|
|
@@ -5810,7 +5861,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
|
|
|
5810
5861
|
// block after the target response.
|
|
5811
5862
|
const visiblePrompts = inboundIsInternal ? appendedUserPrompts : [prompt, ...appendedUserPrompts];
|
|
5812
5863
|
const appendHistoryStart = perfNowMs();
|
|
5813
|
-
appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, { turnId });
|
|
5864
|
+
appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, { turnId, presentations: handlerCtx.presentations });
|
|
5814
5865
|
if (perfTraceId) {
|
|
5815
5866
|
recordAgentPerfTrace(ctx.CONFIG, {
|
|
5816
5867
|
traceId: perfTraceId,
|
|
@@ -5993,7 +6044,11 @@ function buildTurnHistoryEntries(threadId, vpId, prompts, assistantTextParts, to
|
|
|
5993
6044
|
}
|
|
5994
6045
|
}
|
|
5995
6046
|
|
|
5996
|
-
const
|
|
6047
|
+
const presentations = opts.presentations || [];
|
|
6048
|
+
const firstPublicationOffset = presentations[0]?._textPartsCount;
|
|
6049
|
+
const fullText = Number.isInteger(firstPublicationOffset)
|
|
6050
|
+
? assistantTextParts.slice(0, firstPublicationOffset).join('')
|
|
6051
|
+
: assistantTextParts.join('');
|
|
5997
6052
|
if (fullText || toolCallsAccum.length > 0) {
|
|
5998
6053
|
// Stamp speakerVpId on assistant + tool rows so the in-memory
|
|
5999
6054
|
// baseSnapshot filter (filterSnapshotForVp) can mirror the disk
|
|
@@ -6037,6 +6092,19 @@ function buildTurnHistoryEntries(threadId, vpId, prompts, assistantTextParts, to
|
|
|
6037
6092
|
entries.push(markEntry(toolMsg));
|
|
6038
6093
|
}
|
|
6039
6094
|
}
|
|
6095
|
+
for (let index = 0; index < presentations.length; index += 1) {
|
|
6096
|
+
const record = presentations[index];
|
|
6097
|
+
const entry = projectPersistedToHistoryEntry(record);
|
|
6098
|
+
if (entry) entries.push(markEntry(entry));
|
|
6099
|
+
// Keep later prose after its publication in the runtime cache as well as
|
|
6100
|
+
// on disk. The internal offset is never projected into public history.
|
|
6101
|
+
if (Number.isInteger(record._textPartsCount)) {
|
|
6102
|
+
const end = presentations[index + 1]?._textPartsCount ?? assistantTextParts.length;
|
|
6103
|
+
const text = assistantTextParts.slice(record._textPartsCount, end).join('');
|
|
6104
|
+
if (text) entries.push(markEntry({ role: 'assistant', content: text,
|
|
6105
|
+
threadId: threadId || 'main', ...(vpId ? { speakerVpId: vpId } : {}) }));
|
|
6106
|
+
}
|
|
6107
|
+
}
|
|
6040
6108
|
return entries;
|
|
6041
6109
|
}
|
|
6042
6110
|
|