@yeaft/webchat-agent 1.0.172 → 1.0.173
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/package.json +1 -1
- package/yeaft/engine.js +22 -1
- package/yeaft/tools/ask-user.js +7 -0
- package/yeaft/web-bridge.js +159 -57
package/package.json
CHANGED
package/yeaft/engine.js
CHANGED
|
@@ -524,6 +524,9 @@ export class Engine {
|
|
|
524
524
|
/** @type {Array<{content:string|Array, preview:string}>} */
|
|
525
525
|
#pendingUserMessages = [];
|
|
526
526
|
|
|
527
|
+
/** True after the bridge queued input in its external per-thread buffer. */
|
|
528
|
+
#externalUserWakePending = false;
|
|
529
|
+
|
|
527
530
|
/**
|
|
528
531
|
* Tasks (background bash, sub-agent spawns) that were launched DURING
|
|
529
532
|
* the currently-running query() and have NOT terminated yet. The query
|
|
@@ -1654,6 +1657,7 @@ export class Engine {
|
|
|
1654
1657
|
|
|
1655
1658
|
#drainPendingUserMessages(drainPendingUserMessages) {
|
|
1656
1659
|
const pending = [];
|
|
1660
|
+
this.#externalUserWakePending = false;
|
|
1657
1661
|
if (typeof drainPendingUserMessages === 'function') {
|
|
1658
1662
|
try {
|
|
1659
1663
|
const drained = drainPendingUserMessages();
|
|
@@ -1809,6 +1813,7 @@ export class Engine {
|
|
|
1809
1813
|
this.#abortReason = null;
|
|
1810
1814
|
this.#currentThreadId = MAIN_THREAD_ID;
|
|
1811
1815
|
this.#pendingUserMessages.length = 0;
|
|
1816
|
+
this.#externalUserWakePending = false;
|
|
1812
1817
|
this.#asyncTaskToolMeta.clear();
|
|
1813
1818
|
this.#pendingTaskResultMessages.length = 0;
|
|
1814
1819
|
this.#pendingTaskResultUpdates.length = 0;
|
|
@@ -2955,7 +2960,8 @@ export class Engine {
|
|
|
2955
2960
|
while (!signal?.aborted
|
|
2956
2961
|
&& this.#pendingTaskResultMessages.length === 0
|
|
2957
2962
|
&& this.#pendingTaskResultUpdates.length === 0
|
|
2958
|
-
&& this.#pendingUserMessages.length === 0
|
|
2963
|
+
&& this.#pendingUserMessages.length === 0
|
|
2964
|
+
&& !this.#externalUserWakePending) {
|
|
2959
2965
|
if (this.#pendingAsyncTaskIds.size === 0) break;
|
|
2960
2966
|
await this.#waitForAsyncWake(signal);
|
|
2961
2967
|
}
|
|
@@ -3680,6 +3686,20 @@ export class Engine {
|
|
|
3680
3686
|
return true;
|
|
3681
3687
|
}
|
|
3682
3688
|
|
|
3689
|
+
/**
|
|
3690
|
+
* Wake a query whose external pending-message hook has received input.
|
|
3691
|
+
* The bridge keeps the full message (attachments and provenance included)
|
|
3692
|
+
* in its thread queue; this method only releases an async-task wait so the
|
|
3693
|
+
* normal drain callback can consume it at the next safe adapter boundary.
|
|
3694
|
+
* @returns {boolean}
|
|
3695
|
+
*/
|
|
3696
|
+
wakeForPendingUserMessage() {
|
|
3697
|
+
if (!this.isRunning) return false;
|
|
3698
|
+
this.#externalUserWakePending = true;
|
|
3699
|
+
this.#wakeAsyncTaskWaiters();
|
|
3700
|
+
return true;
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3683
3703
|
/**
|
|
3684
3704
|
* Install the coordinator that lets an external dispatcher (web-bridge)
|
|
3685
3705
|
* route a background task's terminal event back to THIS engine while
|
|
@@ -3851,6 +3871,7 @@ export class Engine {
|
|
|
3851
3871
|
if (this.#pendingTaskResultMessages.length > 0) return resolve();
|
|
3852
3872
|
if (this.#pendingTaskResultUpdates.length > 0) return resolve();
|
|
3853
3873
|
if (this.#pendingUserMessages.length > 0) return resolve();
|
|
3874
|
+
if (this.#externalUserWakePending) return resolve();
|
|
3854
3875
|
if (signal?.aborted) return resolve();
|
|
3855
3876
|
this.#asyncTaskWaiters.push(resolve);
|
|
3856
3877
|
if (signal && typeof signal.addEventListener === 'function') {
|
package/yeaft/tools/ask-user.js
CHANGED
|
@@ -66,6 +66,13 @@ Guidelines:
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
const answers = await ctx.askUser({ question, options });
|
|
69
|
+
if (answers?.__yeaftTimedOut) {
|
|
70
|
+
return JSON.stringify({
|
|
71
|
+
question,
|
|
72
|
+
timedOut: true,
|
|
73
|
+
message: 'The user did not answer before the request expired. Continue without this input.',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
69
76
|
return JSON.stringify({ question, answers });
|
|
70
77
|
},
|
|
71
78
|
});
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -80,7 +80,27 @@ const YEAFT_SKILL_COMMAND_PREFIX = 'yeaft-skills:';
|
|
|
80
80
|
const PROJECT_SKILL_TIERS = new Set(['project', 'project-claude', 'project-codex']);
|
|
81
81
|
const BASE_RUNTIME_KEY = '__base__';
|
|
82
82
|
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Live AskUser requests. They are Session-scoped runtime state rather than a
|
|
85
|
+
* turn lock: every connected client may answer the same request, while the
|
|
86
|
+
* first valid answer wins. Pending requests are replayed when another device
|
|
87
|
+
* loads the Session.
|
|
88
|
+
* @type {Map<string, {
|
|
89
|
+
* resolve:Function,
|
|
90
|
+
* reject?:Function,
|
|
91
|
+
* sessionId:string,
|
|
92
|
+
* vpId:string,
|
|
93
|
+
* threadId:string,
|
|
94
|
+
* turnId:string,
|
|
95
|
+
* question:string,
|
|
96
|
+
* options:Array<string>,
|
|
97
|
+
* createdAt:number,
|
|
98
|
+
* expiresAt:number,
|
|
99
|
+
* timer?:NodeJS.Timeout|null,
|
|
100
|
+
* resumeQueryTimer?:Function,
|
|
101
|
+
* signal?:AbortSignal,
|
|
102
|
+
* onAbort?:Function,
|
|
103
|
+
* }>} */
|
|
84
104
|
const pendingUserPrompts = new Map();
|
|
85
105
|
|
|
86
106
|
/** @type {import('./session.js').Session | null} */
|
|
@@ -212,6 +232,7 @@ function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
|
|
|
212
232
|
yeaftDir: ctx.CONFIG?.yeaftDir || null,
|
|
213
233
|
tasks: replaySession.taskManager ? replaySession.taskManager.listActiveTasks() : [],
|
|
214
234
|
}, { sessionId });
|
|
235
|
+
if (sessionId) replayPendingUserPrompts(sessionId);
|
|
215
236
|
sendSessionSnapshotBroadcast();
|
|
216
237
|
if (sessionId && session === replaySession) {
|
|
217
238
|
sendDreamSnapshotForSession(sessionId, { trigger: 'load_history' }).catch(() => null);
|
|
@@ -796,6 +817,8 @@ export function broadcastLanguageChange(language) {
|
|
|
796
817
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
797
818
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
798
819
|
const HIGH_REASONING_QUERY_TIMEOUT_MS = 300_000;
|
|
820
|
+
/** AskUser is human-paced but must never pin a VP turn forever. */
|
|
821
|
+
const ASK_USER_TIMEOUT_MS = 10 * 60_000;
|
|
799
822
|
|
|
800
823
|
function isHighReasoningEffort(effort) {
|
|
801
824
|
const value = typeof effort === 'string' ? effort.trim().toLowerCase() : '';
|
|
@@ -1378,6 +1401,12 @@ export function __testGetVpThreads(sessionId, vpId) {
|
|
|
1378
1401
|
}));
|
|
1379
1402
|
}
|
|
1380
1403
|
|
|
1404
|
+
/** Test-only: attach an Engine-like wake target to a seeded VP thread. */
|
|
1405
|
+
export function __testSetVpThreadEngine({ sessionId, vpId, threadId, engine }) {
|
|
1406
|
+
const thread = getOrCreateVpThread({ sessionId, vpId, threadId });
|
|
1407
|
+
thread.engine = engine || null;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1381
1410
|
/** Test-only: seed a VP thread without starting its engine driver. */
|
|
1382
1411
|
export function __testSeedVpThread({ sessionId, vpId, threadId, title = 'test thread', status = 'queued' }) {
|
|
1383
1412
|
const thread = getOrCreateVpThread({ sessionId, vpId, threadId, title });
|
|
@@ -1725,31 +1754,43 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
|
|
|
1725
1754
|
senderVpId: isInternalAppend ? (envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null) : null,
|
|
1726
1755
|
sourceThreadId: isInternalAppend ? visibleInboundThreadId(envelope, thread.threadId) : null,
|
|
1727
1756
|
});
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
sendSessionEvent({
|
|
1743
|
-
type: 'vp_thread_user_appended',
|
|
1757
|
+
// A thread parked on a background task has no adapter activity to poll this
|
|
1758
|
+
// queue. Wake its Engine explicitly so the new user message is consumed
|
|
1759
|
+
// immediately while the task remains tracked in TaskManager. If the Engine
|
|
1760
|
+
// is not actually running, this was a stale classifier hit; remove the
|
|
1761
|
+
// append and fall through to a normal queued turn instead of orphaning it.
|
|
1762
|
+
let appendedToRunningThread = false;
|
|
1763
|
+
try { appendedToRunningThread = thread.engine?.wakeForPendingUserMessage?.() === true; } catch { /* best-effort wake */ }
|
|
1764
|
+
if (!appendedToRunningThread) {
|
|
1765
|
+
thread.pendingQueries.pop();
|
|
1766
|
+
related = false;
|
|
1767
|
+
} else {
|
|
1768
|
+
persistInboundMessageOnceByMsgId({
|
|
1769
|
+
msgId: envelope?.msg?.id,
|
|
1770
|
+
text,
|
|
1744
1771
|
sessionId,
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1772
|
+
threadId: visibleInboundThreadId(envelope, thread.threadId),
|
|
1773
|
+
role: isInternalAppend ? 'assistant' : 'user',
|
|
1774
|
+
speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
|
|
1775
|
+
attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
|
|
1776
|
+
internal: isInternalAppend,
|
|
1777
|
+
ts: envelope?.msg?.ts || null,
|
|
1778
|
+
clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
|
|
1779
|
+
});
|
|
1780
|
+
thread.updatedAt = Date.now();
|
|
1781
|
+
try {
|
|
1782
|
+
sendSessionEvent({
|
|
1783
|
+
type: 'vp_thread_user_appended',
|
|
1784
|
+
sessionId,
|
|
1785
|
+
vpId,
|
|
1786
|
+
threadId: thread.threadId,
|
|
1787
|
+
title: thread.title,
|
|
1788
|
+
turnId,
|
|
1789
|
+
ts: Date.now(),
|
|
1790
|
+
}, { sessionId, vpId, threadId: thread.threadId, turnId });
|
|
1791
|
+
} catch { /* never crash WS pipeline */ }
|
|
1792
|
+
return thread.threadId;
|
|
1793
|
+
}
|
|
1753
1794
|
}
|
|
1754
1795
|
|
|
1755
1796
|
const key = threadKey(sessionId, vpId, thread.threadId);
|
|
@@ -2335,6 +2376,60 @@ function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, pe
|
|
|
2335
2376
|
});
|
|
2336
2377
|
}
|
|
2337
2378
|
|
|
2379
|
+
function pendingUserPromptEvent(requestId, pending, extra = {}) {
|
|
2380
|
+
return {
|
|
2381
|
+
type: 'ask_user_question',
|
|
2382
|
+
requestId,
|
|
2383
|
+
questions: [{
|
|
2384
|
+
question: pending.question,
|
|
2385
|
+
options: pending.options.map(label => ({ label, description: '' })),
|
|
2386
|
+
multiSelect: false,
|
|
2387
|
+
}],
|
|
2388
|
+
createdAt: pending.createdAt,
|
|
2389
|
+
expiresAt: pending.expiresAt,
|
|
2390
|
+
...extra,
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function sendPendingUserPrompt(requestId, pending, extra = {}) {
|
|
2395
|
+
sendSessionEvent(pendingUserPromptEvent(requestId, pending, extra), {
|
|
2396
|
+
sessionId: pending.sessionId,
|
|
2397
|
+
vpId: pending.vpId,
|
|
2398
|
+
threadId: pending.threadId,
|
|
2399
|
+
turnId: pending.turnId,
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
function replayPendingUserPrompts(sessionId) {
|
|
2404
|
+
const now = Date.now();
|
|
2405
|
+
for (const [requestId, pending] of pendingUserPrompts) {
|
|
2406
|
+
if (pending.sessionId !== sessionId || pending.expiresAt <= now) continue;
|
|
2407
|
+
sendPendingUserPrompt(requestId, pending, { replay: true });
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
function settlePendingUserPrompt(requestId, pending, { answers = null, timedOut = false } = {}) {
|
|
2412
|
+
if (pendingUserPrompts.get(requestId) !== pending) return false;
|
|
2413
|
+
pendingUserPrompts.delete(requestId);
|
|
2414
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
2415
|
+
if (pending.signal && pending.onAbort) {
|
|
2416
|
+
try { pending.signal.removeEventListener('abort', pending.onAbort); } catch { /* ignore */ }
|
|
2417
|
+
}
|
|
2418
|
+
try { pending.resumeQueryTimer?.(); } catch { /* best-effort */ }
|
|
2419
|
+
sendSessionEvent({
|
|
2420
|
+
type: timedOut ? 'ask_user_expired' : 'ask_user_answered',
|
|
2421
|
+
requestId,
|
|
2422
|
+
...(timedOut ? { expiredAt: Date.now() } : { answers: answers || {} }),
|
|
2423
|
+
}, {
|
|
2424
|
+
sessionId: pending.sessionId,
|
|
2425
|
+
vpId: pending.vpId,
|
|
2426
|
+
threadId: pending.threadId,
|
|
2427
|
+
turnId: pending.turnId,
|
|
2428
|
+
});
|
|
2429
|
+
pending.resolve(timedOut ? { __yeaftTimedOut: true } : (answers || {}));
|
|
2430
|
+
return true;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2338
2433
|
function configuredVpPaths() {
|
|
2339
2434
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
2340
2435
|
if (typeof yeaftDir !== 'string' || !yeaftDir.trim()) return {};
|
|
@@ -4560,40 +4655,44 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
|
|
|
4560
4655
|
askUser: ({ question, options }) => new Promise((resolve, reject) => {
|
|
4561
4656
|
const requestId = `ask_${randomUUID()}`;
|
|
4562
4657
|
const signal = vpAbort.signal;
|
|
4563
|
-
|
|
4564
|
-
//
|
|
4658
|
+
const createdAt = Date.now();
|
|
4659
|
+
// Human think time is not engine silence. Pause the query watchdog,
|
|
4660
|
+
// but keep a separate bounded AskUser lifetime so an abandoned card
|
|
4661
|
+
// cannot pin the VP forever.
|
|
4565
4662
|
if (queryTimer) {
|
|
4566
4663
|
clearTimeout(queryTimer);
|
|
4567
4664
|
queryTimer = null;
|
|
4568
4665
|
}
|
|
4569
|
-
const
|
|
4570
|
-
|
|
4571
|
-
reject
|
|
4572
|
-
};
|
|
4573
|
-
pendingUserPrompts.set(requestId, {
|
|
4574
|
-
resolve: answers => {
|
|
4575
|
-
resetQueryTimer();
|
|
4576
|
-
resolve(answers);
|
|
4577
|
-
},
|
|
4666
|
+
const pending = {
|
|
4667
|
+
resolve,
|
|
4668
|
+
reject,
|
|
4578
4669
|
sessionId,
|
|
4579
4670
|
vpId,
|
|
4580
4671
|
threadId,
|
|
4581
4672
|
turnId,
|
|
4673
|
+
question,
|
|
4674
|
+
options: Array.isArray(options) ? options.filter(label => typeof label === 'string') : [],
|
|
4675
|
+
createdAt,
|
|
4676
|
+
expiresAt: createdAt + ASK_USER_TIMEOUT_MS,
|
|
4677
|
+
timer: null,
|
|
4678
|
+
resumeQueryTimer: resetQueryTimer,
|
|
4582
4679
|
signal,
|
|
4583
|
-
onAbort,
|
|
4584
|
-
}
|
|
4680
|
+
onAbort: null,
|
|
4681
|
+
};
|
|
4682
|
+
const onAbort = () => {
|
|
4683
|
+
if (pendingUserPrompts.get(requestId) !== pending) return;
|
|
4684
|
+
pendingUserPrompts.delete(requestId);
|
|
4685
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
4686
|
+
reject(new Error('aborted'));
|
|
4687
|
+
};
|
|
4688
|
+
pending.onAbort = onAbort;
|
|
4689
|
+
pending.timer = setTimeout(() => {
|
|
4690
|
+
settlePendingUserPrompt(requestId, pending, { timedOut: true });
|
|
4691
|
+
}, ASK_USER_TIMEOUT_MS);
|
|
4692
|
+
if (typeof pending.timer.unref === 'function') pending.timer.unref();
|
|
4693
|
+
pendingUserPrompts.set(requestId, pending);
|
|
4585
4694
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
4586
|
-
|
|
4587
|
-
type: 'ask_user_question',
|
|
4588
|
-
requestId,
|
|
4589
|
-
questions: [{
|
|
4590
|
-
question,
|
|
4591
|
-
options: Array.isArray(options)
|
|
4592
|
-
? options.map(label => ({ label, description: '' }))
|
|
4593
|
-
: [],
|
|
4594
|
-
multiSelect: false,
|
|
4595
|
-
}],
|
|
4596
|
-
}, envelope);
|
|
4695
|
+
sendPendingUserPrompt(requestId, pending);
|
|
4597
4696
|
}),
|
|
4598
4697
|
threadId,
|
|
4599
4698
|
vpTurnId: turnId,
|
|
@@ -5639,12 +5738,7 @@ export function handleYeaftAskUserAnswer(msg) {
|
|
|
5639
5738
|
if (msg.turnId && msg.turnId !== pending.turnId) return false;
|
|
5640
5739
|
if (msg.threadId && msg.threadId !== pending.threadId) return false;
|
|
5641
5740
|
|
|
5642
|
-
|
|
5643
|
-
if (pending.signal && pending.onAbort) {
|
|
5644
|
-
try { pending.signal.removeEventListener('abort', pending.onAbort); } catch { /* ignore */ }
|
|
5645
|
-
}
|
|
5646
|
-
pending.resolve(msg.answers || {});
|
|
5647
|
-
return true;
|
|
5741
|
+
return settlePendingUserPrompt(requestId, pending, { answers: msg.answers || {} });
|
|
5648
5742
|
}
|
|
5649
5743
|
|
|
5650
5744
|
export async function handleYeaftSessionSend(msg) {
|
|
@@ -6543,13 +6637,21 @@ export const __testHooks = {
|
|
|
6543
6637
|
vpAborts.clear();
|
|
6544
6638
|
vpInboxes.clear();
|
|
6545
6639
|
},
|
|
6546
|
-
seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test' } = {}) {
|
|
6640
|
+
seedPendingUserPrompt({ requestId = 'ask-test', sessionId = 'session-test', vpId = 'vp-test', threadId = 'main', turnId = 'turn-test', question = 'Continue?', options = [], createdAt = Date.now(), expiresAt = Date.now() + ASK_USER_TIMEOUT_MS } = {}) {
|
|
6547
6641
|
let resolved;
|
|
6548
6642
|
const promise = new Promise(resolve => { resolved = resolve; });
|
|
6549
|
-
pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId });
|
|
6643
|
+
pendingUserPrompts.set(requestId, { resolve: resolved, sessionId, vpId, threadId, turnId, question, options, createdAt, expiresAt, timer: null });
|
|
6550
6644
|
return promise;
|
|
6551
6645
|
},
|
|
6646
|
+
replayPendingUserPrompts,
|
|
6647
|
+
settlePendingUserPromptForTest(requestId, opts = {}) {
|
|
6648
|
+
const pending = pendingUserPrompts.get(requestId);
|
|
6649
|
+
return pending ? settlePendingUserPrompt(requestId, pending, opts) : false;
|
|
6650
|
+
},
|
|
6552
6651
|
resetPendingUserPrompts() {
|
|
6652
|
+
for (const pending of pendingUserPrompts.values()) {
|
|
6653
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
6654
|
+
}
|
|
6553
6655
|
pendingUserPrompts.clear();
|
|
6554
6656
|
},
|
|
6555
6657
|
resetVpStatusBroker() {
|