@yeaft/webchat-agent 0.1.502 → 0.1.504
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/unify/engine.js +168 -1
- package/unify/web-bridge.js +133 -0
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { randomUUID } from 'crypto';
|
|
21
21
|
import { buildSystemPrompt } from './prompts.js';
|
|
22
|
-
import { LLMContextError } from './llm/adapter.js';
|
|
22
|
+
import { LLMContextError, LLMAbortError } from './llm/adapter.js';
|
|
23
23
|
import { recall } from './memory/recall.js';
|
|
24
24
|
import { shouldConsolidate, consolidate } from './memory/consolidate.js';
|
|
25
25
|
import { buildMemoryInjection } from './memory/layout.js';
|
|
@@ -94,6 +94,32 @@ export class Engine {
|
|
|
94
94
|
/** @type {object|null} — Config override for internal tasks (recall, consolidation, dream) using fastModel */
|
|
95
95
|
#fastConfig;
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* task-325a — abort state.
|
|
99
|
+
*
|
|
100
|
+
* The engine exposes a first-class abort surface: `engine.abort(reason)`
|
|
101
|
+
* aborts the currently running `query()` loop. Internally we keep:
|
|
102
|
+
*
|
|
103
|
+
* • `#currentAbortCtrl` — the per-query AbortController created (or
|
|
104
|
+
* reused from the caller's signal) when query() starts. Used to
|
|
105
|
+
* propagate abort to the LLM adapter stream and to tool execution.
|
|
106
|
+
* • `#abortReason` — the reason string passed to abort(), surfaced
|
|
107
|
+
* on the emitted `aborted` event so the UI can render a meaningful
|
|
108
|
+
* stop banner (`user`, `timeout`, `thread_reset`, etc.).
|
|
109
|
+
*
|
|
110
|
+
* State machine convergence: when the signal fires, the loop catches the
|
|
111
|
+
* LLMAbortError (or a synthetic abort check) and yields exactly one pair
|
|
112
|
+
* of events — `{type:'aborted', reason}` followed by
|
|
113
|
+
* `{type:'turn_end', stopReason:'aborted'}` — then returns without
|
|
114
|
+
* persisting partial tool calls, consolidation, or stop-hook side-effects.
|
|
115
|
+
*
|
|
116
|
+
* @type {AbortController|null}
|
|
117
|
+
*/
|
|
118
|
+
#currentAbortCtrl = null;
|
|
119
|
+
|
|
120
|
+
/** @type {string|null} */
|
|
121
|
+
#abortReason = null;
|
|
122
|
+
|
|
97
123
|
/**
|
|
98
124
|
* @param {{
|
|
99
125
|
* adapter: import('./llm/adapter.js').LLMAdapter,
|
|
@@ -139,6 +165,46 @@ export class Engine {
|
|
|
139
165
|
this.#tools.set(tool.name, tool);
|
|
140
166
|
}
|
|
141
167
|
|
|
168
|
+
/**
|
|
169
|
+
* task-325a — abort the currently running query().
|
|
170
|
+
*
|
|
171
|
+
* Idempotent and safe to call when no query is in flight (no-op).
|
|
172
|
+
* The abort is cooperative: the in-flight adapter stream receives the
|
|
173
|
+
* signal immediately (fetch aborts), the tool loop checks the signal
|
|
174
|
+
* between invocations, and the loop emits a typed `aborted` event
|
|
175
|
+
* before returning so the caller can distinguish "user stopped" from
|
|
176
|
+
* "LLM returned end_turn".
|
|
177
|
+
*
|
|
178
|
+
* @param {string} [reason='user'] — Human-tagged reason surfaced on the
|
|
179
|
+
* emitted `aborted` event. Common values: `'user'`, `'timeout'`,
|
|
180
|
+
* `'thread_reset'`, `'session_reset'`.
|
|
181
|
+
* @returns {boolean} true if an in-flight query was aborted, false if
|
|
182
|
+
* nothing was running (no-op).
|
|
183
|
+
*/
|
|
184
|
+
abort(reason = 'user') {
|
|
185
|
+
if (!this.#currentAbortCtrl) return false;
|
|
186
|
+
if (this.#currentAbortCtrl.signal.aborted) return false;
|
|
187
|
+
this.#abortReason = reason || 'user';
|
|
188
|
+
try {
|
|
189
|
+
this.#currentAbortCtrl.abort();
|
|
190
|
+
} catch {
|
|
191
|
+
// AbortController.abort never throws in practice, but swallow
|
|
192
|
+
// defensively so abort() never takes down the caller.
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* task-325a — whether there is an in-flight query that has NOT been
|
|
199
|
+
* aborted. Useful for callers that want to know "is this engine busy?"
|
|
200
|
+
* without racing on the signal.
|
|
201
|
+
* @returns {boolean}
|
|
202
|
+
*/
|
|
203
|
+
get isRunning() {
|
|
204
|
+
return !!this.#currentAbortCtrl && !this.#currentAbortCtrl.signal.aborted;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
142
208
|
/**
|
|
143
209
|
* Unregister a tool.
|
|
144
210
|
*
|
|
@@ -370,6 +436,58 @@ export class Engine {
|
|
|
370
436
|
return;
|
|
371
437
|
}
|
|
372
438
|
|
|
439
|
+
// ─── task-325a: engine-owned AbortController ─────────────
|
|
440
|
+
// We create our own controller for this query run so `engine.abort()`
|
|
441
|
+
// can trigger cancellation without requiring the caller to hand in a
|
|
442
|
+
// signal. If the caller DID provide a signal, we mirror its state onto
|
|
443
|
+
// our controller (honouring both entry points). The linked signal
|
|
444
|
+
// forwarded to the adapter/tools is always `abortCtrl.signal`, so
|
|
445
|
+
// there is exactly one place that actually stops work in flight.
|
|
446
|
+
const abortCtrl = new AbortController();
|
|
447
|
+
this.#currentAbortCtrl = abortCtrl;
|
|
448
|
+
this.#abortReason = null;
|
|
449
|
+
|
|
450
|
+
const onExternalAbort = () => {
|
|
451
|
+
if (!abortCtrl.signal.aborted) {
|
|
452
|
+
// Tag the reason so the emitted `aborted` event reflects the
|
|
453
|
+
// external trigger. Callers that pass a signal without invoking
|
|
454
|
+
// engine.abort() get the neutral tag 'external'.
|
|
455
|
+
if (!this.#abortReason) this.#abortReason = 'external';
|
|
456
|
+
try { abortCtrl.abort(); } catch { /* ignore */ }
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
if (signal) {
|
|
460
|
+
if (signal.aborted) {
|
|
461
|
+
this.#abortReason = 'external';
|
|
462
|
+
try { abortCtrl.abort(); } catch { /* ignore */ }
|
|
463
|
+
} else {
|
|
464
|
+
signal.addEventListener('abort', onExternalAbort, { once: true });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// The signal passed down to adapter.stream() + tool execution.
|
|
468
|
+
const runSignal = abortCtrl.signal;
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
yield* this.#runQuery({ prompt, messages, signal: runSignal });
|
|
472
|
+
} finally {
|
|
473
|
+
if (signal) {
|
|
474
|
+
try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
|
|
475
|
+
}
|
|
476
|
+
// Clear current-run state so engine.isRunning flips back to false
|
|
477
|
+
// and a subsequent query() starts with a clean slate.
|
|
478
|
+
this.#currentAbortCtrl = null;
|
|
479
|
+
this.#abortReason = null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Internal: the original query loop body. Split out of `query()` so the
|
|
485
|
+
* public method can own the per-run AbortController + abort lifecycle
|
|
486
|
+
* in a try/finally without indenting the whole loop.
|
|
487
|
+
* @private
|
|
488
|
+
*/
|
|
489
|
+
async *#runQuery({ prompt, messages, signal }) {
|
|
490
|
+
|
|
373
491
|
// ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
|
|
374
492
|
// New layout: always inject Memory Index + user-preferences + project
|
|
375
493
|
// header excerpt. No per-turn fuzzy recall — LLM calls memory_search /
|
|
@@ -414,6 +532,16 @@ export class Engine {
|
|
|
414
532
|
// non-retryable error, LLMContextError (after compact retry), or
|
|
415
533
|
// caller abort. Keeping this comment so the removal is traceable.
|
|
416
534
|
|
|
535
|
+
// task-325a: check for user abort at the top of every turn so a
|
|
536
|
+
// signal that fires between turns (e.g. during tool execution in
|
|
537
|
+
// the previous iteration) cleanly ends the loop instead of
|
|
538
|
+
// launching another adapter stream.
|
|
539
|
+
if (signal?.aborted) {
|
|
540
|
+
yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber };
|
|
541
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'aborted' };
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
|
|
417
545
|
const turnId = this.#trace.startTurn({
|
|
418
546
|
traceId: this.#traceId,
|
|
419
547
|
turnNumber,
|
|
@@ -491,6 +619,22 @@ export class Engine {
|
|
|
491
619
|
stopReason: 'error',
|
|
492
620
|
};
|
|
493
621
|
|
|
622
|
+
// ─── task-325a: abort short-circuit ────────────────
|
|
623
|
+
// If the adapter threw LLMAbortError, or the signal fired during
|
|
624
|
+
// stream() (fetch throws AbortError / DOMException), we converge
|
|
625
|
+
// the state machine on the 'aborted' terminal state — no retry,
|
|
626
|
+
// no fallback, no persistence. One `aborted` event + one
|
|
627
|
+
// `turn_end` with stopReason='aborted' and we're done.
|
|
628
|
+
const isAbort = err instanceof LLMAbortError
|
|
629
|
+
|| err?.name === 'AbortError'
|
|
630
|
+
|| err?.name === 'LLMAbortError'
|
|
631
|
+
|| (signal?.aborted && /abort/i.test(err?.message || ''));
|
|
632
|
+
if (isAbort || signal?.aborted) {
|
|
633
|
+
yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber };
|
|
634
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'aborted' };
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
|
|
494
638
|
// ─── LLMContextError → force compact → retry ──────
|
|
495
639
|
if (err instanceof LLMContextError && this.#conversationStore && this.#memoryStore) {
|
|
496
640
|
const consolidated = await this.#maybeConsolidate();
|
|
@@ -613,7 +757,21 @@ export class Engine {
|
|
|
613
757
|
// Execute tool calls and feed results back
|
|
614
758
|
const toolCtx = this.#buildToolContext(signal);
|
|
615
759
|
|
|
760
|
+
// task-325a: track whether we aborted mid tool-loop so we can
|
|
761
|
+
// break out of the outer while-loop cleanly once the current
|
|
762
|
+
// tool batch finishes reporting.
|
|
763
|
+
let abortedDuringTools = false;
|
|
764
|
+
|
|
616
765
|
for (const tc of toolCalls) {
|
|
766
|
+
// task-325a: honour abort between tools. We don't cancel a tool
|
|
767
|
+
// that's already running (the signal is passed in, tools decide
|
|
768
|
+
// themselves whether to bail early), but we stop dispatching
|
|
769
|
+
// any remaining tools the moment abort fires.
|
|
770
|
+
if (signal?.aborted) {
|
|
771
|
+
abortedDuringTools = true;
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
|
|
617
775
|
const toolStartTime = Date.now();
|
|
618
776
|
|
|
619
777
|
let output;
|
|
@@ -665,6 +823,15 @@ export class Engine {
|
|
|
665
823
|
});
|
|
666
824
|
}
|
|
667
825
|
|
|
826
|
+
// task-325a: if abort fired between tools, converge now — emit
|
|
827
|
+
// the typed `aborted` event + a final turn_end with stopReason
|
|
828
|
+
// 'aborted' instead of looping back to a new adapter call.
|
|
829
|
+
if (abortedDuringTools || signal?.aborted) {
|
|
830
|
+
yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber };
|
|
831
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'aborted' };
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
|
|
668
835
|
yield { type: 'turn_end', turnNumber, stopReason: 'tool_use' };
|
|
669
836
|
|
|
670
837
|
// Loop back to call adapter again with tool results
|
package/unify/web-bridge.js
CHANGED
|
@@ -243,6 +243,109 @@ const THREAD_MUTATING_TOOLS = new Set([
|
|
|
243
243
|
'AttachThreadToTask',
|
|
244
244
|
]);
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* task-325b — Working Status event stream.
|
|
248
|
+
*
|
|
249
|
+
* Surfaces Engine lifecycle events (emitted by 325a) as a single
|
|
250
|
+
* `thread_status` event for the frontend Working Status panel, plus
|
|
251
|
+
* `thread_list_snapshot` for cold-start / reconnect.
|
|
252
|
+
*
|
|
253
|
+
* Contract (aligned with designer spec):
|
|
254
|
+
* thread_status → { type: 'thread_status', threadId, state,
|
|
255
|
+
* startedAt?, completedAt?, toolName?, reason? }
|
|
256
|
+
* state ∈ 'running' | 'idle' | 'aborted' | 'error'
|
|
257
|
+
* thread_list_snapshot → { type: 'thread_list_snapshot', threads[],
|
|
258
|
+
* currentThreadId, serverTime }
|
|
259
|
+
*
|
|
260
|
+
* Red lines (per PM): do NOT mutate engine state; this layer is a pure
|
|
261
|
+
* observer + translator. Event names match designer doc verbatim.
|
|
262
|
+
*/
|
|
263
|
+
|
|
264
|
+
/** Map Engine event name → Working Status state string. */
|
|
265
|
+
function engineEventToState(engineEventType) {
|
|
266
|
+
switch (engineEventType) {
|
|
267
|
+
case 'thread_started': return 'running';
|
|
268
|
+
case 'thread_completed': return 'idle';
|
|
269
|
+
case 'thread_aborted': return 'aborted';
|
|
270
|
+
case 'thread_error': return 'error';
|
|
271
|
+
default: return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build and broadcast a `thread_status` payload translated from a raw
|
|
277
|
+
* engine lifecycle event. The engine event shape (325a) is:
|
|
278
|
+
* { type, threadId, startedAt?, completedAt?, toolName?, reason? }
|
|
279
|
+
* Unknown fields pass through untouched so future engine additions
|
|
280
|
+
* (e.g. `attempt`) flow to the UI without another bridge change.
|
|
281
|
+
*
|
|
282
|
+
* @param {object} ev — engine event
|
|
283
|
+
* @returns {boolean} true if a thread_status was emitted
|
|
284
|
+
*/
|
|
285
|
+
function emitThreadStatusFromEngineEvent(ev) {
|
|
286
|
+
if (!ev || typeof ev !== 'object') return false;
|
|
287
|
+
const state = engineEventToState(ev.type);
|
|
288
|
+
if (!state) return false;
|
|
289
|
+
const payload = { type: 'thread_status', threadId: ev.threadId, state };
|
|
290
|
+
if (ev.startedAt != null) payload.startedAt = ev.startedAt;
|
|
291
|
+
if (ev.completedAt != null) payload.completedAt = ev.completedAt;
|
|
292
|
+
if (ev.toolName) payload.toolName = ev.toolName;
|
|
293
|
+
if (ev.reason) payload.reason = ev.reason;
|
|
294
|
+
if (ev.error?.message) payload.error = ev.error.message;
|
|
295
|
+
sendUnifyEvent(payload);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* task-325b: full-snapshot push distinct from `thread_list_updated`.
|
|
301
|
+
* Emits `thread_list_snapshot` — a complete state dump the client uses
|
|
302
|
+
* on page load / WebSocket reconnect to rebuild the Working Status panel
|
|
303
|
+
* without missing any in-flight thread.
|
|
304
|
+
*
|
|
305
|
+
* Snapshot includes per-thread `state` (idle / running / aborted) resolved
|
|
306
|
+
* from the engine registry's live inflight set. Threads the registry has
|
|
307
|
+
* no entry for default to 'idle'.
|
|
308
|
+
*/
|
|
309
|
+
function sendThreadListSnapshot() {
|
|
310
|
+
try {
|
|
311
|
+
const store = getThreadStore();
|
|
312
|
+
const registry = session?.engineRegistry || null;
|
|
313
|
+
const inflight = new Set(
|
|
314
|
+
typeof registry?.inflightThreadIds === 'function'
|
|
315
|
+
? registry.inflightThreadIds()
|
|
316
|
+
: [],
|
|
317
|
+
);
|
|
318
|
+
const threads = store.list().map(t => ({
|
|
319
|
+
id: t.id,
|
|
320
|
+
name: t.name,
|
|
321
|
+
goal: t.goal || '',
|
|
322
|
+
parentThreadId: t.parentThreadId || null,
|
|
323
|
+
status: t.status,
|
|
324
|
+
archived: !!t.archived,
|
|
325
|
+
messageCount: t.messageCount || 0,
|
|
326
|
+
lastMessageAt: t.lastMessageAt || null,
|
|
327
|
+
lastActivityAt: t.lastActivityAt || t.lastMessageAt || t.updatedAt || null,
|
|
328
|
+
unread: t.unread || 0,
|
|
329
|
+
preview: t.preview || '',
|
|
330
|
+
createdAt: t.createdAt,
|
|
331
|
+
updatedAt: t.updatedAt,
|
|
332
|
+
taskId: (typeof store.attachedTask === 'function')
|
|
333
|
+
? (store.attachedTask(t.id) || null)
|
|
334
|
+
: null,
|
|
335
|
+
running: t.id === store.currentId,
|
|
336
|
+
state: inflight.has(t.id) ? 'running' : 'idle',
|
|
337
|
+
}));
|
|
338
|
+
sendUnifyEvent({
|
|
339
|
+
type: 'thread_list_snapshot',
|
|
340
|
+
threads,
|
|
341
|
+
currentThreadId: store.currentId,
|
|
342
|
+
serverTime: Date.now(),
|
|
343
|
+
});
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.warn('[Unify] sendThreadListSnapshot failed:', err?.message || err);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
246
349
|
/**
|
|
247
350
|
* task-310: parse a leading `@thread-<id>` or `@thread-<name>` marker on
|
|
248
351
|
* the user's input and return it as a dispatcher override. The marker
|
|
@@ -331,6 +434,23 @@ function forwardPipelineEvent(ev, ctx) {
|
|
|
331
434
|
*/
|
|
332
435
|
function handleEngineEvent(event, threadId, hctx) {
|
|
333
436
|
hctx.resetQueryTimer();
|
|
437
|
+
|
|
438
|
+
// task-325b: translate Engine lifecycle events into a single
|
|
439
|
+
// `thread_status` event for the frontend Working Status panel. These
|
|
440
|
+
// events are observer-only — they never mutate bridge state. The raw
|
|
441
|
+
// engine event is NOT forwarded further; the switch below handles
|
|
442
|
+
// anything the UI still needs.
|
|
443
|
+
if (event && (
|
|
444
|
+
event.type === 'thread_started' ||
|
|
445
|
+
event.type === 'thread_completed' ||
|
|
446
|
+
event.type === 'thread_aborted' ||
|
|
447
|
+
event.type === 'thread_error'
|
|
448
|
+
)) {
|
|
449
|
+
// Engine events carry their own threadId; fall back to envelope id.
|
|
450
|
+
emitThreadStatusFromEngineEvent({ ...event, threadId: event.threadId || threadId });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
334
454
|
switch (event.type) {
|
|
335
455
|
case 'text_delta':
|
|
336
456
|
hctx.assistantTextParts.push(event.text);
|
|
@@ -562,6 +682,10 @@ export async function handleUnifyChat(msg) {
|
|
|
562
682
|
// task-301 Part 2: initial thread snapshot so sidebar V2 renders
|
|
563
683
|
// the real 'main' thread (and any restored threads) right away.
|
|
564
684
|
sendThreadListUpdate();
|
|
685
|
+
// task-325b: full Working Status snapshot (superset with state +
|
|
686
|
+
// serverTime) so a freshly-connected client can restore inflight
|
|
687
|
+
// status without waiting for the next engine event.
|
|
688
|
+
sendThreadListSnapshot();
|
|
565
689
|
}
|
|
566
690
|
|
|
567
691
|
// ─── Per-call AbortController (task-320) ──
|
|
@@ -945,6 +1069,12 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
945
1069
|
tools: session.status.tools,
|
|
946
1070
|
});
|
|
947
1071
|
sendThreadListUpdate();
|
|
1072
|
+
// task-325b: after a page refresh / reconnect the frontend needs the
|
|
1073
|
+
// full Working Status snapshot to rebuild the panel (which thread is
|
|
1074
|
+
// running, idle, aborted). `thread_list_updated` is intentionally a
|
|
1075
|
+
// mutation-delta stream; `thread_list_snapshot` is the single
|
|
1076
|
+
// authoritative "everything right now" payload.
|
|
1077
|
+
sendThreadListSnapshot();
|
|
948
1078
|
|
|
949
1079
|
const limit = msg.limit || 50;
|
|
950
1080
|
const messages = session.conversationStore.loadRecent(limit);
|
|
@@ -1030,6 +1160,9 @@ export async function resetUnifySession() {
|
|
|
1030
1160
|
});
|
|
1031
1161
|
// task-301 Part 2: re-push thread snapshot after session reset.
|
|
1032
1162
|
sendThreadListUpdate();
|
|
1163
|
+
// task-325b: also push the full Working Status snapshot so the UI
|
|
1164
|
+
// doesn't retain stale "running" badges from the prior session.
|
|
1165
|
+
sendThreadListSnapshot();
|
|
1033
1166
|
} catch (err) {
|
|
1034
1167
|
console.error('[Unify] Failed to re-initialize session after reset:', err.message);
|
|
1035
1168
|
}
|