@yeaft/webchat-agent 0.1.705 → 0.1.706
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 +133 -3
- package/unify/models.js +37 -0
- package/unify/tools/registry.js +78 -1
- package/unify/tools/types.js +3 -0
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.
|
|
@@ -1152,6 +1217,17 @@ export class Engine {
|
|
|
1152
1217
|
if (exchange?.rawResponse) rawResponse = exchange.rawResponse;
|
|
1153
1218
|
};
|
|
1154
1219
|
|
|
1220
|
+
// task-704b: resolve the live model's context window for this turn.
|
|
1221
|
+
// Used by the per-tool-result cap (passed via toolCtx) and the
|
|
1222
|
+
// pre-flight total-token guard inside the try-block. Hoisted out of
|
|
1223
|
+
// the try so toolCtx (built after the adapter stream) can see it.
|
|
1224
|
+
// Re-resolved every turn because fallbackModel switches change
|
|
1225
|
+
// `currentModel` mid query() — the cap MUST track the model we're
|
|
1226
|
+
// actually about to call. Single resolver in models.js owns the
|
|
1227
|
+
// fallback ladder (registry → config → default) so engine.js and
|
|
1228
|
+
// tools/registry.js can never disagree.
|
|
1229
|
+
const currentContextWindow = resolveContextWindow(currentModel, this.#config);
|
|
1230
|
+
|
|
1155
1231
|
yield { type: 'turn_start', turnNumber };
|
|
1156
1232
|
|
|
1157
1233
|
try {
|
|
@@ -1203,6 +1279,7 @@ export class Engine {
|
|
|
1203
1279
|
// message_trace can fetch it on demand. The stub keeps the
|
|
1204
1280
|
// OpenAI/Anthropic toolCallId pairing intact.
|
|
1205
1281
|
let wireMessages = stripMetaForWire([...conversationMessages]);
|
|
1282
|
+
|
|
1206
1283
|
if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
1207
1284
|
try {
|
|
1208
1285
|
const swept = await archiveToolResults({
|
|
@@ -1224,6 +1301,52 @@ export class Engine {
|
|
|
1224
1301
|
} catch { /* best-effort */ }
|
|
1225
1302
|
}
|
|
1226
1303
|
|
|
1304
|
+
// task-704b: pre-flight total-token guard. Even with the per-tool
|
|
1305
|
+
// cap (registry.js: 10% of contextWindow per result), N tool
|
|
1306
|
+
// results plus history can still breach the wire limit before we
|
|
1307
|
+
// ever call adapter.stream(). Estimate the total token cost; if
|
|
1308
|
+
// it exceeds PREFLIGHT_RATIO of the live context window, run an
|
|
1309
|
+
// emergency archive sweep with `turnAgeMin: 0` so even
|
|
1310
|
+
// current-turn-but-not-this-call bulky results get stubbed. The
|
|
1311
|
+
// normal sweep above only stubs results older than 5 user turns
|
|
1312
|
+
// — that's the wrong cadence when the *current* turn already has
|
|
1313
|
+
// 4 large grep results.
|
|
1314
|
+
//
|
|
1315
|
+
// PREFLIGHT_RATIO = 0.85 leaves ~15% of the window for the model's
|
|
1316
|
+
// own output tokens + tools metadata + light future history.
|
|
1317
|
+
// The estimator (`estimateMessagesTokens`) is approxTokens
|
|
1318
|
+
// (char/4 with CJK weighting) — good enough for a guard rail; a
|
|
1319
|
+
// real tokenizer would be exact but adds a heavy dep.
|
|
1320
|
+
if (this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
|
|
1321
|
+
const PREFLIGHT_RATIO = 0.85;
|
|
1322
|
+
const threshold = Math.floor(currentContextWindow * PREFLIGHT_RATIO);
|
|
1323
|
+
const estimate = estimateMessagesTokens(systemPrompt, wireMessages);
|
|
1324
|
+
if (estimate > threshold) {
|
|
1325
|
+
try {
|
|
1326
|
+
const sweep = await archiveToolResults({
|
|
1327
|
+
root: `${this.#yeaftDir}/memory`,
|
|
1328
|
+
scopeDir: 'user',
|
|
1329
|
+
messages: wireMessages,
|
|
1330
|
+
turnAgeMin: 0,
|
|
1331
|
+
lengthMin: this.#config?.archive?.lengthMin ?? 2000,
|
|
1332
|
+
});
|
|
1333
|
+
wireMessages = sweep.nextMessages;
|
|
1334
|
+
if (sweep.archivedCount > 0) {
|
|
1335
|
+
for (let i = 0; i < conversationMessages.length; i += 1) {
|
|
1336
|
+
conversationMessages[i] = wireMessages[i];
|
|
1337
|
+
}
|
|
1338
|
+
this.#trace.log?.('preflight_sweep', {
|
|
1339
|
+
archivedCount: sweep.archivedCount,
|
|
1340
|
+
archivedBytes: sweep.archivedBytes,
|
|
1341
|
+
estimateBefore: estimate,
|
|
1342
|
+
threshold,
|
|
1343
|
+
contextWindow: currentContextWindow,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
} catch { /* best-effort */ }
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1227
1350
|
// Stream from adapter
|
|
1228
1351
|
for await (const event of this.#adapter.stream({
|
|
1229
1352
|
model: currentModel,
|
|
@@ -1555,7 +1678,7 @@ export class Engine {
|
|
|
1555
1678
|
}
|
|
1556
1679
|
|
|
1557
1680
|
// Execute tool calls and feed results back
|
|
1558
|
-
const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona });
|
|
1681
|
+
const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona, contextWindow: currentContextWindow });
|
|
1559
1682
|
|
|
1560
1683
|
// task-325a: track whether we aborted mid tool-loop so we can
|
|
1561
1684
|
// break out of the outer while-loop cleanly once the current
|
|
@@ -1624,7 +1747,14 @@ export class Engine {
|
|
|
1624
1747
|
output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
|
|
1625
1748
|
} else {
|
|
1626
1749
|
const tool = this.#tools.get(tc.name);
|
|
1627
|
-
|
|
1750
|
+
const rawOutput = await tool.execute(tc.input, { signal });
|
|
1751
|
+
// task-704b: legacy #tools branch must apply the same per-tool
|
|
1752
|
+
// cap as ToolRegistry.execute. Otherwise a deployment using
|
|
1753
|
+
// the legacy registration path bypasses the defense entirely.
|
|
1754
|
+
output = truncateToolResultIfNeeded(rawOutput, {
|
|
1755
|
+
contextWindow: currentContextWindow,
|
|
1756
|
+
toolName: tc.name,
|
|
1757
|
+
});
|
|
1628
1758
|
}
|
|
1629
1759
|
yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false, threadId: this.currentThreadId };
|
|
1630
1760
|
} catch (err) {
|
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. */
|
package/unify/tools/types.js
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
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).
|
|
27
30
|
*/
|
|
28
31
|
|
|
29
32
|
/**
|