@yeaft/webchat-agent 0.1.505 → 0.1.507

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.
@@ -25,7 +25,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
25
25
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
26
26
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
27
27
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings } from '../unify/config-api.js';
28
- import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread } from '../unify/web-bridge.js';
28
+ import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyMergeThread, handleUnifyForkThread, handleUnifyAbortThread, handleUnifyAbortAll } from '../unify/web-bridge.js';
29
29
 
30
30
  export async function handleMessage(msg) {
31
31
  switch (msg.type) {
@@ -373,6 +373,19 @@ export async function handleMessage(msg) {
373
373
  handleUnifyForkThread(msg);
374
374
  break;
375
375
 
376
+ case 'unify_abort_thread':
377
+ // task-325c: user-initiated abort of a single thread's in-flight
378
+ // query. Payload `{ threadId }`. Silent no-op when the thread has
379
+ // no in-flight controller.
380
+ handleUnifyAbortThread(msg);
381
+ break;
382
+
383
+ case 'unify_abort_all':
384
+ // task-325c: user-initiated abort of ALL in-flight queries across
385
+ // every thread. Always emits `unify_aborted` ack.
386
+ handleUnifyAbortAll();
387
+ break;
388
+
376
389
  // Expert roles definition (for ExpertPanel detail view)
377
390
  case 'get_expert_roles': {
378
391
  const { getExpertRolesDefinition } = await import('../expert-roles.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.505",
3
+ "version": "0.1.507",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,119 @@
1
+ /**
2
+ * effort.js — Scenario → effort decision tree for Unify
3
+ *
4
+ * task-327b: given a per-query context (scenario tag, tool-loop depth,
5
+ * user override), pick the thinking/reasoning effort level that should be
6
+ * passed to `adapter.stream({ effort })`.
7
+ *
8
+ * Config layering (highest wins):
9
+ * 1. userEffort (explicit per-query override — from `/max` prefix,
10
+ * Settings slider, or API caller)
11
+ * 2. scenarioEffort (from the decision tree below)
12
+ * 3. model defaultEffort (from registry) — handled at adapter level
13
+ * 4. null (no effort = adapter/router drops the param)
14
+ *
15
+ * Red lines:
16
+ * • Never error on unknown scenario — default to 'high'.
17
+ * • Feature flag UNIFY_THINKING_V1 is enforced at the adapter/router
18
+ * layer; this module just computes the intended value. If the flag
19
+ * is off, adapters drop it anyway.
20
+ * • Unsupported models silently drop effort at the router — this
21
+ * module does NOT consult the capability matrix.
22
+ */
23
+
24
+ import { normalizeEffort } from './models.js';
25
+
26
+ /**
27
+ * Number of tool-loop turns past which a query is considered "complex"
28
+ * and gets an auto-bump from 'high' to 'max'. Tuned to catch genuine
29
+ * multi-hop work (code refactor across many files, sub-agent coordination)
30
+ * without punishing normal 2–3 tool chat turns.
31
+ */
32
+ export const LONG_LOOP_TURN_THRESHOLD = 8;
33
+
34
+ /**
35
+ * Scenario → default effort mapping. The engine tags each LLM call with
36
+ * a scenario string before invoking `pickEffort()`.
37
+ *
38
+ * Tiers (6 scenarios per architect spec):
39
+ * chat → high (default interactive pair-programming turn)
40
+ * consolidate → max (memory compaction — quality matters, runs once)
41
+ * dream → max (memory maintenance — same rationale)
42
+ * sub_agent → max (coordinator spawns + merges)
43
+ * long_loop → max (auto-bumped when toolLoopTurns >= threshold)
44
+ * recall → low (keyword/tag pre-filter — cheap classifier)
45
+ * light → low (side-queries: summary title, extract pass1)
46
+ *
47
+ * Unknown scenarios fall through to 'high'.
48
+ */
49
+ export const SCENARIO_EFFORT = Object.freeze({
50
+ chat: 'high',
51
+ consolidate: 'max',
52
+ dream: 'max',
53
+ sub_agent: 'max',
54
+ long_loop: 'max',
55
+ recall: 'low',
56
+ light: 'low',
57
+ });
58
+
59
+ /**
60
+ * Pick the effort level for a given query context.
61
+ *
62
+ * Decision order:
63
+ * 1. If userEffort is a valid Effort ('low'|'medium'|'high'|'max'),
64
+ * return it unchanged. This is the explicit override path —
65
+ * `/max` prefix, Settings slider, or API caller.
66
+ * 2. If toolLoopTurns >= LONG_LOOP_TURN_THRESHOLD, upgrade the
67
+ * base scenario to 'long_loop' (→ 'max').
68
+ * 3. Look up SCENARIO_EFFORT[scenario]; unknown → 'high'.
69
+ *
70
+ * @param {object} ctx
71
+ * @param {string} [ctx.scenario='chat'] — Scenario tag; see SCENARIO_EFFORT.
72
+ * @param {number} [ctx.toolLoopTurns=0] — Number of tool-use turns
73
+ * already consumed in the current `query()` call.
74
+ * @param {unknown} [ctx.userEffort=null] — User-supplied override.
75
+ * Invalid values are ignored (fall through to scenario path).
76
+ * @returns {'low'|'medium'|'high'|'max'} Resolved effort. Never null —
77
+ * the adapter/router is responsible for dropping it when the
78
+ * feature flag is off or the model doesn't support thinking.
79
+ */
80
+ export function pickEffort({ scenario = 'chat', toolLoopTurns = 0, userEffort = null } = {}) {
81
+ // 1. Explicit user override wins.
82
+ const normUser = normalizeEffort(userEffort);
83
+ if (normUser) return normUser;
84
+
85
+ // 2. Long-loop auto-bump (only when scenario was a "normal" one).
86
+ // If the scenario is already 'recall' / 'light' (explicitly cheap),
87
+ // we respect the operator's intent and don't bump — those are
88
+ // classifier calls where depth doesn't imply complexity.
89
+ const cheap = scenario === 'recall' || scenario === 'light';
90
+ if (!cheap && typeof toolLoopTurns === 'number' && toolLoopTurns >= LONG_LOOP_TURN_THRESHOLD) {
91
+ return SCENARIO_EFFORT.long_loop;
92
+ }
93
+
94
+ // 3. Scenario table lookup.
95
+ return SCENARIO_EFFORT[scenario] || 'high';
96
+ }
97
+
98
+ /**
99
+ * Parse a user prompt for `/max`, `/high`, `/medium`, `/low` prefix
100
+ * commands. Returns `{ effort, cleanedPrompt }` where cleanedPrompt has
101
+ * the prefix (plus one trailing space) stripped.
102
+ *
103
+ * Red line: only ONE leading prefix is honoured — stacking (`/max /high
104
+ * hello`) just eats the first and leaves the rest untouched.
105
+ *
106
+ * PM decision (task-327): `/max` prefix is retained; skills trigger
107
+ * via `!` or `/skill:` instead to avoid collision.
108
+ *
109
+ * @param {string} prompt
110
+ * @returns {{ effort: 'low'|'medium'|'high'|'max'|null, cleanedPrompt: string }}
111
+ */
112
+ export function parseEffortPrefix(prompt) {
113
+ if (typeof prompt !== 'string') return { effort: null, cleanedPrompt: prompt };
114
+ const m = prompt.match(/^\/(max|high|medium|low)(\s+|$)/);
115
+ if (!m) return { effort: null, cleanedPrompt: prompt };
116
+ const effort = m[1];
117
+ const cleanedPrompt = prompt.slice(m[0].length);
118
+ return { effort, cleanedPrompt };
119
+ }
package/unify/engine.js CHANGED
@@ -25,6 +25,7 @@ import { shouldConsolidate, consolidate } from './memory/consolidate.js';
25
25
  import { buildMemoryInjection } from './memory/layout.js';
26
26
  import { runStopHooks } from './stop-hooks.js';
27
27
  import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
28
+ import { pickEffort, parseEffortPrefix } from './effort.js';
28
29
 
29
30
  /**
30
31
  * task-324 — Turn cap removed.
@@ -424,9 +425,16 @@ export class Engine {
424
425
  * @param {string} params.prompt - The user prompt (required, non-empty).
425
426
  * @param {Array} [params.messages] - Prior conversation messages.
426
427
  * @param {AbortSignal} [params.signal] - Abort signal.
428
+ * @param {'low'|'medium'|'high'|'max'|null} [params.userEffort] -
429
+ * task-327b: explicit per-query effort override (from Settings or
430
+ * API caller). `/max`/`/high`/`/medium`/`/low` prefixes in prompt
431
+ * also set this. Null/invalid → scenario decision tree decides.
432
+ * @param {string} [params.scenario='chat'] - task-327b: scenario tag
433
+ * forwarded to the effort decision tree. See effort.js
434
+ * SCENARIO_EFFORT. Unknown values fall through to 'high'.
427
435
  * @yields {EngineEvent}
428
436
  */
429
- async *query({ prompt, messages = [], signal }) {
437
+ async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat' }) {
430
438
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
431
439
  yield {
432
440
  type: 'error',
@@ -436,6 +444,12 @@ export class Engine {
436
444
  return;
437
445
  }
438
446
 
447
+ // task-327b: `/max` / `/high` / `/medium` / `/low` prefix override.
448
+ // Explicit caller-supplied userEffort wins over the prefix.
449
+ const parsed = parseEffortPrefix(prompt);
450
+ const effectivePrompt = parsed.cleanedPrompt;
451
+ const effectiveUserEffort = userEffort || parsed.effort || null;
452
+
439
453
  // ─── task-325a: engine-owned AbortController ─────────────
440
454
  // We create our own controller for this query run so `engine.abort()`
441
455
  // can trigger cancellation without requiring the caller to hand in a
@@ -468,7 +482,7 @@ export class Engine {
468
482
  const runSignal = abortCtrl.signal;
469
483
 
470
484
  try {
471
- yield* this.#runQuery({ prompt, messages, signal: runSignal });
485
+ yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario });
472
486
  } finally {
473
487
  if (signal) {
474
488
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -486,7 +500,7 @@ export class Engine {
486
500
  * in a try/finally without indenting the whole loop.
487
501
  * @private
488
502
  */
489
- async *#runQuery({ prompt, messages, signal }) {
503
+ async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat' }) {
490
504
 
491
505
  // ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
492
506
  // New layout: always inject Memory Index + user-preferences + project
@@ -522,6 +536,7 @@ export class Engine {
522
536
  const toolDefs = this.#getToolDefs();
523
537
  let turnNumber = 0;
524
538
  let continueTurns = 0; // auto-continue counter
539
+ let toolLoopTurns = 0; // task-327b: tool-use turns for long-loop auto-bump
525
540
  let fullResponseText = '';
526
541
  let currentModel = this.#config.model;
527
542
 
@@ -557,6 +572,10 @@ export class Engine {
557
572
  yield { type: 'turn_start', turnNumber };
558
573
 
559
574
  try {
575
+ // task-327b: resolve effort per-turn so the long-loop auto-bump
576
+ // kicks in once toolLoopTurns crosses the threshold.
577
+ const resolvedEffort = pickEffort({ scenario, toolLoopTurns, userEffort });
578
+
560
579
  // Stream from adapter
561
580
  for await (const event of this.#adapter.stream({
562
581
  model: currentModel,
@@ -564,6 +583,7 @@ export class Engine {
564
583
  messages: [...conversationMessages],
565
584
  tools: toolDefs.length > 0 ? toolDefs : undefined,
566
585
  maxTokens: this.#config.maxOutputTokens || 16384,
586
+ effort: resolvedEffort,
567
587
  signal,
568
588
  })) {
569
589
  switch (event.type) {
@@ -834,6 +854,11 @@ export class Engine {
834
854
 
835
855
  yield { type: 'turn_end', turnNumber, stopReason: 'tool_use' };
836
856
 
857
+ // task-327b: count this as a tool-loop turn. Next iteration's
858
+ // pickEffort() will see the bumped counter and upgrade to 'max'
859
+ // once LONG_LOOP_TURN_THRESHOLD is reached.
860
+ toolLoopTurns++;
861
+
837
862
  // Loop back to call adapter again with tool results
838
863
  }
839
864
  }
package/unify/session.js CHANGED
@@ -269,5 +269,13 @@ export async function loadSession(options = {}) {
269
269
  threadStore: getThreadStore(),
270
270
  status,
271
271
  shutdown,
272
+ // task-325c: user-initiated abort API. Delegates to web-bridge which
273
+ // owns the per-thread AbortController registry (`abortByThread`).
274
+ // Lazy-imported to avoid a hard cycle with web-bridge.js (which already
275
+ // imports this module to call loadSession).
276
+ async abort(opts = {}) {
277
+ const { abortUnifySession } = await import('./web-bridge.js');
278
+ return abortUnifySession(opts);
279
+ },
272
280
  };
273
281
  }
@@ -848,6 +848,90 @@ export async function handleUnifyChat(msg) {
848
848
  }
849
849
  }
850
850
 
851
+ /**
852
+ * task-325c: user-initiated abort of an in-flight Unify query on ONE thread.
853
+ *
854
+ * Cancels the AbortController registered for `msg.threadId` (if any). Silent
855
+ * no-op when the thread has no in-flight round — users clicking Stop on an
856
+ * already-idle thread should not trigger an error bubble. Emits an
857
+ * `unify_aborted` event for UI acknowledgement and a fresh
858
+ * `thread_list_updated` so inflight pills clear immediately.
859
+ *
860
+ * Red line (PM): the `thread_list_updated` event name is preserved; no
861
+ * new per-thread abort signal leaks into `Engine.abort()`'s signature.
862
+ *
863
+ * @param {{ threadId?: string }} msg
864
+ * @returns {{ aborted: string[], all: boolean }}
865
+ */
866
+ export function handleUnifyAbortThread(msg = {}) {
867
+ const aborted = [];
868
+ const threadId = msg && msg.threadId;
869
+ if (threadId) {
870
+ const ctrl = abortByThread.get(threadId);
871
+ if (ctrl) {
872
+ try { ctrl.abort(); } catch { /* best-effort */ }
873
+ abortByThread.delete(threadId);
874
+ aborted.push(threadId);
875
+ }
876
+ }
877
+ sendUnifyEvent({ type: 'unify_aborted', aborted, all: false });
878
+ sendThreadListUpdate();
879
+ return { aborted, all: false };
880
+ }
881
+
882
+ /**
883
+ * task-325c: user-initiated abort of ALL in-flight Unify queries.
884
+ *
885
+ * Iterates every registered controller, aborts it, then clears the map.
886
+ * Always emits `unify_aborted` with `all:true` (even when nothing was
887
+ * running) so the UI can confirm the click landed.
888
+ *
889
+ * @returns {{ aborted: string[], all: boolean }}
890
+ */
891
+ export function handleUnifyAbortAll() {
892
+ const aborted = [];
893
+ for (const [threadId, ctrl] of abortByThread.entries()) {
894
+ try { ctrl.abort(); } catch { /* best-effort */ }
895
+ aborted.push(threadId);
896
+ }
897
+ abortByThread.clear();
898
+ sendUnifyEvent({ type: 'unify_aborted', aborted, all: true });
899
+ sendThreadListUpdate();
900
+ return { aborted, all: true };
901
+ }
902
+
903
+ /**
904
+ * Unified dispatcher bound onto `session.abort({ threadId?, all? })`.
905
+ * Routes to {@link handleUnifyAbortThread} or {@link handleUnifyAbortAll}
906
+ * per input. Kept exported so message-router and tests can call it too.
907
+ *
908
+ * @param {{ threadId?: string, all?: boolean }} [opts]
909
+ */
910
+ export function abortUnifySession(opts = {}) {
911
+ if (opts && opts.all) return handleUnifyAbortAll();
912
+ if (opts && opts.threadId) return handleUnifyAbortThread({ threadId: opts.threadId });
913
+ // No payload — conservative default: abort nothing, just emit ack so
914
+ // callers see the no-op round-trip. Matches PM "don't accidentally
915
+ // nuke everything on a bare click".
916
+ sendUnifyEvent({ type: 'unify_aborted', aborted: [], all: false });
917
+ return { aborted: [], all: false };
918
+ }
919
+
920
+ /**
921
+ * Test-only: seed / inspect the abort registry without spinning up a
922
+ * full session. Never use from production code — the prod registry is
923
+ * managed by handleUnifyChat's per-query controller lifecycle.
924
+ * @private
925
+ */
926
+ export function __testSeedAbortController(threadId, ctrl) {
927
+ abortByThread.set(threadId, ctrl);
928
+ }
929
+
930
+ /** Test-only: returns the set of thread ids currently registered. */
931
+ export function __testGetRegisteredThreadIds() {
932
+ return [...abortByThread.keys()];
933
+ }
934
+
851
935
  /**
852
936
  * Handle mode switch from the web UI.
853
937
  * DEPRECATED (task-297): Unify no longer has chat/work mode distinction.