@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.705",
3
+ "version": "0.1.706",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -151,7 +151,15 @@ export async function archiveToolResults({
151
151
  };
152
152
  }
153
153
 
154
- function formatSize(bytes) {
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
- output = await tool.execute(tc.input, { signal });
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
  *
@@ -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
- return tool.execute(input, ctx);
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. */
@@ -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
  /**