@yeaft/webchat-agent 0.1.721 → 0.1.722
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/llm/router.js +67 -4
package/package.json
CHANGED
package/unify/llm/router.js
CHANGED
|
@@ -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
|
|
182
|
-
|
|
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
|
|
194
|
-
|
|
255
|
+
const sanitized = sanitizeMessagesForWire(filtered);
|
|
256
|
+
const adapter = await this.#resolveAdapter(sanitized.model);
|
|
257
|
+
return adapter.call(sanitized);
|
|
195
258
|
}
|
|
196
259
|
|
|
197
260
|
/**
|