@yeaft/webchat-agent 0.1.735 → 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/package.json +1 -1
- package/unify/tools/registry.js +87 -1
- package/unify/tools/types.js +10 -1
- package/unify/web-bridge.js +140 -119
- package/unify/feature-arc.js +0 -437
- package/unify/quick-response.js +0 -229
package/package.json
CHANGED
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
|
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -50,8 +50,6 @@ import {
|
|
|
50
50
|
compactHistory,
|
|
51
51
|
trimSnapshotForBudget,
|
|
52
52
|
} from './history-compact.js';
|
|
53
|
-
import { createFeatureArc } from './feature-arc.js';
|
|
54
|
-
import { getFeatureStore } from './tools/feature-tools.js';
|
|
55
53
|
import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
|
|
56
54
|
import { parseSeqFromId } from './conversation/persist.js';
|
|
57
55
|
|
|
@@ -194,6 +192,42 @@ export function broadcastLanguageChange(language) {
|
|
|
194
192
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
195
193
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
196
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Secondary watchdog grace period (ms).
|
|
197
|
+
*
|
|
198
|
+
* After {@link QUERY_TIMEOUT_MS} of silence the per-VP `vpAbort` is fired.
|
|
199
|
+
* That's enough on its own when adapters / tools cooperate with the
|
|
200
|
+
* AbortSignal — the engine throws `AbortError`, runVpTurn's catch emits
|
|
201
|
+
* `result{stopped:true}`, the driver `finally` emits `vp_typing_end`, and
|
|
202
|
+
* the user is unstuck.
|
|
203
|
+
*
|
|
204
|
+
* If a tool ignores `signal` and never resolves, the engine generator's
|
|
205
|
+
* `await tool.execute(...)` is permanently blocked: the abort fires on a
|
|
206
|
+
* controller it never observes, and runVpTurn never returns. The same
|
|
207
|
+
* applies to an adapter `stream()` that ignores `signal` (e.g. a stuck
|
|
208
|
+
* SSE connection) or to tools that legitimately opt out of the per-tool
|
|
209
|
+
* timeout via `timeoutMs <= 0`. The per-tool timeout in
|
|
210
|
+
* {@link import('./tools/registry.js').DEFAULT_TOOL_TIMEOUT_MS}
|
|
211
|
+
* is the primary cure for the tool-ignore-signal case; this bridge-level
|
|
212
|
+
* escalation strictly extends it to cover the adapter and opt-out cases.
|
|
213
|
+
* Without a second-stage escalation the typing dots hang forever —
|
|
214
|
+
* exactly the "halts mid-execution with no turn_end" symptom.
|
|
215
|
+
*
|
|
216
|
+
* The driver loop wraps `await runVpTurn(...)` in a Promise.race against
|
|
217
|
+
* this grace-window timer. If runVpTurn doesn't return within
|
|
218
|
+
* QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS, the driver forces its
|
|
219
|
+
* `finally` block (vp_typing_end + group_message), emits a synthetic
|
|
220
|
+
* `result{stopped:true}` so the frontend leaves its in-flight state,
|
|
221
|
+
* and moves on. The hung tool promise leaks (JS lacks cooperative
|
|
222
|
+
* promise cancellation) but the user-facing turn is closed.
|
|
223
|
+
*
|
|
224
|
+
* 15s is wide enough that legitimate "abort took a moment to propagate"
|
|
225
|
+
* paths (network teardown, finally cleanup) finish first; tight enough
|
|
226
|
+
* that a truly stuck tool doesn't stretch the user-visible stall to
|
|
227
|
+
* minutes.
|
|
228
|
+
*/
|
|
229
|
+
const ESCALATE_AFTER_ABORT_MS = 15_000;
|
|
230
|
+
|
|
197
231
|
/** Virtual conversationId for the Unify session */
|
|
198
232
|
let unifyConversationId = null;
|
|
199
233
|
|
|
@@ -424,7 +458,7 @@ function ensureDriverRunning(groupId, vpId) {
|
|
|
424
458
|
} catch { /* never crash WS pipeline */ }
|
|
425
459
|
|
|
426
460
|
try {
|
|
427
|
-
await
|
|
461
|
+
await runVpTurnWithEscalation({
|
|
428
462
|
prompt,
|
|
429
463
|
promptParts,
|
|
430
464
|
groupId,
|
|
@@ -910,15 +944,18 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
910
944
|
*/
|
|
911
945
|
function handleEngineEvent(event, hctx) {
|
|
912
946
|
hctx.resetQueryTimer();
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
|
|
947
|
+
// Sub-agent events may carry their own `featureId` (stamped by the
|
|
948
|
+
// sub-agent runner from the parent's inbound feature scope). Plain
|
|
949
|
+
// VP-turn events have no featureId — auto-feature creation was
|
|
950
|
+
// removed when Track-A / FeatureArc was deleted (2026-05-08).
|
|
951
|
+
const eventFeatureId = typeof event === 'object' && event && typeof event.featureId === 'string'
|
|
952
|
+
? event.featureId
|
|
953
|
+
: null;
|
|
917
954
|
const envelope = {
|
|
918
955
|
groupId: hctx.groupId,
|
|
919
956
|
vpId: hctx.vpId,
|
|
920
957
|
turnId: hctx.turnId,
|
|
921
|
-
...(
|
|
958
|
+
...(eventFeatureId ? { featureId: eventFeatureId } : {}),
|
|
922
959
|
};
|
|
923
960
|
|
|
924
961
|
switch (event.type) {
|
|
@@ -1672,6 +1709,94 @@ async function ensureSessionLoaded() {
|
|
|
1672
1709
|
sendGroupSnapshotBroadcast();
|
|
1673
1710
|
}
|
|
1674
1711
|
|
|
1712
|
+
/**
|
|
1713
|
+
* Wrap {@link runVpTurn} with a hard escalation deadline.
|
|
1714
|
+
*
|
|
1715
|
+
* The first-line defense is the in-turn watchdog inside runVpTurn: at
|
|
1716
|
+
* {@link QUERY_TIMEOUT_MS} of silence it calls `vpAbort.abort()`. When
|
|
1717
|
+
* adapters and tools cooperate with AbortSignal that's enough — the
|
|
1718
|
+
* engine throws AbortError, the catch handler emits `result{stopped:true}`,
|
|
1719
|
+
* and the driver's `finally` emits `vp_typing_end`.
|
|
1720
|
+
*
|
|
1721
|
+
* This wrapper is the second-line defense for the "tool ignores signal"
|
|
1722
|
+
* failure mode. If runVpTurn doesn't return within
|
|
1723
|
+
* QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS we synthesize a clean exit:
|
|
1724
|
+
* emit a synthetic `result{stopped:true}` so the frontend leaves its
|
|
1725
|
+
* in-flight state, log loudly so operators know a tool is stuck, and
|
|
1726
|
+
* resolve. The hung promise leaks (the engine generator is permanently
|
|
1727
|
+
* blocked on a tool that ignores cancellation) but the user-facing turn
|
|
1728
|
+
* is closed and the next message can flow. Resolving the wrapper is
|
|
1729
|
+
* preferred over rejecting because the driver's catch already logs a
|
|
1730
|
+
* warning — we want a single, unambiguous "watchdog escalated" line in
|
|
1731
|
+
* the log instead of layered noise.
|
|
1732
|
+
*
|
|
1733
|
+
* Tool-level timeouts (see registry.js DEFAULT_TOOL_TIMEOUT_MS) are the
|
|
1734
|
+
* real cure: this wrapper should rarely fire because no tool should be
|
|
1735
|
+
* able to block longer than its budget. It exists as belt-and-suspenders
|
|
1736
|
+
* for tools that legitimately disable timeouts (long-running internal
|
|
1737
|
+
* helpers) or for adapter implementations that ignore signal.
|
|
1738
|
+
*/
|
|
1739
|
+
async function runVpTurnWithEscalation(args) {
|
|
1740
|
+
const { groupId, vpId, turnId } = args;
|
|
1741
|
+
const deadlineMs = QUERY_TIMEOUT_MS + ESCALATE_AFTER_ABORT_MS;
|
|
1742
|
+
await raceWithEscalation(runVpTurn(args), {
|
|
1743
|
+
deadlineMs,
|
|
1744
|
+
onEscalate: () => {
|
|
1745
|
+
console.error(
|
|
1746
|
+
`[Unify] runVpTurn watchdog escalation: VP ${vpId} did not return ${deadlineMs}ms after enqueue — emitting synthetic stop and unblocking driver`,
|
|
1747
|
+
);
|
|
1748
|
+
try {
|
|
1749
|
+
sendUnifyOutput(
|
|
1750
|
+
{ type: 'result', result_text: '', stopped: true },
|
|
1751
|
+
{ groupId, vpId, turnId },
|
|
1752
|
+
);
|
|
1753
|
+
} catch { /* never crash WS pipeline */ }
|
|
1754
|
+
},
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Race `inner` against a deadline timer. If `inner` resolves/rejects first,
|
|
1760
|
+
* the timer is cleared and the result of `inner` is returned. If the timer
|
|
1761
|
+
* wins, `onEscalate` is called and the wrapper resolves cleanly — the inner
|
|
1762
|
+
* promise is left dangling (JS has no promise cancellation) but the caller
|
|
1763
|
+
* is unblocked.
|
|
1764
|
+
*
|
|
1765
|
+
* `onEscalate` MUST be synchronous. We swallow synchronous throws so a
|
|
1766
|
+
* torn-down WS pipeline can't crash the watchdog, but a Promise rejection
|
|
1767
|
+
* from an async `onEscalate` would leak past this `catch`.
|
|
1768
|
+
*
|
|
1769
|
+
* Pure helper, no module-level state, exported as `__testRaceWithEscalation`
|
|
1770
|
+
* so the contract can be unit-tested in isolation. Inner errors propagate
|
|
1771
|
+
* (a tool that throws still surfaces through `runVpTurn`'s normal catch).
|
|
1772
|
+
*
|
|
1773
|
+
* @template T
|
|
1774
|
+
* @param {Promise<T>} inner
|
|
1775
|
+
* @param {{ deadlineMs: number, onEscalate: () => void }} opts
|
|
1776
|
+
* @returns {Promise<T|void>}
|
|
1777
|
+
*/
|
|
1778
|
+
async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
|
|
1779
|
+
let escalateTimer = null;
|
|
1780
|
+
const escalation = new Promise((resolve) => {
|
|
1781
|
+
escalateTimer = setTimeout(() => {
|
|
1782
|
+
try { onEscalate(); } catch { /* never throw out of the watchdog */ }
|
|
1783
|
+
resolve();
|
|
1784
|
+
}, deadlineMs);
|
|
1785
|
+
// `unref()` lets a pending escalation timer not hold the Node event
|
|
1786
|
+
// loop open (e.g. during graceful shutdown). Browsers / non-Node
|
|
1787
|
+
// runtimes don't expose it, hence the typeof guard. Node's own
|
|
1788
|
+
// `Timeout.unref()` does not throw, so no try/catch is needed.
|
|
1789
|
+
if (escalateTimer && typeof escalateTimer.unref === 'function') {
|
|
1790
|
+
escalateTimer.unref();
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
try {
|
|
1794
|
+
return await Promise.race([inner, escalation]);
|
|
1795
|
+
} finally {
|
|
1796
|
+
clearTimeout(escalateTimer);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1675
1800
|
/**
|
|
1676
1801
|
* Run a single VP's turn: call engine.query() with the supplied prompt and
|
|
1677
1802
|
* coordinator-bound router, stream events to the frontend, and append the
|
|
@@ -1697,11 +1822,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1697
1822
|
if (!prompt?.trim()) return;
|
|
1698
1823
|
|
|
1699
1824
|
const envelope = { groupId, vpId, turnId };
|
|
1700
|
-
// Arc is declared at outer-try scope so the catch / finally branches
|
|
1701
|
-
// below can call `arc.finalize({status:'aborted'|'error'})` after a
|
|
1702
|
-
// throw escaping the inner try. It's null until the inner try
|
|
1703
|
-
// populates it; all catch-side calls guard with `arc?.finalize?.`.
|
|
1704
|
-
let arc = null;
|
|
1705
1825
|
|
|
1706
1826
|
try {
|
|
1707
1827
|
if (session?.dreamScheduler) {
|
|
@@ -1727,12 +1847,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1727
1847
|
const assistantTextParts = [];
|
|
1728
1848
|
const toolCallsAccum = [];
|
|
1729
1849
|
const toolResultsAccum = [];
|
|
1730
|
-
// PR-4 (review fix): hoist `vpEngine` so the `finally` can clear
|
|
1731
|
-
// the per-turn featureId accessor on the SAME engine instance
|
|
1732
|
-
// we installed it on — even if the VP was kicked or its group
|
|
1733
|
-
// deleted mid-turn (both code paths call `vpEngines.delete(...)`).
|
|
1734
|
-
// Calling `getOrCreateVpEngine` again from `finally` would
|
|
1735
|
-
// resurrect a zombie engine for a VP that no longer exists.
|
|
1736
1850
|
let vpEngine = null;
|
|
1737
1851
|
|
|
1738
1852
|
// task-707: per-VP engine + persistent group coord. The coord is
|
|
@@ -1749,74 +1863,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1749
1863
|
envelope: inboundEnvelope,
|
|
1750
1864
|
});
|
|
1751
1865
|
|
|
1752
|
-
// ── Dual-track feature arc ──
|
|
1753
|
-
// Track A (quick-response) runs concurrently against the same
|
|
1754
|
-
// primary model with a non-looping single call; its preview is
|
|
1755
|
-
// surfaced to the user immediately via `quick_preview` so they
|
|
1756
|
-
// see *something* within ~1s. Three signals (Track A intent,
|
|
1757
|
-
// ≥3 engine loops, key tool call) auto-create a Feature record
|
|
1758
|
-
// and the wire envelope starts tagging emits with `featureId`,
|
|
1759
|
-
// letting the frontend fold subsequent messages into a pill.
|
|
1760
|
-
arc = createFeatureArc({
|
|
1761
|
-
adapter: session?.adapter || null,
|
|
1762
|
-
model: session?.config?.model || null,
|
|
1763
|
-
featureStore: getFeatureStore(),
|
|
1764
|
-
prompt,
|
|
1765
|
-
vpId,
|
|
1766
|
-
groupId: groupId || null,
|
|
1767
|
-
turnId,
|
|
1768
|
-
vpDisplayName: queryOpts?.vpPersona?.displayName || vpId,
|
|
1769
|
-
language: session?.config?.language || 'en',
|
|
1770
|
-
signal: vpAbort.signal,
|
|
1771
|
-
emit: {
|
|
1772
|
-
quickPreview: ({ intent, preview }) => {
|
|
1773
|
-
sendUnifyEvent({
|
|
1774
|
-
type: 'quick_preview',
|
|
1775
|
-
intent,
|
|
1776
|
-
preview,
|
|
1777
|
-
vpId,
|
|
1778
|
-
turnId,
|
|
1779
|
-
}, envelope);
|
|
1780
|
-
},
|
|
1781
|
-
featureStarted: ({ featureId, title, trigger, toolName }) => {
|
|
1782
|
-
sendUnifyEvent({
|
|
1783
|
-
type: 'feature_started',
|
|
1784
|
-
featureId,
|
|
1785
|
-
title,
|
|
1786
|
-
trigger, // 'quick' | 'turns' | 'tool'
|
|
1787
|
-
toolName: toolName || null,
|
|
1788
|
-
vpId,
|
|
1789
|
-
turnId,
|
|
1790
|
-
}, { ...envelope, featureId });
|
|
1791
|
-
},
|
|
1792
|
-
featureCompleted: ({ featureId, summary, status }) => {
|
|
1793
|
-
sendUnifyEvent({
|
|
1794
|
-
type: 'feature_completed',
|
|
1795
|
-
featureId,
|
|
1796
|
-
summary,
|
|
1797
|
-
status, // 'completed' | 'aborted' | 'error'
|
|
1798
|
-
vpId,
|
|
1799
|
-
turnId,
|
|
1800
|
-
}, { ...envelope, featureId });
|
|
1801
|
-
},
|
|
1802
|
-
},
|
|
1803
|
-
});
|
|
1804
|
-
// Fire-and-forget — Track A produces its preview / decision when
|
|
1805
|
-
// ready; the main engine loop must not be held back waiting for it.
|
|
1806
|
-
arc.startTrackA();
|
|
1807
|
-
|
|
1808
|
-
// PR-4: let sub-agents spawned during this turn inherit the
|
|
1809
|
-
// parent's active featureId. Read lazily inside the engine's
|
|
1810
|
-
// parentEngineDeps so a feature that opens AFTER a sub-agent
|
|
1811
|
-
// spawns still tags the sub-agent's later events. Cleared in the
|
|
1812
|
-
// `finally` below so a stale `arc` reference doesn't leak into
|
|
1813
|
-
// the next turn.
|
|
1814
1866
|
vpEngine = getOrCreateVpEngine(groupId, vpId);
|
|
1815
|
-
if (typeof vpEngine.setCurrentFeatureIdAccessor === 'function') {
|
|
1816
|
-
vpEngine.setCurrentFeatureIdAccessor(() => {
|
|
1817
|
-
try { return arc?.getFeatureId?.() || null; } catch { return null; }
|
|
1818
|
-
});
|
|
1819
|
-
}
|
|
1820
1867
|
|
|
1821
1868
|
const handlerCtx = {
|
|
1822
1869
|
assistantTextParts,
|
|
@@ -1826,9 +1873,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1826
1873
|
groupId,
|
|
1827
1874
|
vpId,
|
|
1828
1875
|
turnId,
|
|
1829
|
-
// Lets handleEngineEvent stamp the latest featureId on each
|
|
1830
|
-
// outgoing envelope; the arc may publish it mid-turn.
|
|
1831
|
-
getFeatureId: () => arc.getFeatureId(),
|
|
1832
1876
|
};
|
|
1833
1877
|
// Always trim the snapshot before passing to engine.query. This is
|
|
1834
1878
|
// the second-line defense (history-compact only fires above 30K
|
|
@@ -1853,26 +1897,12 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1853
1897
|
...queryOpts,
|
|
1854
1898
|
})) {
|
|
1855
1899
|
resetQueryTimer();
|
|
1856
|
-
// Arc observes BEFORE dispatch so featureId (if just published)
|
|
1857
|
-
// is available when handleEngineEvent stamps the envelope.
|
|
1858
|
-
try { arc.observeEvent(event); } catch (err) {
|
|
1859
|
-
console.warn('[FeatureArc] observe failed:', err?.message || err);
|
|
1860
|
-
}
|
|
1861
1900
|
handleEngineEvent(event, handlerCtx);
|
|
1862
1901
|
}
|
|
1863
1902
|
|
|
1864
1903
|
// Turn completed — atomically append this VP's output to shared history.
|
|
1865
1904
|
appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
|
|
1866
1905
|
|
|
1867
|
-
// Close the arc: if a feature was opened during the turn, run the
|
|
1868
|
-
// summary call and write status='completed' back to FeatureStore.
|
|
1869
|
-
// Awaited so the `feature_completed` event reaches the frontend
|
|
1870
|
-
// before the final 'result' bubble (UI ordering matters: the pill
|
|
1871
|
-
// should reach its done state before the turn is marked done).
|
|
1872
|
-
try { await arc.finalize({ status: 'completed' }); } catch (err) {
|
|
1873
|
-
console.warn('[FeatureArc] finalize failed:', err?.message || err);
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
1906
|
sendUnifyOutput({
|
|
1877
1907
|
type: 'assistant',
|
|
1878
1908
|
message: { content: [] },
|
|
@@ -1883,25 +1913,10 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1883
1913
|
}, envelope);
|
|
1884
1914
|
} finally {
|
|
1885
1915
|
if (queryTimer) clearTimeout(queryTimer);
|
|
1886
|
-
// PR-4 (review fix): clear the accessor on the SAME engine
|
|
1887
|
-
// instance we installed it on. Reusing the captured reference
|
|
1888
|
-
// (instead of calling getOrCreateVpEngine again) avoids
|
|
1889
|
-
// resurrecting a zombie engine if the VP/group was torn down
|
|
1890
|
-
// mid-turn. `vpEngine` is null only when the install path threw
|
|
1891
|
-
// before the engine lookup (very early failure) — in that case
|
|
1892
|
-
// there's nothing to clear.
|
|
1893
|
-
try {
|
|
1894
|
-
if (vpEngine && typeof vpEngine.setCurrentFeatureIdAccessor === 'function') {
|
|
1895
|
-
vpEngine.setCurrentFeatureIdAccessor(null);
|
|
1896
|
-
}
|
|
1897
|
-
} catch { /* best-effort */ }
|
|
1898
1916
|
}
|
|
1899
1917
|
} catch (err) {
|
|
1900
1918
|
const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
|
|
1901
1919
|
if (isAbort) {
|
|
1902
|
-
// Best-effort close: mark the feature aborted so the frontend pill
|
|
1903
|
-
// settles into the right terminal state instead of staying active.
|
|
1904
|
-
try { await arc?.finalize?.({ status: 'aborted' }); } catch { /* ignore */ }
|
|
1905
1920
|
sendUnifyOutput({
|
|
1906
1921
|
type: 'result',
|
|
1907
1922
|
result_text: '',
|
|
@@ -1911,7 +1926,6 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1911
1926
|
}
|
|
1912
1927
|
|
|
1913
1928
|
console.error('[Unify] query error:', err);
|
|
1914
|
-
try { await arc?.finalize?.({ status: 'error' }); } catch { /* ignore */ }
|
|
1915
1929
|
|
|
1916
1930
|
if (isPermissionErrorMsg(err.message)) {
|
|
1917
1931
|
if (!_permissionDiagnosticSent) {
|
|
@@ -2322,6 +2336,13 @@ export function __testGetRegisteredThreadIds() {
|
|
|
2322
2336
|
return currentAbortCtrl && !currentAbortCtrl.signal.aborted ? ['main'] : [];
|
|
2323
2337
|
}
|
|
2324
2338
|
|
|
2339
|
+
/**
|
|
2340
|
+
* Test-only: expose the bridge-level escalation helper. Lets tests verify
|
|
2341
|
+
* the "tool ignored signal → wrapper escalates" contract without booting a
|
|
2342
|
+
* full session. See `test/agent/unify/web-bridge-escalation.test.js`.
|
|
2343
|
+
*/
|
|
2344
|
+
export const __testRaceWithEscalation = raceWithEscalation;
|
|
2345
|
+
|
|
2325
2346
|
/**
|
|
2326
2347
|
* Manual dream trigger from VP detail page.
|
|
2327
2348
|
*/
|
package/unify/feature-arc.js
DELETED
|
@@ -1,437 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* feature-arc.js — Tracks a single VP turn's "is this heavy work?" arc.
|
|
3
|
-
*
|
|
4
|
-
* Design rationale
|
|
5
|
-
* ----------------
|
|
6
|
-
* The Unify group chat used to dump every tool call from every VP into
|
|
7
|
-
* one scrolling feed, which made anything beyond a one-shot Q&A
|
|
8
|
-
* unreadable. The fix is dual-layer:
|
|
9
|
-
*
|
|
10
|
-
* group chat → user prompt + a *pill* per heavy turn
|
|
11
|
-
* (active: "🔧 [vp] doing X…", done: "✅ [vp] X — summary")
|
|
12
|
-
* detail panel → the full unflattened timeline of whichever VP is
|
|
13
|
-
* selected
|
|
14
|
-
*
|
|
15
|
-
* For pills to exist we need to know **which VP turns are heavy**. The
|
|
16
|
-
* triage runs out of three signals (any-of):
|
|
17
|
-
*
|
|
18
|
-
* 1. Track A (quick-response) returned `intent: 'feature'`
|
|
19
|
-
* 2. Track B (main engine) has cycled ≥ FEATURE_TURN_THRESHOLD loops
|
|
20
|
-
* 3. Track B called any tool on KEY_TOOLS (work tools — bash, edits,
|
|
21
|
-
* sub-agent spawn, grep/find/glob — *not* pure read or web search)
|
|
22
|
-
*
|
|
23
|
-
* When any signal fires, this arc:
|
|
24
|
-
* - calls FeatureStore.create() with title := preview (or fallback)
|
|
25
|
-
* - stamps a `currentFeatureId` on the runVpTurn ctx so subsequent
|
|
26
|
-
* emits get featureId on their wire envelope
|
|
27
|
-
* - notifies the wire layer via a `feature_started` event so the
|
|
28
|
-
* frontend knows to fold prior messages into a pill
|
|
29
|
-
* - on turn close, runs a one-shot summarisation call against the
|
|
30
|
-
* accumulated assistant text, then writes status='completed' +
|
|
31
|
-
* result back through FeatureStore.update()
|
|
32
|
-
*
|
|
33
|
-
* The arc is **strictly additive** — it does not mutate engine state,
|
|
34
|
-
* does not consume engine events the dispatcher needs, and silently
|
|
35
|
-
* no-ops on any failure (logging only). A broken FeatureArc must never
|
|
36
|
-
* break the user's turn.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
import { runQuickResponse } from './quick-response.js';
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Tools that strongly signal "doing real work". Single-file Read and
|
|
43
|
-
* web search are intentionally excluded — they show up in trivial Q&A
|
|
44
|
-
* (one quick lookup, one fact check) and would over-trigger the pill.
|
|
45
|
-
*
|
|
46
|
-
* Codebase grep/glob/find are *included* because they signal multi-file
|
|
47
|
-
* investigation, which is exactly the "this got heavy" mode we want to
|
|
48
|
-
* surface to the user.
|
|
49
|
-
*
|
|
50
|
-
* @type {Set<string>}
|
|
51
|
-
*/
|
|
52
|
-
export const KEY_TOOLS = new Set([
|
|
53
|
-
'Bash',
|
|
54
|
-
'FileEdit',
|
|
55
|
-
'FileWrite',
|
|
56
|
-
'FileCreate',
|
|
57
|
-
'ApplyPatch',
|
|
58
|
-
'NotebookEdit',
|
|
59
|
-
'Agent',
|
|
60
|
-
'Grep',
|
|
61
|
-
'Glob',
|
|
62
|
-
'Find',
|
|
63
|
-
'JsRepl',
|
|
64
|
-
]);
|
|
65
|
-
|
|
66
|
-
/** Track-B loop count that on its own counts as "this got heavy". */
|
|
67
|
-
export const FEATURE_TURN_THRESHOLD = 3;
|
|
68
|
-
|
|
69
|
-
/** Cap title length we store on the Feature. */
|
|
70
|
-
const TITLE_MAX = 60;
|
|
71
|
-
/** Cap summary stored on completion. */
|
|
72
|
-
const SUMMARY_MAX = 600;
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Build a one-shot system prompt asking the LLM to summarise what it
|
|
76
|
-
* just did in 1–3 sentences. Bilingual.
|
|
77
|
-
*
|
|
78
|
-
* @param {string} language
|
|
79
|
-
*/
|
|
80
|
-
function buildSummarySystem(language = 'en') {
|
|
81
|
-
const isZh = String(language || '').toLowerCase().startsWith('zh');
|
|
82
|
-
if (isZh) {
|
|
83
|
-
return [
|
|
84
|
-
'你刚刚完成了一段工作。请用中文写一条 1–3 句的总结,告诉用户你做了什么、关键结果是什么。',
|
|
85
|
-
'只输出总结正文,不要 markdown,不要前缀(如「总结:」),不超过 600 字符。',
|
|
86
|
-
].join('\n');
|
|
87
|
-
}
|
|
88
|
-
return [
|
|
89
|
-
'You just finished a piece of work. Write a 1–3 sentence summary in English describing what you did and the key outcome.',
|
|
90
|
-
'Output only the summary prose. No markdown, no leading label like "Summary:", at most 600 characters.',
|
|
91
|
-
].join('\n');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Drive one summary call. Fails-soft: returns '' on any error.
|
|
96
|
-
*
|
|
97
|
-
* @param {{
|
|
98
|
-
* adapter: object,
|
|
99
|
-
* model: string,
|
|
100
|
-
* prompt: string, // user's original prompt — supplied as context
|
|
101
|
-
* assistantText: string, // joined VP text output for this turn
|
|
102
|
-
* language?: string,
|
|
103
|
-
* signal?: AbortSignal,
|
|
104
|
-
* }} args
|
|
105
|
-
* @returns {Promise<string>}
|
|
106
|
-
*/
|
|
107
|
-
async function runSummaryCall({ adapter, model, prompt, assistantText, language, signal }) {
|
|
108
|
-
if (!adapter || typeof adapter.stream !== 'function') return '';
|
|
109
|
-
if (!model) return '';
|
|
110
|
-
const text = (assistantText || '').trim();
|
|
111
|
-
if (!text) return '';
|
|
112
|
-
const system = buildSummarySystem(language);
|
|
113
|
-
// We feed the model BOTH the user request and what we said back, so
|
|
114
|
-
// a summary like "Looked at auth.js, found X, fixed it" is grounded.
|
|
115
|
-
const userMsg = [
|
|
116
|
-
'USER REQUEST:',
|
|
117
|
-
String(prompt || '').slice(0, 4000),
|
|
118
|
-
'',
|
|
119
|
-
'WHAT YOU DID / SAID:',
|
|
120
|
-
text.slice(0, 8000),
|
|
121
|
-
].join('\n');
|
|
122
|
-
try {
|
|
123
|
-
const parts = [];
|
|
124
|
-
for await (const evt of adapter.stream({
|
|
125
|
-
model,
|
|
126
|
-
system,
|
|
127
|
-
messages: [{ role: 'user', content: userMsg }],
|
|
128
|
-
maxTokens: 400,
|
|
129
|
-
signal,
|
|
130
|
-
})) {
|
|
131
|
-
if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
|
|
132
|
-
parts.push(evt.text);
|
|
133
|
-
} else if (evt && evt.type === 'error') {
|
|
134
|
-
return '';
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return parts.join('').replace(/\s+/g, ' ').trim().slice(0, SUMMARY_MAX);
|
|
138
|
-
} catch {
|
|
139
|
-
return '';
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Make a feature title from preview / prompt. Strips trailing
|
|
145
|
-
* punctuation and clamps length.
|
|
146
|
-
*/
|
|
147
|
-
function makeTitle({ preview, prompt }) {
|
|
148
|
-
const src = (preview || prompt || '').replace(/\s+/g, ' ').trim();
|
|
149
|
-
if (!src) return '(untitled task)';
|
|
150
|
-
const clipped = src.slice(0, TITLE_MAX);
|
|
151
|
-
return clipped.replace(/[.!?。!?…]+$/u, '').trim() || clipped;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* @typedef {Object} FeatureArcEmits
|
|
156
|
-
* @property {(payload:{intent:'quick'|'feature', preview:string})=>void}
|
|
157
|
-
* [quickPreview] — Track A finished
|
|
158
|
-
* @property {(payload:{featureId:string, title:string, trigger:'quick'|'turns'|'tool', toolName?:string})=>void}
|
|
159
|
-
* [featureStarted] — auto-create fired, frontend should fold
|
|
160
|
-
* @property {(payload:{featureId:string, summary:string, status:'completed'|'aborted'|'error'})=>void}
|
|
161
|
-
* [featureCompleted] — turn ended, pill becomes done state
|
|
162
|
-
*
|
|
163
|
-
* @typedef {Object} FeatureArcDeps
|
|
164
|
-
* @property {object|null} adapter — LLMAdapter (session.adapter)
|
|
165
|
-
* @property {string|null} model — primaryModel
|
|
166
|
-
* @property {object|null} featureStore — FeatureStore instance (singleton)
|
|
167
|
-
* @property {string} prompt — user prompt that opened the turn
|
|
168
|
-
* @property {string} vpId
|
|
169
|
-
* @property {string|null} groupId
|
|
170
|
-
* @property {string} turnId
|
|
171
|
-
* @property {string} [vpDisplayName]
|
|
172
|
-
* @property {string} [language]
|
|
173
|
-
* @property {AbortSignal} [signal]
|
|
174
|
-
* @property {FeatureArcEmits} [emit]
|
|
175
|
-
* @property {Set<string>} [keyTools] — override for tests
|
|
176
|
-
* @property {number} [turnThreshold] — override for tests
|
|
177
|
-
*/
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Create a per-VP-turn arc tracker. Caller MUST:
|
|
181
|
-
* - call `arc.startTrackA()` once at the very beginning of runVpTurn
|
|
182
|
-
* (returns a Promise that backgrounds; do NOT await)
|
|
183
|
-
* - call `arc.observeEvent(event)` for every engine event before
|
|
184
|
-
* dispatching it to the existing handleEngineEvent
|
|
185
|
-
* - call `arc.collectAssistantText(chunk)` whenever a text_delta is
|
|
186
|
-
* forwarded (lets us seed the summary call without re-aggregating)
|
|
187
|
-
* - call `await arc.finalize({status})` AFTER the engine query
|
|
188
|
-
* generator drains, before sending the final 'result'.
|
|
189
|
-
*
|
|
190
|
-
* The arc is opinionated about ordering: featureId is published only
|
|
191
|
-
* once, the first time any signal fires. Subsequent fires are no-ops.
|
|
192
|
-
*
|
|
193
|
-
* @param {FeatureArcDeps} deps
|
|
194
|
-
*/
|
|
195
|
-
export function createFeatureArc(deps = {}) {
|
|
196
|
-
const {
|
|
197
|
-
adapter = null,
|
|
198
|
-
model = null,
|
|
199
|
-
featureStore = null,
|
|
200
|
-
prompt = '',
|
|
201
|
-
vpId,
|
|
202
|
-
groupId = null,
|
|
203
|
-
turnId,
|
|
204
|
-
vpDisplayName,
|
|
205
|
-
language,
|
|
206
|
-
signal,
|
|
207
|
-
emit = {},
|
|
208
|
-
keyTools = KEY_TOOLS,
|
|
209
|
-
turnThreshold = FEATURE_TURN_THRESHOLD,
|
|
210
|
-
} = deps;
|
|
211
|
-
|
|
212
|
-
let trackAResult = null; // {intent, preview} | null
|
|
213
|
-
let trackADone = false;
|
|
214
|
-
let featureId = null;
|
|
215
|
-
let featureTitle = null;
|
|
216
|
-
let assistantText = ''; // accumulated for summary call
|
|
217
|
-
let loopCount = 0; // 'turn_open'/'loop'/'reflection' increments
|
|
218
|
-
let _finalised = false;
|
|
219
|
-
|
|
220
|
-
/** Internal: try to fire the auto-create. Idempotent. */
|
|
221
|
-
function maybeCreateFeature(signalKind, extra = {}) {
|
|
222
|
-
// Race guard: Track A is fire-and-forget, so it can resolve AFTER
|
|
223
|
-
// the engine generator has drained and finalize() has already
|
|
224
|
-
// closed the arc. Without this guard a late Track A would publish
|
|
225
|
-
// `feature_started` *after* `feature_completed` (or worse, with
|
|
226
|
-
// no `feature_completed` at all), leaving a dangling-active pill
|
|
227
|
-
// on the frontend.
|
|
228
|
-
if (_finalised) return;
|
|
229
|
-
if (featureId) return; // already created
|
|
230
|
-
if (!featureStore || typeof featureStore.create !== 'function') {
|
|
231
|
-
// No store — at least publish a synthetic id so the frontend can
|
|
232
|
-
// still render a pill. Use a deterministic prefix so it's obvious
|
|
233
|
-
// when something is wrong.
|
|
234
|
-
featureId = `feat-local-${turnId}`;
|
|
235
|
-
} else {
|
|
236
|
-
try {
|
|
237
|
-
// Use the FULL UUID / random-string. A previous version
|
|
238
|
-
// sliced to 8 chars (32 bits of entropy) — collisions in a
|
|
239
|
-
// multi-VP group ingest were observed because the
|
|
240
|
-
// Date.now()/random fallback's first chars are dominated by
|
|
241
|
-
// the ms-precision timestamp, so two VPs in the same
|
|
242
|
-
// millisecond would hash to the same 8-char prefix and the
|
|
243
|
-
// frontend's featureId-keyed map would silently overwrite.
|
|
244
|
-
const rand = globalThis.crypto?.randomUUID?.()
|
|
245
|
-
|| (Date.now().toString(36) + Math.random().toString(36).slice(2));
|
|
246
|
-
const id = `feat-${rand}`;
|
|
247
|
-
const title = makeTitle({ preview: trackAResult?.preview, prompt });
|
|
248
|
-
featureTitle = title;
|
|
249
|
-
const record = {
|
|
250
|
-
id,
|
|
251
|
-
title,
|
|
252
|
-
description: prompt ? prompt.slice(0, 500) : '',
|
|
253
|
-
priority: 'medium',
|
|
254
|
-
status: 'in_progress',
|
|
255
|
-
parentId: null,
|
|
256
|
-
parentTaskId: null,
|
|
257
|
-
createdAt: Date.now(),
|
|
258
|
-
updatedAt: Date.now(),
|
|
259
|
-
};
|
|
260
|
-
if (groupId) {
|
|
261
|
-
record.groupId = groupId;
|
|
262
|
-
record.members = [vpId];
|
|
263
|
-
record.initiator = vpId;
|
|
264
|
-
}
|
|
265
|
-
featureStore.create(record);
|
|
266
|
-
featureId = id;
|
|
267
|
-
} catch (err) {
|
|
268
|
-
console.warn('[FeatureArc] create failed:', err?.message || err);
|
|
269
|
-
// Fallback: still publish a synthetic id so the UI gets a pill
|
|
270
|
-
// (matches the no-store branch above — a broken store should
|
|
271
|
-
// not silently disable the feature folding UX).
|
|
272
|
-
featureId = `feat-local-${turnId}`;
|
|
273
|
-
featureTitle = makeTitle({ preview: trackAResult?.preview, prompt });
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
if (typeof emit.featureStarted === 'function') {
|
|
277
|
-
try {
|
|
278
|
-
emit.featureStarted({
|
|
279
|
-
featureId,
|
|
280
|
-
title: featureTitle || makeTitle({ preview: trackAResult?.preview, prompt }),
|
|
281
|
-
trigger: signalKind,
|
|
282
|
-
toolName: extra.toolName,
|
|
283
|
-
});
|
|
284
|
-
} catch (err) {
|
|
285
|
-
console.warn('[FeatureArc] featureStarted emit failed:', err?.message || err);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Launch Track A in the background. Returns the promise so callers can
|
|
292
|
-
* await it during shutdown if they need to (tests). In the hot path
|
|
293
|
-
* runVpTurn fires-and-forgets.
|
|
294
|
-
*/
|
|
295
|
-
async function startTrackA() {
|
|
296
|
-
try {
|
|
297
|
-
const result = await runQuickResponse({
|
|
298
|
-
adapter,
|
|
299
|
-
model,
|
|
300
|
-
prompt,
|
|
301
|
-
language,
|
|
302
|
-
vpDisplayName,
|
|
303
|
-
signal,
|
|
304
|
-
});
|
|
305
|
-
trackAResult = result;
|
|
306
|
-
trackADone = true;
|
|
307
|
-
if (result && typeof emit.quickPreview === 'function') {
|
|
308
|
-
try {
|
|
309
|
-
emit.quickPreview({ intent: result.intent, preview: result.preview });
|
|
310
|
-
} catch (err) {
|
|
311
|
-
console.warn('[FeatureArc] quickPreview emit failed:', err?.message || err);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
if (result && result.intent === 'feature') {
|
|
315
|
-
maybeCreateFeature('quick');
|
|
316
|
-
}
|
|
317
|
-
} catch (err) {
|
|
318
|
-
// runQuickResponse already swallows most things; log + continue.
|
|
319
|
-
trackADone = true;
|
|
320
|
-
console.warn('[FeatureArc] Track A failed:', err?.message || err);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Observe one engine event, mutating internal counters and possibly
|
|
326
|
-
* firing the auto-create. Always called BEFORE the existing
|
|
327
|
-
* handleEngineEvent dispatch so the featureId is set in time for
|
|
328
|
-
* the wire envelope to pick it up.
|
|
329
|
-
*
|
|
330
|
-
* @param {{type:string, name?:string, text?:string}} event
|
|
331
|
-
*/
|
|
332
|
-
function observeEvent(event) {
|
|
333
|
-
if (!event || typeof event !== 'object') return;
|
|
334
|
-
switch (event.type) {
|
|
335
|
-
// Only `loop` counts toward the heavy-turn threshold. The engine
|
|
336
|
-
// emits exactly one `turn_open` per turn (the bookkeeping marker
|
|
337
|
-
// that the turn started); `loop` is the per-iteration event.
|
|
338
|
-
// Counting both inflates by one and would cause
|
|
339
|
-
// FEATURE_TURN_THRESHOLD = 3 to fire after only 2 real loops.
|
|
340
|
-
case 'loop':
|
|
341
|
-
loopCount += 1;
|
|
342
|
-
if (loopCount >= turnThreshold) maybeCreateFeature('turns');
|
|
343
|
-
break;
|
|
344
|
-
case 'tool_call':
|
|
345
|
-
if (event.name && keyTools.has(event.name)) {
|
|
346
|
-
maybeCreateFeature('tool', { toolName: event.name });
|
|
347
|
-
}
|
|
348
|
-
break;
|
|
349
|
-
case 'text_delta':
|
|
350
|
-
if (typeof event.text === 'string') {
|
|
351
|
-
// Soft cap so a runaway VP doesn't balloon memory before
|
|
352
|
-
// summarisation. 50 KB is enough context for any 1–3 sentence
|
|
353
|
-
// summary.
|
|
354
|
-
if (assistantText.length < 50_000) {
|
|
355
|
-
assistantText += event.text;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
break;
|
|
359
|
-
default:
|
|
360
|
-
break;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Run summary + FeatureStore.update. Idempotent. Caller passes a
|
|
366
|
-
* status hint so we know whether to write 'completed' / 'aborted' /
|
|
367
|
-
* 'error'.
|
|
368
|
-
*
|
|
369
|
-
* @param {{status?:'completed'|'aborted'|'error'}} [opts]
|
|
370
|
-
*/
|
|
371
|
-
async function finalize(opts = {}) {
|
|
372
|
-
if (_finalised) return;
|
|
373
|
-
_finalised = true;
|
|
374
|
-
if (!featureId) return; // never escalated; nothing to close
|
|
375
|
-
|
|
376
|
-
const status = opts.status || 'completed';
|
|
377
|
-
let summary = '';
|
|
378
|
-
if (status === 'completed') {
|
|
379
|
-
summary = await runSummaryCall({
|
|
380
|
-
adapter, model, prompt, assistantText, language, signal,
|
|
381
|
-
});
|
|
382
|
-
} else if (status === 'aborted') {
|
|
383
|
-
summary = '(turn aborted)';
|
|
384
|
-
} else {
|
|
385
|
-
summary = '(turn ended with error)';
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
if (!summary) {
|
|
389
|
-
// Fallback to a truncated tail of the assistant text so the pill
|
|
390
|
-
// is never a blank "✅ — ".
|
|
391
|
-
summary = (assistantText || '').replace(/\s+/g, ' ').trim().slice(0, 200) || '(no summary)';
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Skip the persistence call for synthetic ids — those exist
|
|
395
|
-
// precisely because the store was unavailable or threw on create,
|
|
396
|
-
// so any update against them would also throw on the unknown id
|
|
397
|
-
// (and the catch would silently swallow it). Wire emits still
|
|
398
|
-
// happen so the frontend gets a consistent close.
|
|
399
|
-
const isSynthetic = featureId.startsWith('feat-local-');
|
|
400
|
-
if (!isSynthetic && featureStore && typeof featureStore.update === 'function') {
|
|
401
|
-
try {
|
|
402
|
-
featureStore.update(featureId, {
|
|
403
|
-
status,
|
|
404
|
-
result: summary,
|
|
405
|
-
});
|
|
406
|
-
} catch (err) {
|
|
407
|
-
console.warn('[FeatureArc] update failed:', err?.message || err);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
if (typeof emit.featureCompleted === 'function') {
|
|
412
|
-
try {
|
|
413
|
-
emit.featureCompleted({ featureId, summary, status });
|
|
414
|
-
} catch (err) {
|
|
415
|
-
console.warn('[FeatureArc] featureCompleted emit failed:', err?.message || err);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
return {
|
|
421
|
-
startTrackA,
|
|
422
|
-
observeEvent,
|
|
423
|
-
finalize,
|
|
424
|
-
/** Mostly for tests / wire-tagging in the hot path. */
|
|
425
|
-
getFeatureId: () => featureId,
|
|
426
|
-
getTitle: () => featureTitle,
|
|
427
|
-
getTrackAResult: () => trackAResult,
|
|
428
|
-
isTrackADone: () => trackADone,
|
|
429
|
-
getLoopCount: () => loopCount,
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Test seams.
|
|
434
|
-
export const __test = {
|
|
435
|
-
makeTitle,
|
|
436
|
-
buildSummarySystem,
|
|
437
|
-
};
|
package/unify/quick-response.js
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* quick-response.js — Track A of the Unify dual-track turn.
|
|
3
|
-
*
|
|
4
|
-
* Purpose
|
|
5
|
-
* -------
|
|
6
|
-
* Run a single, non-looping LLM call against the user prompt that:
|
|
7
|
-
* 1. classifies the turn as `quick` (one-shot reply) vs `feature`
|
|
8
|
-
* (heavy multi-step work that should be surfaced as a feature pill);
|
|
9
|
-
* 2. emits a short `preview` sentence telling the user what the VP is
|
|
10
|
-
* about to do (e.g. "I'll grep the auth code, give me a sec").
|
|
11
|
-
*
|
|
12
|
-
* The result feeds the dual-track UI:
|
|
13
|
-
* - `intent === 'feature'` is one of the three signals that auto-create
|
|
14
|
-
* a Feature record, collapsing all subsequent VP output into a pill.
|
|
15
|
-
* - `preview` is rendered as an instant bubble under the user's message
|
|
16
|
-
* so the user sees something within ~1s, even if the main engine
|
|
17
|
-
* loop (Track B) takes longer.
|
|
18
|
-
*
|
|
19
|
-
* Properties
|
|
20
|
-
* ----------
|
|
21
|
-
* - **One LLM call**, no tools, no loop. The whole point is to be cheap
|
|
22
|
-
* and predictable. Uses the same `primaryModel` as the main engine
|
|
23
|
-
* per design ruling — there is no separate `fastModel` channel.
|
|
24
|
-
* - **Retries once on parse/transport failure** then gives up silently.
|
|
25
|
-
* A failed Track A is fine: signals 2 (≥3 turns) and 3 (key tool)
|
|
26
|
-
* still pick up real heavy turns.
|
|
27
|
-
* - **Hard timeout** of 8s wall-clock. Track B must not be held back
|
|
28
|
-
* waiting on Track A.
|
|
29
|
-
*
|
|
30
|
-
* Wire shape — what we emit to the frontend
|
|
31
|
-
* -----------------------------------------
|
|
32
|
-
* On success:
|
|
33
|
-
* { type: 'quick_preview', vpId, turnId, intent, preview }
|
|
34
|
-
*
|
|
35
|
-
* The preview is plain text, ≤ 140 chars, in the user's language.
|
|
36
|
-
*
|
|
37
|
-
* Failure mode
|
|
38
|
-
* ------------
|
|
39
|
-
* Returns `null`. Caller MUST tolerate this and not block on the result.
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
const QUICK_TIMEOUT_MS = 8000;
|
|
43
|
-
const PREVIEW_MAX_CHARS = 140;
|
|
44
|
-
const QUICK_MAX_TOKENS = 300;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Compose the system prompt that asks the LLM for a structured
|
|
48
|
-
* intent + preview. Bilingual to match the rest of Unify.
|
|
49
|
-
*
|
|
50
|
-
* @param {{ language?: string, vpDisplayName?: string }} opts
|
|
51
|
-
* @returns {string}
|
|
52
|
-
*/
|
|
53
|
-
function buildQuickSystem({ language = 'en', vpDisplayName = 'assistant' } = {}) {
|
|
54
|
-
const isZh = String(language || '').toLowerCase().startsWith('zh');
|
|
55
|
-
if (isZh) {
|
|
56
|
-
return [
|
|
57
|
-
`你正在以「${vpDisplayName}」的身份做一次极简的"先回声"判断。这不是真正的回答,主回答会由另一条线并发产出。`,
|
|
58
|
-
'',
|
|
59
|
-
'只输出一行 JSON,不要 markdown、不要 ```、不要前后空行:',
|
|
60
|
-
'{"intent":"quick"|"feature","preview":"<不超过 80 个字符的中文,告诉用户你打算做什么>"}',
|
|
61
|
-
'',
|
|
62
|
-
'intent 规则:',
|
|
63
|
-
'- "quick":用户是寒暄、问事实、要一句话答案,预计一次回复就够。',
|
|
64
|
-
'- "feature":需要查代码 / 改文件 / 调 bash / 跑测试 / 多步推理,预计要折腾若干轮。',
|
|
65
|
-
'',
|
|
66
|
-
'preview 规则:',
|
|
67
|
-
'- 用第一人称简短陈述「我去做什么」,例如:「我去看看 auth 模块再回你」。',
|
|
68
|
-
'- 不要承诺结果,不要复述用户的话。',
|
|
69
|
-
'- 不要带表情、不要带 markdown。',
|
|
70
|
-
].join('\n');
|
|
71
|
-
}
|
|
72
|
-
return [
|
|
73
|
-
`You are "${vpDisplayName}" giving a one-shot pre-reply. This is NOT the real answer; the real answer is being produced concurrently on another track.`,
|
|
74
|
-
'',
|
|
75
|
-
'Output ONE line of strict JSON, no markdown, no fences, no leading/trailing whitespace:',
|
|
76
|
-
'{"intent":"quick"|"feature","preview":"<at most 80 chars telling the user what you are about to do>"}',
|
|
77
|
-
'',
|
|
78
|
-
'intent rules:',
|
|
79
|
-
'- "quick": small talk / factual lookup / single-sentence answer.',
|
|
80
|
-
'- "feature": needs code reading, file edits, bash, tests, or multi-step reasoning.',
|
|
81
|
-
'',
|
|
82
|
-
'preview rules:',
|
|
83
|
-
'- First-person, short. Example: "Let me grep the auth module and get back to you."',
|
|
84
|
-
'- Do NOT promise outcomes. Do NOT echo the user.',
|
|
85
|
-
'- No emoji, no markdown.',
|
|
86
|
-
].join('\n');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Robust JSON extraction. Models occasionally wrap output in fences or
|
|
91
|
-
* leading prose despite instructions; we accept any single JSON object
|
|
92
|
-
* we can find.
|
|
93
|
-
*
|
|
94
|
-
* @param {string} raw
|
|
95
|
-
* @returns {{intent:string, preview:string}|null}
|
|
96
|
-
*/
|
|
97
|
-
function parseQuickJson(raw) {
|
|
98
|
-
if (typeof raw !== 'string') return null;
|
|
99
|
-
let s = raw.trim();
|
|
100
|
-
if (!s) return null;
|
|
101
|
-
// Strip ``` fences if present.
|
|
102
|
-
if (s.startsWith('```')) {
|
|
103
|
-
s = s.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
|
|
104
|
-
}
|
|
105
|
-
// First-pass direct parse.
|
|
106
|
-
let obj = null;
|
|
107
|
-
try { obj = JSON.parse(s); } catch { /* fall through */ }
|
|
108
|
-
// Second-pass: locate first `{` and last `}`.
|
|
109
|
-
if (!obj) {
|
|
110
|
-
const i = s.indexOf('{');
|
|
111
|
-
const j = s.lastIndexOf('}');
|
|
112
|
-
if (i >= 0 && j > i) {
|
|
113
|
-
try { obj = JSON.parse(s.slice(i, j + 1)); } catch { /* nope */ }
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (!obj || typeof obj !== 'object') return null;
|
|
117
|
-
const intent = obj.intent === 'feature' ? 'feature' : 'quick';
|
|
118
|
-
const previewRaw = typeof obj.preview === 'string' ? obj.preview : '';
|
|
119
|
-
const preview = previewRaw.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_MAX_CHARS);
|
|
120
|
-
if (!preview) return null; // a preview-less response is useless
|
|
121
|
-
return { intent, preview };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Drive the adapter once. Collects text deltas, returns the assembled
|
|
126
|
-
* raw string. Throws on adapter error / abort / timeout.
|
|
127
|
-
*
|
|
128
|
-
* @param {object} adapter — LLMAdapter instance with .stream()
|
|
129
|
-
* @param {object} args — { model, system, messages, signal }
|
|
130
|
-
* @returns {Promise<string>}
|
|
131
|
-
*/
|
|
132
|
-
async function callOnce(adapter, args) {
|
|
133
|
-
const parts = [];
|
|
134
|
-
for await (const event of adapter.stream(args)) {
|
|
135
|
-
if (!event || typeof event !== 'object') continue;
|
|
136
|
-
if (event.type === 'text_delta' && typeof event.text === 'string') {
|
|
137
|
-
parts.push(event.text);
|
|
138
|
-
} else if (event.type === 'error') {
|
|
139
|
-
throw event.error || new Error('adapter stream error');
|
|
140
|
-
}
|
|
141
|
-
// tool_call / thinking_delta / usage / stop are ignored; we
|
|
142
|
-
// explicitly do not pass any tools to the adapter.
|
|
143
|
-
}
|
|
144
|
-
return parts.join('');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Run Track A. One adapter call, retry-once on failure, hard 8s deadline.
|
|
149
|
-
*
|
|
150
|
-
* @param {{
|
|
151
|
-
* adapter: object,
|
|
152
|
-
* model: string,
|
|
153
|
-
* prompt: string,
|
|
154
|
-
* language?: string,
|
|
155
|
-
* vpDisplayName?: string,
|
|
156
|
-
* signal?: AbortSignal,
|
|
157
|
-
* }} args
|
|
158
|
-
* @returns {Promise<{intent:'quick'|'feature', preview:string}|null>}
|
|
159
|
-
*/
|
|
160
|
-
export async function runQuickResponse({
|
|
161
|
-
adapter,
|
|
162
|
-
model,
|
|
163
|
-
prompt,
|
|
164
|
-
language,
|
|
165
|
-
vpDisplayName,
|
|
166
|
-
signal,
|
|
167
|
-
} = {}) {
|
|
168
|
-
if (!adapter || typeof adapter.stream !== 'function') return null;
|
|
169
|
-
if (typeof prompt !== 'string' || !prompt.trim()) return null;
|
|
170
|
-
if (!model) return null;
|
|
171
|
-
|
|
172
|
-
// Composite signal: caller's abort OR our timeout, whichever fires first.
|
|
173
|
-
const ctrl = new AbortController();
|
|
174
|
-
const onCallerAbort = () => ctrl.abort();
|
|
175
|
-
if (signal) {
|
|
176
|
-
if (signal.aborted) return null;
|
|
177
|
-
signal.addEventListener('abort', onCallerAbort, { once: true });
|
|
178
|
-
}
|
|
179
|
-
const timer = setTimeout(() => ctrl.abort(), QUICK_TIMEOUT_MS);
|
|
180
|
-
|
|
181
|
-
const system = buildQuickSystem({ language, vpDisplayName });
|
|
182
|
-
const messages = [{ role: 'user', content: prompt }];
|
|
183
|
-
const callArgs = {
|
|
184
|
-
model,
|
|
185
|
-
system,
|
|
186
|
-
messages,
|
|
187
|
-
maxTokens: QUICK_MAX_TOKENS,
|
|
188
|
-
signal: ctrl.signal,
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
// Attempt 1.
|
|
193
|
-
let raw = '';
|
|
194
|
-
try {
|
|
195
|
-
raw = await callOnce(adapter, callArgs);
|
|
196
|
-
} catch (err) {
|
|
197
|
-
// Abort or external error — exit silently. Don't retry on abort.
|
|
198
|
-
if (err && (err.name === 'AbortError' || err.name === 'LLMAbortError')) return null;
|
|
199
|
-
// Otherwise fall through to retry.
|
|
200
|
-
raw = '';
|
|
201
|
-
}
|
|
202
|
-
let parsed = raw ? parseQuickJson(raw) : null;
|
|
203
|
-
|
|
204
|
-
if (!parsed) {
|
|
205
|
-
// Attempt 2 (retry once). Reuse the same args; adapter is stateless.
|
|
206
|
-
if (ctrl.signal.aborted) return null;
|
|
207
|
-
try {
|
|
208
|
-
const raw2 = await callOnce(adapter, callArgs);
|
|
209
|
-
parsed = raw2 ? parseQuickJson(raw2) : null;
|
|
210
|
-
} catch {
|
|
211
|
-
parsed = null;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return parsed;
|
|
216
|
-
} finally {
|
|
217
|
-
clearTimeout(timer);
|
|
218
|
-
if (signal) signal.removeEventListener('abort', onCallerAbort);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Test seams — exported so tests can exercise pure helpers without
|
|
223
|
-
// spinning up an adapter.
|
|
224
|
-
export const __test = {
|
|
225
|
-
parseQuickJson,
|
|
226
|
-
buildQuickSystem,
|
|
227
|
-
QUICK_TIMEOUT_MS,
|
|
228
|
-
PREVIEW_MAX_CHARS,
|
|
229
|
-
};
|