@yeaft/webchat-agent 0.1.705 → 0.1.707
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/archive/tool-results.js +9 -1
- package/unify/engine.js +191 -3
- package/unify/models.js +37 -0
- package/unify/tools/registry.js +78 -1
- package/unify/tools/route-forward.js +18 -0
- package/unify/tools/types.js +20 -0
- package/unify/web-bridge.js +583 -99
package/package.json
CHANGED
|
@@ -151,7 +151,15 @@ export async function archiveToolResults({
|
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Format a byte count as a short human-readable string ("1.5KB", "2.0MB").
|
|
156
|
+
* Exported so other size-aware helpers (tool-result truncation in the
|
|
157
|
+
* registry) format identically.
|
|
158
|
+
*
|
|
159
|
+
* @param {number} bytes
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
export function formatSize(bytes) {
|
|
155
163
|
if (bytes < 1024) return `${bytes}B`;
|
|
156
164
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
157
165
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
package/unify/engine.js
CHANGED
|
@@ -34,9 +34,11 @@ import { runStopHooks } from './stop-hooks.js';
|
|
|
34
34
|
// the constant 'main'.
|
|
35
35
|
const MAIN_THREAD_ID = 'main';
|
|
36
36
|
import { pickEffort, parseEffortPrefix } from './effort.js';
|
|
37
|
-
import { normalizeEffort } from './models.js';
|
|
37
|
+
import { normalizeEffort, resolveContextWindow } from './models.js';
|
|
38
38
|
import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
|
|
39
39
|
import { resolveThinking } from './router/thinking.js';
|
|
40
|
+
import { approxTokens } from './memory/budget.js';
|
|
41
|
+
import { truncateToolResultIfNeeded } from './tools/registry.js';
|
|
40
42
|
import {
|
|
41
43
|
TOOL_BATCH_SIZE,
|
|
42
44
|
TURN_SUMMARY_THRESHOLD,
|
|
@@ -100,6 +102,64 @@ export function mapDebugMessage(m) {
|
|
|
100
102
|
return out;
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* task-704b — estimate the total token cost of a system prompt + a
|
|
107
|
+
* messages array. Used by the pre-flight guard before adapter.stream()
|
|
108
|
+
* to decide whether to run an emergency archive sweep.
|
|
109
|
+
*
|
|
110
|
+
* Why estimate, not exact: a real tokenizer (tiktoken, claude-tokenizer)
|
|
111
|
+
* adds a heavy dep + per-turn cost for what is fundamentally a guard
|
|
112
|
+
* rail. `approxTokens` (char/4 with CJK weighting) is the same
|
|
113
|
+
* estimator the AMS budget code uses; it is monotonic in payload size
|
|
114
|
+
* and that is the only property the guard rail needs. False positives
|
|
115
|
+
* cost an unnecessary archive sweep (cheap); false negatives let a
|
|
116
|
+
* runaway request through (expensive — that is exactly the bug we are
|
|
117
|
+
* fixing).
|
|
118
|
+
*
|
|
119
|
+
* Multi-modal messages: `content` may be an array of content parts
|
|
120
|
+
* (Anthropic / OpenAI Responses shape). Text parts use approxTokens;
|
|
121
|
+
* image parts get a fixed 1024-token estimate — a rough average across
|
|
122
|
+
* vision pricing models. Exact pricing isn't the goal; "this is roughly
|
|
123
|
+
* how much of the window the message will consume" is.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} system
|
|
126
|
+
* @param {Array<{role:string, content?:any, toolCalls?:Array}>} messages
|
|
127
|
+
* @returns {number}
|
|
128
|
+
*/
|
|
129
|
+
export function estimateMessagesTokens(system, messages) {
|
|
130
|
+
let total = approxTokens(typeof system === 'string' ? system : '');
|
|
131
|
+
if (!Array.isArray(messages)) return total;
|
|
132
|
+
for (const m of messages) {
|
|
133
|
+
if (!m) continue;
|
|
134
|
+
const c = m.content;
|
|
135
|
+
if (typeof c === 'string') {
|
|
136
|
+
total += approxTokens(c);
|
|
137
|
+
} else if (Array.isArray(c)) {
|
|
138
|
+
for (const part of c) {
|
|
139
|
+
if (!part) continue;
|
|
140
|
+
if (part.type === 'text') {
|
|
141
|
+
// Coerce defensively — a non-string `text` (number, Buffer,
|
|
142
|
+
// object) would otherwise throw inside approxTokens and abort
|
|
143
|
+
// the pre-flight estimate, defeating the guard rail.
|
|
144
|
+
total += approxTokens(typeof part.text === 'string' ? part.text : '');
|
|
145
|
+
} else if (part.type === 'image') {
|
|
146
|
+
total += 1024;
|
|
147
|
+
}
|
|
148
|
+
// Other multi-modal parts (audio etc.) — skip; not produced today.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(m.toolCalls)) {
|
|
152
|
+
for (const tc of m.toolCalls) {
|
|
153
|
+
try {
|
|
154
|
+
total += approxTokens(JSON.stringify(tc.input || {}));
|
|
155
|
+
} catch { /* circular — ignore */ }
|
|
156
|
+
total += approxTokens(typeof tc.name === 'string' ? tc.name : '');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return total;
|
|
161
|
+
}
|
|
162
|
+
|
|
103
163
|
// ─── Engine Events (superset of adapter events) ──────────────────
|
|
104
164
|
|
|
105
165
|
/**
|
|
@@ -603,6 +663,11 @@ export class Engine {
|
|
|
603
663
|
conversationStore: this.#conversationStore,
|
|
604
664
|
adapter: this.#adapter,
|
|
605
665
|
config: this.#config,
|
|
666
|
+
// task-704b: per-tool-result hard cap derives from this. Threaded
|
|
667
|
+
// from the live model (resolveModel(currentModel)) every turn so
|
|
668
|
+
// fallbackModel switches see the new window. Falls back to
|
|
669
|
+
// config.maxContextTokens, then 200K, in registry.js.
|
|
670
|
+
contextWindow: vpCtx?.contextWindow,
|
|
606
671
|
// ViewImage (task-333b PR-B rev-3 P1-A): expose size cap + allowlist
|
|
607
672
|
// via tool ctx so hosts can override via ~/.yeaft/config.json without
|
|
608
673
|
// touching the tool impl.
|
|
@@ -617,6 +682,11 @@ export class Engine {
|
|
|
617
682
|
inboundEnvelope: vpCtx?.inboundEnvelope,
|
|
618
683
|
taskId: vpCtx?.taskId,
|
|
619
684
|
taskMembers: vpCtx?.taskMembers,
|
|
685
|
+
// task-707: tool-callable end-turn signal. The engine threads this
|
|
686
|
+
// setter when constructing toolCtx so a tool (e.g. route_forward)
|
|
687
|
+
// can mark "after this batch, end the turn — do NOT call adapter
|
|
688
|
+
// again". Honored at the top of the tool-loop continuation.
|
|
689
|
+
requestEndTurn: vpCtx?.requestEndTurn,
|
|
620
690
|
// Sub-agent plumbing — Agent tool needs these to spawn a child
|
|
621
691
|
// Engine that inherits the parent's adapter / stores / toolset.
|
|
622
692
|
parentEngineDeps: {
|
|
@@ -1115,6 +1185,13 @@ export class Engine {
|
|
|
1115
1185
|
let currentModel = this.#config.model;
|
|
1116
1186
|
let cumulativeInputTokens = 0;
|
|
1117
1187
|
let cumulativeOutputTokens = 0;
|
|
1188
|
+
// task-707: tool-callable end-turn signal. Tools (currently only
|
|
1189
|
+
// `route_forward`) can set this via toolCtx.requestEndTurn(reason)
|
|
1190
|
+
// to break out of the tool-loop after the current batch finishes
|
|
1191
|
+
// — without invoking another adapter.stream(). Used to hand off
|
|
1192
|
+
// control to other VPs cleanly. Reset to null at the top of every
|
|
1193
|
+
// outer-loop iteration so the flag never carries across turns.
|
|
1194
|
+
let endTurnRequested = null;
|
|
1118
1195
|
|
|
1119
1196
|
while (true) {
|
|
1120
1197
|
turnNumber++;
|
|
@@ -1152,6 +1229,17 @@ export class Engine {
|
|
|
1152
1229
|
if (exchange?.rawResponse) rawResponse = exchange.rawResponse;
|
|
1153
1230
|
};
|
|
1154
1231
|
|
|
1232
|
+
// task-704b: resolve the live model's context window for this turn.
|
|
1233
|
+
// Used by the per-tool-result cap (passed via toolCtx) and the
|
|
1234
|
+
// pre-flight total-token guard inside the try-block. Hoisted out of
|
|
1235
|
+
// the try so toolCtx (built after the adapter stream) can see it.
|
|
1236
|
+
// Re-resolved every turn because fallbackModel switches change
|
|
1237
|
+
// `currentModel` mid query() — the cap MUST track the model we're
|
|
1238
|
+
// actually about to call. Single resolver in models.js owns the
|
|
1239
|
+
// fallback ladder (registry → config → default) so engine.js and
|
|
1240
|
+
// tools/registry.js can never disagree.
|
|
1241
|
+
const currentContextWindow = resolveContextWindow(currentModel, this.#config);
|
|
1242
|
+
|
|
1155
1243
|
yield { type: 'turn_start', turnNumber };
|
|
1156
1244
|
|
|
1157
1245
|
try {
|
|
@@ -1203,6 +1291,7 @@ export class Engine {
|
|
|
1203
1291
|
// message_trace can fetch it on demand. The stub keeps the
|
|
1204
1292
|
// OpenAI/Anthropic toolCallId pairing intact.
|
|
1205
1293
|
let wireMessages = stripMetaForWire([...conversationMessages]);
|
|
1294
|
+
|
|
1206
1295
|
if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
1207
1296
|
try {
|
|
1208
1297
|
const swept = await archiveToolResults({
|
|
@@ -1224,6 +1313,52 @@ export class Engine {
|
|
|
1224
1313
|
} catch { /* best-effort */ }
|
|
1225
1314
|
}
|
|
1226
1315
|
|
|
1316
|
+
// task-704b: pre-flight total-token guard. Even with the per-tool
|
|
1317
|
+
// cap (registry.js: 10% of contextWindow per result), N tool
|
|
1318
|
+
// results plus history can still breach the wire limit before we
|
|
1319
|
+
// ever call adapter.stream(). Estimate the total token cost; if
|
|
1320
|
+
// it exceeds PREFLIGHT_RATIO of the live context window, run an
|
|
1321
|
+
// emergency archive sweep with `turnAgeMin: 0` so even
|
|
1322
|
+
// current-turn-but-not-this-call bulky results get stubbed. The
|
|
1323
|
+
// normal sweep above only stubs results older than 5 user turns
|
|
1324
|
+
// — that's the wrong cadence when the *current* turn already has
|
|
1325
|
+
// 4 large grep results.
|
|
1326
|
+
//
|
|
1327
|
+
// PREFLIGHT_RATIO = 0.85 leaves ~15% of the window for the model's
|
|
1328
|
+
// own output tokens + tools metadata + light future history.
|
|
1329
|
+
// The estimator (`estimateMessagesTokens`) is approxTokens
|
|
1330
|
+
// (char/4 with CJK weighting) — good enough for a guard rail; a
|
|
1331
|
+
// real tokenizer would be exact but adds a heavy dep.
|
|
1332
|
+
if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
1333
|
+
const PREFLIGHT_RATIO = 0.85;
|
|
1334
|
+
const threshold = Math.floor(currentContextWindow * PREFLIGHT_RATIO);
|
|
1335
|
+
const estimate = estimateMessagesTokens(systemPrompt, wireMessages);
|
|
1336
|
+
if (estimate > threshold) {
|
|
1337
|
+
try {
|
|
1338
|
+
const sweep = await archiveToolResults({
|
|
1339
|
+
root: `${this.#yeaftDir}/memory`,
|
|
1340
|
+
scopeDir: 'user',
|
|
1341
|
+
messages: wireMessages,
|
|
1342
|
+
turnAgeMin: 0,
|
|
1343
|
+
lengthMin: this.#config?.archive?.lengthMin ?? 2000,
|
|
1344
|
+
});
|
|
1345
|
+
wireMessages = sweep.nextMessages;
|
|
1346
|
+
if (sweep.archivedCount > 0) {
|
|
1347
|
+
for (let i = 0; i < conversationMessages.length; i += 1) {
|
|
1348
|
+
conversationMessages[i] = wireMessages[i];
|
|
1349
|
+
}
|
|
1350
|
+
this.#trace.log?.('preflight_sweep', {
|
|
1351
|
+
archivedCount: sweep.archivedCount,
|
|
1352
|
+
archivedBytes: sweep.archivedBytes,
|
|
1353
|
+
estimateBefore: estimate,
|
|
1354
|
+
threshold,
|
|
1355
|
+
contextWindow: currentContextWindow,
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
} catch { /* best-effort */ }
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1227
1362
|
// Stream from adapter
|
|
1228
1363
|
for await (const event of this.#adapter.stream({
|
|
1229
1364
|
model: currentModel,
|
|
@@ -1555,7 +1690,28 @@ export class Engine {
|
|
|
1555
1690
|
}
|
|
1556
1691
|
|
|
1557
1692
|
// Execute tool calls and feed results back
|
|
1558
|
-
|
|
1693
|
+
// task-707: requestEndTurn is a per-batch closure that lets a tool
|
|
1694
|
+
// signal "end this turn after the current batch — no adapter retry".
|
|
1695
|
+
// We re-create the closure each iteration because endTurnRequested
|
|
1696
|
+
// is a per-query local (reset implicitly at the top of #runQuery).
|
|
1697
|
+
const toolCtx = this.#buildToolContext(signal, {
|
|
1698
|
+
router,
|
|
1699
|
+
senderVpId,
|
|
1700
|
+
inboundEnvelope,
|
|
1701
|
+
taskId,
|
|
1702
|
+
taskMembers,
|
|
1703
|
+
vpPersona,
|
|
1704
|
+
contextWindow: currentContextWindow,
|
|
1705
|
+
requestEndTurn: (reason) => {
|
|
1706
|
+
// First call wins — preserve the kind/reason of the first tool
|
|
1707
|
+
// that asked to end the turn. Late callers (a second
|
|
1708
|
+
// route_forward in the same batch) keep dispatching but don't
|
|
1709
|
+
// overwrite the recorded reason.
|
|
1710
|
+
if (endTurnRequested == null) {
|
|
1711
|
+
endTurnRequested = reason || { kind: 'tool_handoff' };
|
|
1712
|
+
}
|
|
1713
|
+
},
|
|
1714
|
+
});
|
|
1559
1715
|
|
|
1560
1716
|
// task-325a: track whether we aborted mid tool-loop so we can
|
|
1561
1717
|
// break out of the outer while-loop cleanly once the current
|
|
@@ -1624,7 +1780,14 @@ export class Engine {
|
|
|
1624
1780
|
output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
|
|
1625
1781
|
} else {
|
|
1626
1782
|
const tool = this.#tools.get(tc.name);
|
|
1627
|
-
|
|
1783
|
+
const rawOutput = await tool.execute(tc.input, { signal });
|
|
1784
|
+
// task-704b: legacy #tools branch must apply the same per-tool
|
|
1785
|
+
// cap as ToolRegistry.execute. Otherwise a deployment using
|
|
1786
|
+
// the legacy registration path bypasses the defense entirely.
|
|
1787
|
+
output = truncateToolResultIfNeeded(rawOutput, {
|
|
1788
|
+
contextWindow: currentContextWindow,
|
|
1789
|
+
toolName: tc.name,
|
|
1790
|
+
});
|
|
1628
1791
|
}
|
|
1629
1792
|
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false, threadId: this.currentThreadId };
|
|
1630
1793
|
} catch (err) {
|
|
@@ -1692,6 +1855,31 @@ export class Engine {
|
|
|
1692
1855
|
conversationMessages.push({ role: 'user', content: reminder });
|
|
1693
1856
|
}
|
|
1694
1857
|
|
|
1858
|
+
// task-707: tool-callable end-turn signal. If a tool in this batch
|
|
1859
|
+
// called toolCtx.requestEndTurn(reason), break out of the outer
|
|
1860
|
+
// while-loop now — DON'T call adapter.stream() again. The
|
|
1861
|
+
// assistant(tool_use)+tool(tool_result) pairs are already in
|
|
1862
|
+
// conversationMessages, so the next user-initiated turn sees a
|
|
1863
|
+
// clean wire shape. Used by `route_forward` to hand off control
|
|
1864
|
+
// to other VPs without continuing to generate.
|
|
1865
|
+
//
|
|
1866
|
+
// Order matters: this runs BEFORE T1 reflection (which would
|
|
1867
|
+
// collapse the arc into a summary that's only valuable across
|
|
1868
|
+
// multi-iteration tool loops) and BEFORE the abortedDuringTools
|
|
1869
|
+
// check (so a clean handoff doesn't get reported as 'aborted').
|
|
1870
|
+
if (endTurnRequested) {
|
|
1871
|
+
const handoffDetail = typeof endTurnRequested === 'object'
|
|
1872
|
+
? endTurnRequested
|
|
1873
|
+
: { kind: 'tool_handoff', reason: String(endTurnRequested) };
|
|
1874
|
+
yield {
|
|
1875
|
+
type: 'turn_end',
|
|
1876
|
+
turnNumber,
|
|
1877
|
+
stopReason: 'tool_handoff',
|
|
1878
|
+
detail: handoffDetail,
|
|
1879
|
+
};
|
|
1880
|
+
break;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1695
1883
|
// PR-L: T1 in-turn (synchronous) reflection. Fires exactly once per
|
|
1696
1884
|
// query() lifetime, the moment queryToolCount crosses
|
|
1697
1885
|
// TOOL_BATCH_SIZE (13). Generates a markdown reflection over the
|
package/unify/models.js
CHANGED
|
@@ -213,6 +213,43 @@ export function resolveModel(modelName) {
|
|
|
213
213
|
return info ? { ...info } : null;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Default context window when neither the model registry nor the engine
|
|
218
|
+
* config has a value. 200K is a conservative middle-ground — most modern
|
|
219
|
+
* production models (Claude, GPT-5, Gemini) have ≥ 128K.
|
|
220
|
+
*
|
|
221
|
+
* Single source of truth: callers (engine.js pre-flight guard,
|
|
222
|
+
* tools/registry.js per-result cap) MUST use this constant or
|
|
223
|
+
* {@link resolveContextWindow} instead of hardcoding 200_000.
|
|
224
|
+
*/
|
|
225
|
+
export const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolve the live context window for a model, with an explicit fallback
|
|
229
|
+
* ladder:
|
|
230
|
+
* 1. MODEL_REGISTRY entry's `contextWindow` (most accurate)
|
|
231
|
+
* 2. caller-supplied config override (e.g. `config.maxContextTokens`)
|
|
232
|
+
* 3. {@link DEFAULT_CONTEXT_WINDOW}
|
|
233
|
+
*
|
|
234
|
+
* Used by the per-tool-result cap and the pre-flight token guard so the
|
|
235
|
+
* defense layers always see the same number, regardless of which seam
|
|
236
|
+
* resolves it first.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} modelName
|
|
239
|
+
* @param {{ maxContextTokens?: number }} [config]
|
|
240
|
+
* @returns {number}
|
|
241
|
+
*/
|
|
242
|
+
export function resolveContextWindow(modelName, config) {
|
|
243
|
+
const info = resolveModel(modelName);
|
|
244
|
+
if (info && Number.isFinite(info.contextWindow) && info.contextWindow > 0) {
|
|
245
|
+
return info.contextWindow;
|
|
246
|
+
}
|
|
247
|
+
const cfg = config && Number.isFinite(config.maxContextTokens) && config.maxContextTokens > 0
|
|
248
|
+
? config.maxContextTokens : null;
|
|
249
|
+
if (cfg !== null) return cfg;
|
|
250
|
+
return DEFAULT_CONTEXT_WINDOW;
|
|
251
|
+
}
|
|
252
|
+
|
|
216
253
|
/**
|
|
217
254
|
* List all known models.
|
|
218
255
|
*
|
package/unify/tools/registry.js
CHANGED
|
@@ -9,6 +9,73 @@
|
|
|
9
9
|
* still carry a `modes` field, but the registry ignores it.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { formatSize } from '../archive/tool-results.js';
|
|
13
|
+
import { DEFAULT_CONTEXT_WINDOW } from '../models.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-tool-result hard cap.
|
|
17
|
+
*
|
|
18
|
+
* A single tool can return megabytes (a grep over a large repo, a large
|
|
19
|
+
* file read, a paginated web fetch). If we forward that verbatim into the
|
|
20
|
+
* next LLM request the model returns LLMContextError ("context_length_exceeded")
|
|
21
|
+
* and the user sees a hard failure mid-turn. Cap at a fraction of the
|
|
22
|
+
* model's context window so even a runaway tool can't kill the request.
|
|
23
|
+
*
|
|
24
|
+
* Cap = max(MIN_CAP, floor(contextWindow * RATIO))
|
|
25
|
+
* - RATIO = 10%: leaves 90% of context for system prompt, history, the
|
|
26
|
+
* model's own output, and other tools called this turn. Generous in
|
|
27
|
+
* absolute terms (25.6 KB on a 256 K window, ~20 K on a 200 K window),
|
|
28
|
+
* plenty for a real tool result; small enough that 5 such results
|
|
29
|
+
* still fit alongside everything else.
|
|
30
|
+
* - MIN_CAP = 8 KB: sanity floor for tiny test fixtures (4 K context
|
|
31
|
+
* models in tests would otherwise cap at 409 chars, defeating the
|
|
32
|
+
* test's purpose). Real production models all have ≥ 32 K context, so
|
|
33
|
+
* the floor never bites in practice.
|
|
34
|
+
*
|
|
35
|
+
* The truncation lands HERE (not in engine.js when pushing tool results
|
|
36
|
+
* into messages) so the UI's `tool_end` event, the exec log, AND the
|
|
37
|
+
* model all see the same truncated content. Truncating later would mean
|
|
38
|
+
* the user sees the full 2 MB output but the model gets a stub —
|
|
39
|
+
* confusing.
|
|
40
|
+
*/
|
|
41
|
+
const TOOL_RESULT_CAP_RATIO = 0.10;
|
|
42
|
+
const TOOL_RESULT_MIN_CAP = 8 * 1024;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Truncate a tool result if it exceeds the per-result cap. Non-string
|
|
46
|
+
* outputs are JSON-stringified first (matching what engine.js eventually
|
|
47
|
+
* pushes into `content`), then capped.
|
|
48
|
+
*
|
|
49
|
+
* Edge cases handled:
|
|
50
|
+
* - `undefined` → `JSON.stringify(undefined)` returns `undefined`, not
|
|
51
|
+
* a string, so we coerce to `'undefined'` and let the cap apply.
|
|
52
|
+
* - circular refs / `JSON.stringify` throws → fall back to `String(...)`.
|
|
53
|
+
*
|
|
54
|
+
* @param {unknown} output
|
|
55
|
+
* @param {{ contextWindow?: number, toolName: string }} opts
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function truncateToolResultIfNeeded(output, { contextWindow, toolName }) {
|
|
59
|
+
let text;
|
|
60
|
+
if (typeof output === 'string') {
|
|
61
|
+
text = output;
|
|
62
|
+
} else {
|
|
63
|
+
try {
|
|
64
|
+
const json = JSON.stringify(output);
|
|
65
|
+
text = typeof json === 'string' ? json : String(output);
|
|
66
|
+
} catch {
|
|
67
|
+
text = String(output);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const ctx = Number.isFinite(contextWindow) && contextWindow > 0
|
|
71
|
+
? contextWindow : DEFAULT_CONTEXT_WINDOW;
|
|
72
|
+
const cap = Math.max(TOOL_RESULT_MIN_CAP, Math.floor(ctx * TOOL_RESULT_CAP_RATIO));
|
|
73
|
+
if (text.length <= cap) return text;
|
|
74
|
+
const head = text.slice(0, cap);
|
|
75
|
+
const marker = `\n\n[truncated: ${toolName} returned ${formatSize(text.length)}, capped at ${formatSize(cap)}; the model will not see the rest of this output]`;
|
|
76
|
+
return head + marker;
|
|
77
|
+
}
|
|
78
|
+
|
|
12
79
|
export class ToolRegistry {
|
|
13
80
|
/** @type {Map<string, import('./types.js').ToolDef>} */
|
|
14
81
|
#tools = new Map();
|
|
@@ -93,6 +160,12 @@ export class ToolRegistry {
|
|
|
93
160
|
|
|
94
161
|
/**
|
|
95
162
|
* Execute a tool by name.
|
|
163
|
+
*
|
|
164
|
+
* The result is passed through {@link truncateToolResultIfNeeded} so that
|
|
165
|
+
* a single tool can never blow the context window. The cap derives from
|
|
166
|
+
* `ctx.contextWindow` (the live model's window, threaded by engine.js);
|
|
167
|
+
* fall back to a 200K default for callers that don't supply it.
|
|
168
|
+
*
|
|
96
169
|
* @param {string} name
|
|
97
170
|
* @param {object} input
|
|
98
171
|
* @param {import('./types.js').ToolContext} [ctx={}]
|
|
@@ -101,7 +174,11 @@ export class ToolRegistry {
|
|
|
101
174
|
async execute(name, input, ctx = {}) {
|
|
102
175
|
const tool = this.#tools.get(name);
|
|
103
176
|
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
|
104
|
-
|
|
177
|
+
const output = await tool.execute(input, ctx);
|
|
178
|
+
return truncateToolResultIfNeeded(output, {
|
|
179
|
+
contextWindow: ctx.contextWindow,
|
|
180
|
+
toolName: name,
|
|
181
|
+
});
|
|
105
182
|
}
|
|
106
183
|
|
|
107
184
|
/** Number of registered tools. */
|
|
@@ -106,6 +106,24 @@ Returns JSON: { ok, dispatched?, error?, detail? }.`,
|
|
|
106
106
|
detail: result.detail || null,
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
|
+
// task-707: hand off control. Successful forward means the originating
|
|
110
|
+
// turn should NOT continue generating — the target VPs are now in
|
|
111
|
+
// charge. Signal the engine to break the tool-loop after this batch.
|
|
112
|
+
// The structured payload feeds web-bridge's `group_handoff` UX event
|
|
113
|
+
// so the frontend can render "↪ 已转交给 @vp-x、@vp-y" without
|
|
114
|
+
// re-parsing a string.
|
|
115
|
+
if (typeof ctx.requestEndTurn === 'function') {
|
|
116
|
+
try {
|
|
117
|
+
ctx.requestEndTurn({
|
|
118
|
+
kind: 'route_forward',
|
|
119
|
+
fromVpId: senderVpId,
|
|
120
|
+
dispatched: result.dispatched.slice(),
|
|
121
|
+
broadcast: Boolean(result.report?.broadcast),
|
|
122
|
+
text,
|
|
123
|
+
reason: reason || null,
|
|
124
|
+
});
|
|
125
|
+
} catch { /* never block the tool path on a UX hint */ }
|
|
126
|
+
}
|
|
109
127
|
return JSON.stringify({
|
|
110
128
|
ok: true,
|
|
111
129
|
dispatched: result.dispatched,
|
package/unify/tools/types.js
CHANGED
|
@@ -24,6 +24,26 @@
|
|
|
24
24
|
* @property {(groupId: string) => string[]|null} [getGroupRoster]
|
|
25
25
|
* — R6: resolve a group's roster (used by TaskCreate / route_forward to
|
|
26
26
|
* validate `members` ⊆ roster without importing group-store directly).
|
|
27
|
+
* @property {number} [contextWindow] — current model's context window in
|
|
28
|
+
* tokens (used by ToolRegistry.execute to cap a single tool result at a
|
|
29
|
+
* fraction of the window so one runaway grep can't blow the wire).
|
|
30
|
+
* @property {(reason?: string|object) => void} [requestEndTurn]
|
|
31
|
+
* — tool-callable signal that the current engine turn should end after
|
|
32
|
+
* this batch of tool calls completes (no follow-up adapter.stream call).
|
|
33
|
+
* Used by `route_forward` to hand off control to other VPs without
|
|
34
|
+
* continuing to generate. The engine wires this when it builds toolCtx
|
|
35
|
+
* and yields a `turn_end` event with `stopReason: 'tool_handoff'` and
|
|
36
|
+
* the supplied reason as `detail`. `reason` may be a structured object
|
|
37
|
+
* `{kind, ...}` so downstream observers (web-bridge) can render UI hints
|
|
38
|
+
* (e.g. "↪ 已转交给 @vp-b") without re-parsing strings.
|
|
39
|
+
* @property {string} [senderVpId] — id of the VP whose turn is currently
|
|
40
|
+
* running. Used by `route_forward` to stamp the forwarded message and
|
|
41
|
+
* by the loop guard to key per-sender throttling.
|
|
42
|
+
* @property {object} [inboundEnvelope] — the envelope that triggered this
|
|
43
|
+
* turn (groupId / msgId / causedBy chain). Threaded into route_forward
|
|
44
|
+
* so causedBy chains extend correctly.
|
|
45
|
+
* @property {object} [router] — per-group router (createRouter() output)
|
|
46
|
+
* for VP-to-VP forwarding. Set by the bridge when running inside a group.
|
|
27
47
|
*/
|
|
28
48
|
|
|
29
49
|
/**
|