@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.705",
3
+ "version": "0.1.707",
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.
@@ -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
- const toolCtx = this.#buildToolContext(signal, { router, senderVpId, inboundEnvelope, taskId, taskMembers, vpPersona });
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
- output = await tool.execute(tc.input, { signal });
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
  *
@@ -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. */
@@ -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,
@@ -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
  /**