@yeaft/webchat-agent 0.1.721 → 0.1.723

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.721",
3
+ "version": "0.1.723",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { LLMAdapter } from './adapter.js';
19
19
  import { getThinkingCapability, normalizeEffort } from '../models.js';
20
+ import { pairSanitize } from '../pair-sanitize.js';
20
21
 
21
22
  /**
22
23
  * task-327a: feature-flag accessor. Read lazily so tests can flip.
@@ -58,6 +59,66 @@ export function filterEffortForModel(params) {
58
59
  return { ...params, effort: norm };
59
60
  }
60
61
 
62
+ /**
63
+ * task-715: last-line-of-defense pair sanitize at the wire.
64
+ *
65
+ * `pairSanitize` already runs in two upstream paths
66
+ * (`conversation/persist.js#loadRecentByGroup` and
67
+ * `history-compact.js#compactHistory`), but the engine's main loop
68
+ * mutates `conversationMessages` AFTER those — appending tool results
69
+ * mid-loop, archiving bulky tool results into stubs, and (in failure
70
+ * paths) potentially leaving an assistant `tool_use` whose matching
71
+ * `role:'tool'` was dropped or never produced. Anthropic's Messages
72
+ * API rejects either shape with HTTP 400 ("Each tool_use block must
73
+ * have a corresponding tool_result block in the next message").
74
+ *
75
+ * The router is the SINGLE choke point through which every
76
+ * adapter.stream() / adapter.call() flows. Sanitizing here means no
77
+ * caller can accidentally bypass the guard — including the per-VP
78
+ * group-mode path that surfaced the bug.
79
+ *
80
+ * Implementation: call `pairSanitize` exactly once and compare the
81
+ * result to the input. If nothing changed (length matches AND each
82
+ * assistant kept its toolCalls count), return the original `params`
83
+ * reference so the happy path is one O(n) walk + one comparison
84
+ * walk, no allocation downstream. If something WAS dropped, return
85
+ * `{ ...params, messages: cleaned }` and log a diagnostic so a
86
+ * recurrence stays traceable in agent logs.
87
+ *
88
+ * @param {object} params
89
+ * @returns {object}
90
+ */
91
+ export function sanitizeMessagesForWire(params) {
92
+ if (!params || !Array.isArray(params.messages)) return params;
93
+ const cleaned = pairSanitize(params.messages);
94
+ if (sliceUnchanged(params.messages, cleaned)) return params;
95
+ console.warn(
96
+ `[router] dropped tool_use/tool_result orphans before wire send: ` +
97
+ `${params.messages.length} → ${cleaned.length} messages`
98
+ );
99
+ return { ...params, messages: cleaned };
100
+ }
101
+
102
+ /**
103
+ * Cheap structural equality between the original messages array and
104
+ * the post-sanitize result. Same length AND every assistant kept its
105
+ * toolCalls count = no orphans were dropped. We do NOT compare full
106
+ * deep equality — `pairSanitize` only ever shrinks, never reorders or
107
+ * mutates payloads.
108
+ */
109
+ function sliceUnchanged(original, cleaned) {
110
+ if (original.length !== cleaned.length) return false;
111
+ for (let i = 0; i < original.length; i += 1) {
112
+ const a = original[i];
113
+ const b = cleaned[i];
114
+ if (!a || !b) continue;
115
+ const aCalls = Array.isArray(a.toolCalls) ? a.toolCalls.length : 0;
116
+ const bCalls = Array.isArray(b.toolCalls) ? b.toolCalls.length : 0;
117
+ if (aCalls !== bCalls) return false;
118
+ }
119
+ return true;
120
+ }
121
+
61
122
  /**
62
123
  * AdapterRouter — Implements LLMAdapter, routes by model → provider.
63
124
  */
@@ -178,8 +239,9 @@ export class AdapterRouter extends LLMAdapter {
178
239
  */
179
240
  async *stream(params) {
180
241
  const filtered = filterEffortForModel(params);
181
- const adapter = await this.#resolveAdapter(filtered.model);
182
- yield* adapter.stream(filtered);
242
+ const sanitized = sanitizeMessagesForWire(filtered);
243
+ const adapter = await this.#resolveAdapter(sanitized.model);
244
+ yield* adapter.stream(sanitized);
183
245
  }
184
246
 
185
247
  /**
@@ -190,8 +252,9 @@ export class AdapterRouter extends LLMAdapter {
190
252
  */
191
253
  async call(params) {
192
254
  const filtered = filterEffortForModel(params);
193
- const adapter = await this.#resolveAdapter(filtered.model);
194
- return adapter.call(filtered);
255
+ const sanitized = sanitizeMessagesForWire(filtered);
256
+ const adapter = await this.#resolveAdapter(sanitized.model);
257
+ return adapter.call(sanitized);
195
258
  }
196
259
 
197
260
  /**