@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.
- package/connection/message-router.js +5 -1
- package/package.json +1 -1
- package/unify/conversation/persist.js +74 -0
- package/unify/tools/registry.js +87 -1
- package/unify/tools/types.js +10 -1
- package/unify/web-bridge.js +232 -119
- package/unify/feature-arc.js +0 -437
- package/unify/quick-response.js +0 -229
|
@@ -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
|
@@ -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
|
*
|
package/unify/tools/registry.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/unify/tools/types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|