@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.
- package/connection/message-router.js +14 -1
- package/package.json +1 -1
- package/unify/effort.js +119 -0
- package/unify/engine.js +28 -3
- package/unify/session.js +8 -0
- package/unify/web-bridge.js +84 -0
|
@@ -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
package/unify/effort.js
ADDED
|
@@ -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
|
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -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.
|