@yeaft/webchat-agent 0.1.502 → 0.1.503

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/unify/engine.js +168 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.502",
3
+ "version": "0.1.503",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
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