@yeaft/webchat-agent 0.1.734 → 0.1.738

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
- import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, broadcastLanguageChange } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, broadcastLanguageChange } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -403,6 +403,10 @@ export async function handleMessage(msg) {
403
403
  await handleUnifyLoadHistory(msg);
404
404
  break;
405
405
 
406
+ case 'unify_load_more_history':
407
+ await handleUnifyLoadMoreHistory(msg);
408
+ break;
409
+
406
410
  case 'unify_mode_switch':
407
411
  handleUnifyModeSwitch(msg);
408
412
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.734",
3
+ "version": "0.1.738",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -55,6 +55,20 @@ export function estimateTokens(text) {
55
55
  return Math.ceil(text.length / 4);
56
56
  }
57
57
 
58
+ /**
59
+ * Parse the global monotonic sequence number out of a message id of the
60
+ * form `m####`. Returns NaN for malformed ids. Used by the pagination
61
+ * cursor (`loadOlderByGroup`) to compare ids numerically without having
62
+ * to trust file-system sort order.
63
+ *
64
+ * @param {string} id
65
+ * @returns {number}
66
+ */
67
+ export function parseSeqFromId(id) {
68
+ const m = String(id || '').match(/^m(\d+)$/);
69
+ return m ? parseInt(m[1], 10) : NaN;
70
+ }
71
+
58
72
  // ─── Frontmatter helpers ─────────────────────────────────────
59
73
 
60
74
  /**
@@ -527,6 +541,66 @@ export class ConversationStore {
527
541
  return this.loadRecentByGroup(groupId, Infinity);
528
542
  }
529
543
 
544
+ /**
545
+ * Pagination-cursor read: load the page of `turnsLimit` TURNS that ends
546
+ * just before `beforeSeq` (exclusive) for the given `groupId`. Used by
547
+ * the Unify "Load older messages" UI to walk backwards through history
548
+ * one click at a time.
549
+ *
550
+ * Crucially, this scans BOTH hot (`messages/`) and cold (`cold/`) dirs
551
+ * — `#getNextSeq` is global across both, and `moveToCold` is a `rename`
552
+ * that never reseqs, so cold ids are strictly < hot ids and a flat
553
+ * `[...cold, ...hot]` concat is already chronological. Crossing the
554
+ * hot→cold boundary is therefore transparent to the caller.
555
+ *
556
+ * `hasMore` is computed in TURNS (not raw message count). It's true iff
557
+ * the slice we returned still leaves an earlier turn boundary unread in
558
+ * the filtered prefix — i.e. there's at least one more page to fetch.
559
+ *
560
+ * `pairSanitize` runs as a defensive secondary pass. Turn-boundary cuts
561
+ * are already pair-safe, but historical / hand-edited stores may
562
+ * contain orphan tool_use/tool_result pairs.
563
+ *
564
+ * @param {string} groupId — required; null/empty returns empty result
565
+ * @param {number|null} beforeSeq — exclusive upper bound on message
566
+ * sequence id. Special cases:
567
+ * - `null` / `undefined` / non-finite (e.g. `Infinity`, `NaN`) → start
568
+ * from the newest (no upper bound).
569
+ * - `0` is a VALID finite cutoff that excludes everything (since seqs
570
+ * start at 1). Distinct from `null`. A caller writing
571
+ * `loadOlderByGroup(g, store.firstSeq || 0, ...)` will silently get
572
+ * an empty page — pass `null` if you mean "from newest".
573
+ * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS] — max turns per page
574
+ * @returns {{ messages: object[], oldestSeq: number|null, hasMore: boolean }}
575
+ */
576
+ loadOlderByGroup(groupId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
577
+ if (!groupId) return { messages: [], oldestSeq: null, hasMore: false };
578
+ const hot = this.#loadFromDir(this.#msgDir, Infinity);
579
+ const cold = this.#loadFromDir(this.#coldDir, Infinity);
580
+ // Cold ids strictly < hot ids by construction → chronological concat.
581
+ const all = [...cold, ...hot];
582
+ const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
583
+ const prefix = all.filter(m => m && m.groupId === groupId
584
+ && parseSeqFromId(m.id) < cutoff);
585
+ if (prefix.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
586
+ const sliced = pairSanitize(sliceLastNTurns(prefix, turnsLimit));
587
+ // Turn-based hasMore: there's an EARLIER turn boundary we didn't keep.
588
+ // Compare seqs (not object identity) — pairSanitize / sliceLastNTurns
589
+ // return references today, but a future normalization pass that clones
590
+ // rows would silently flip identity-compare to always-true.
591
+ const oldestSlicedSeq = sliced.length ? parseSeqFromId(sliced[0].id) : NaN;
592
+ const oldestPrefixSeq = parseSeqFromId(prefix[0].id);
593
+ const hasMore = sliced.length > 0
594
+ && Number.isFinite(oldestSlicedSeq)
595
+ && Number.isFinite(oldestPrefixSeq)
596
+ && oldestSlicedSeq > oldestPrefixSeq;
597
+ // Defend the cursor at the source: a malformed id surfaces as NaN here
598
+ // and a NaN cursor would round-trip back as a poison `beforeSeq` that
599
+ // degrades to "give me the newest page again".
600
+ const oldestSeq = Number.isFinite(oldestSlicedSeq) ? oldestSlicedSeq : null;
601
+ return { messages: sliced, oldestSeq, hasMore };
602
+ }
603
+
530
604
  /**
531
605
  * Count hot messages.
532
606
  *
@@ -41,6 +41,50 @@ import { DEFAULT_CONTEXT_WINDOW } from '../models.js';
41
41
  const TOOL_RESULT_CAP_RATIO = 0.10;
42
42
  const TOOL_RESULT_MIN_CAP = 8 * 1024;
43
43
 
44
+ /**
45
+ * Per-tool execution timeout (ms).
46
+ *
47
+ * Without a timeout, a tool whose `execute()` ignores `signal` (or hangs on
48
+ * a network call that doesn't honor AbortSignal) blocks the engine
49
+ * generator's `await this.#toolRegistry.execute(...)` forever. The for-await
50
+ * in the bridge driver never advances → no further events emitted → no
51
+ * `turn_end` → typing dots hang → user sees the conversation "halt" with
52
+ * no terminal event. The bridge's 120s watchdog calls `vpAbort.abort()`
53
+ * but a tool that ignores signal also ignores the abort, so the abort
54
+ * does nothing.
55
+ *
56
+ * Fix: race the tool's promise against a timer. On timeout we throw a
57
+ * loud error — the engine's existing catch (engine.js: tool-execute path)
58
+ * emits `tool_end{isError:true}` and the loop continues normally. Loud
59
+ * failure beats silent stall.
60
+ *
61
+ * 90s is comfortably above the typical tool budget (most tools complete
62
+ * in <1s; bash and web-fetch can run tens of seconds; web-search is
63
+ * usually <10s) but well below the 120s bridge-level watchdog so the
64
+ * tool-level signal fires first and surfaces a useful per-tool diagnosis
65
+ * rather than an opaque "VP stalled" log.
66
+ *
67
+ * Override per-tool by setting `tool.timeoutMs` on the ToolDef. Set to
68
+ * 0 (or a negative number) to disable the timeout for that tool — only
69
+ * use this for legitimately long-running internal tools.
70
+ */
71
+ export const DEFAULT_TOOL_TIMEOUT_MS = 90_000;
72
+
73
+ /**
74
+ * Error thrown when a tool's execute() exceeds its timeout. Carries the
75
+ * tool name + budget so the engine's catch path (and the resulting
76
+ * `tool_end{isError:true}` event) can surface a precise diagnostic to
77
+ * the user instead of a generic stall.
78
+ */
79
+ export class ToolExecutionTimeoutError extends Error {
80
+ constructor(toolName, timeoutMs) {
81
+ super(`Tool "${toolName}" did not complete within ${timeoutMs}ms`);
82
+ this.name = 'ToolExecutionTimeoutError';
83
+ this.toolName = toolName;
84
+ this.timeoutMs = timeoutMs;
85
+ }
86
+ }
87
+
44
88
  /**
45
89
  * Truncate a tool result if it exceeds the per-result cap. Non-string
46
90
  * outputs are JSON-stringified first (matching what engine.js eventually
@@ -76,6 +120,35 @@ export function truncateToolResultIfNeeded(output, { contextWindow, toolName })
76
120
  return head + marker;
77
121
  }
78
122
 
123
+ /**
124
+ * Race a promise against a timer. If the promise resolves first, return its
125
+ * value. If the timer wins, throw {@link ToolExecutionTimeoutError}. The
126
+ * underlying tool promise is intentionally NOT cancelled — JS has no
127
+ * cooperative promise cancellation, so a tool that ignores `signal` will
128
+ * keep running in the background; we just stop waiting on it. The engine
129
+ * sees a clean error and the user sees `tool_end{isError:true}` instead
130
+ * of an indefinite hang.
131
+ *
132
+ * Internal helper — not exported. The default and overrides are managed
133
+ * via {@link DEFAULT_TOOL_TIMEOUT_MS} and `tool.timeoutMs`.
134
+ *
135
+ * @param {Promise<unknown>} promise
136
+ * @param {number} timeoutMs
137
+ * @param {string} toolName
138
+ * @returns {Promise<unknown>}
139
+ */
140
+ function runWithTimeout(promise, timeoutMs, toolName) {
141
+ let timer = null;
142
+ const timeoutPromise = new Promise((_resolve, reject) => {
143
+ timer = setTimeout(() => {
144
+ reject(new ToolExecutionTimeoutError(toolName, timeoutMs));
145
+ }, timeoutMs);
146
+ });
147
+ return Promise.race([promise, timeoutPromise]).finally(() => {
148
+ clearTimeout(timer);
149
+ });
150
+ }
151
+
79
152
  export class ToolRegistry {
80
153
  /** @type {Map<string, import('./types.js').ToolDef>} */
81
154
  #tools = new Map();
@@ -174,7 +247,20 @@ export class ToolRegistry {
174
247
  async execute(name, input, ctx = {}) {
175
248
  const tool = this.#tools.get(name);
176
249
  if (!tool) throw new Error(`Unknown tool: ${name}`);
177
- const output = await tool.execute(input, ctx);
250
+
251
+ // Per-tool timeout. A tool that ignores `signal` and never resolves
252
+ // would otherwise hang the engine's `await this.#toolRegistry.execute(...)`
253
+ // indefinitely — see DEFAULT_TOOL_TIMEOUT_MS docblock for the
254
+ // motivating "silent turn stall" failure. The race throws a typed
255
+ // error on timeout; the engine's existing catch turns it into
256
+ // `tool_end{isError:true}` and the loop continues.
257
+ const rawTimeout = Number.isFinite(tool.timeoutMs) ? tool.timeoutMs : DEFAULT_TOOL_TIMEOUT_MS;
258
+ const useTimeout = rawTimeout > 0;
259
+
260
+ const output = useTimeout
261
+ ? await runWithTimeout(tool.execute(input, ctx), rawTimeout, name)
262
+ : await tool.execute(input, ctx);
263
+
178
264
  return truncateToolResultIfNeeded(output, {
179
265
  contextWindow: ctx.contextWindow,
180
266
  toolName: name,
@@ -68,6 +68,7 @@
68
68
  * isConcurrencySafe?: (input?: object) => boolean,
69
69
  * isReadOnly?: (input?: object) => boolean,
70
70
  * isDestructive?: (input?: object) => boolean,
71
+ * timeoutMs?: number,
71
72
  * }} def
72
73
  * @returns {ToolDef}
73
74
  */
@@ -79,11 +80,12 @@ export function defineTool({
79
80
  isConcurrencySafe = () => false,
80
81
  isReadOnly = () => false,
81
82
  isDestructive = () => false,
83
+ timeoutMs,
82
84
  }) {
83
85
  if (!name) throw new Error('Tool must have a name');
84
86
  if (!execute) throw new Error(`Tool "${name}" must have an execute function`);
85
87
 
86
- return {
88
+ const def = {
87
89
  name,
88
90
  description: description || `Tool: ${name}`,
89
91
  parameters: parameters || { type: 'object', properties: {} },
@@ -92,4 +94,11 @@ export function defineTool({
92
94
  isReadOnly,
93
95
  isDestructive,
94
96
  };
97
+ // Only attach `timeoutMs` when the tool author opts in. Leaving it
98
+ // unset means ToolRegistry.execute uses DEFAULT_TOOL_TIMEOUT_MS — set
99
+ // to <= 0 to disable the per-tool timeout entirely.
100
+ if (Number.isFinite(timeoutMs)) {
101
+ def.timeoutMs = timeoutMs;
102
+ }
103
+ return def;
95
104
  }