@yeaft/webchat-agent 0.1.702 → 0.1.704

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.702",
3
+ "version": "0.1.704",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -74,10 +74,11 @@ const MAX_CONTINUE_TURNS = 3;
74
74
  * - `toolCalls` on assistant turns (the LLM's function_call requests)
75
75
  * - `toolCallId` + `isError` on tool turns (the paired tool_result)
76
76
  *
77
- * Content is truncated at 50000 chars; each tool_call input is JSON-stringified
78
- * + sliced at 10000 chars before being re-parsed, so a runaway `input` blob
79
- * can't blow past the WebSocket frame budget. Unknown roles pass through
80
- * unchanged.
77
+ * Content is passed through verbatim never truncated. Debug traces must
78
+ * mirror exactly what we sent to the LLM; a truncated copy is misleading.
79
+ * If the resulting payload is too large for the client debug store the
80
+ * bound is per-loop-count (see `MAX_UNIFY_DEBUG_LOOPS` in
81
+ * `web/stores/chat.js`), not per-payload mutilation here.
81
82
  *
82
83
  * Pure function — no side effects on the input message.
83
84
  *
@@ -86,22 +87,13 @@ const MAX_CONTINUE_TURNS = 3;
86
87
  */
87
88
  export function mapDebugMessage(m) {
88
89
  const out = { role: m.role };
89
- out.content = typeof m.content === 'string' ? m.content.slice(0, 50000) : m.content;
90
+ out.content = m.content;
90
91
  if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
91
- out.toolCalls = m.toolCalls.map(tc => {
92
- let input = tc.input;
93
- try {
94
- const s = JSON.stringify(input);
95
- if (typeof s === 'string' && s.length > 10000) {
96
- input = { __truncated: true, preview: s.slice(0, 10000) };
97
- }
98
- } catch {
99
- // Non-serializable input — fall through with raw reference; the
100
- // frontend's JSON.stringify will hit the same failure and replace
101
- // it with a placeholder string.
102
- }
103
- return { id: tc.id, name: tc.name, input };
104
- });
92
+ out.toolCalls = m.toolCalls.map(tc => ({
93
+ id: tc.id,
94
+ name: tc.name,
95
+ input: tc.input,
96
+ }));
105
97
  }
106
98
  if (m.toolCallId) out.toolCallId = m.toolCallId;
107
99
  if (m.isError != null) out.isError = m.isError;
@@ -105,65 +105,20 @@ export class LLMAbortError extends Error {
105
105
  }
106
106
  }
107
107
 
108
- // ─── task-344: Raw payload redaction helper ────────────────────
109
-
110
- /**
111
- * task-344 follow-up (N2): cap raw payload size exposed via onRawExchange.
112
- * Prevents the web debug store from linear-growing when a single turn
113
- * sends / receives megabytes of content. 256 KiB per field per turn.
114
- */
115
- export const RAW_PAYLOAD_CAP_BYTES = 256 * 1024;
116
-
117
- /**
118
- * Truncate a string to at most `cap` bytes (UTF-8). When within cap the
119
- * original is returned; otherwise a prefix with a trailing
120
- * `…[truncated, original N bytes]` marker. Non-string inputs pass through.
121
- *
122
- * @param {string} s
123
- * @param {number} [cap=RAW_PAYLOAD_CAP_BYTES]
124
- * @returns {string}
125
- */
126
- export function capRawString(s, cap = RAW_PAYLOAD_CAP_BYTES) {
127
- if (typeof s !== 'string') return s;
128
- const encoder = new TextEncoder();
129
- const fullBytes = encoder.encode(s);
130
- if (fullBytes.length <= cap) return s;
131
- const decoder = new TextDecoder('utf-8', { fatal: false });
132
- const prefix = decoder.decode(fullBytes.slice(0, cap));
133
- return `${prefix}…[truncated, original ${fullBytes.length} bytes]`;
134
- }
135
-
136
- /**
137
- * Cap the `body` field of a rawRequest envelope. JSON-stringifies objects
138
- * before sizing so an oversized `messages` array does not escape the cap.
139
- * When truncation fires, body becomes a string (JSON prefix + marker);
140
- * objects under cap stay objects.
141
- *
142
- * @param {{ url: string, method: string, headers: object, body: any }} req
143
- * @param {number} [cap=RAW_PAYLOAD_CAP_BYTES]
144
- * @returns {{ url: string, method: string, headers: object, body: any }}
145
- */
146
- export function capRawRequest(req, cap = RAW_PAYLOAD_CAP_BYTES) {
147
- if (!req || typeof req !== 'object') return req;
148
- let body = req.body;
149
- if (body != null && typeof body !== 'string') {
150
- let serialized;
151
- try { serialized = JSON.stringify(body); }
152
- catch { serialized = String(body); }
153
- if (typeof serialized === 'string' && new TextEncoder().encode(serialized).length > cap) {
154
- body = capRawString(serialized, cap);
155
- }
156
- } else if (typeof body === 'string') {
157
- body = capRawString(body, cap);
158
- }
159
- return { url: req.url, method: req.method, headers: req.headers, body };
160
- }
108
+ // ─── Raw payload redaction helper ──────────────────────────────
161
109
 
162
110
  /**
163
111
  * Redact sensitive headers (API keys / bearer tokens) from a raw request
164
112
  * shape before exposing it to debug UI. Always returns a NEW object — never
165
113
  * mutates the input.
166
114
  *
115
+ * NOTE: there is intentionally NO body / response truncation here. The whole
116
+ * point of the "copy request" debug feature is to capture EXACTLY what we
117
+ * sent to the LLM. A truncated copy is worse than useless — it lies about
118
+ * what the model saw. If the resulting payload is too large for the debug
119
+ * store, the fix is to bound retention (drop oldest turns), not to mutilate
120
+ * individual payloads. See `MAX_UNIFY_DEBUG_LOOPS` in `web/stores/chat.js`.
121
+ *
167
122
  * @param {{ url: string, method: string, headers: object, body: any }} req
168
123
  * @returns {{ url: string, method: string, headers: object, body: any }}
169
124
  */
@@ -180,6 +135,27 @@ export function redactRawRequest(req) {
180
135
  return { url: req.url, method: req.method, headers, body: req.body };
181
136
  }
182
137
 
138
+ /**
139
+ * Snapshot a Fetch Response's headers into a plain object for the debug
140
+ * panel. Defensive against polyfilled / mocked Response shapes that don't
141
+ * implement `Headers#entries()` — falls back to `{}` rather than throwing.
142
+ *
143
+ * NOTE: multi-valued headers (e.g. `Set-Cookie`) collapse to the last value
144
+ * because `Object.fromEntries` can't represent duplicates. For LLM debug
145
+ * traffic this is fine; if a future use case needs multi-valued capture,
146
+ * switch the return to an array of [k, v] pairs.
147
+ *
148
+ * @param {Response | { headers?: { entries?: () => Iterable<[string, string]> } }} response
149
+ * @returns {Record<string, string>}
150
+ */
151
+ export function safeHeaders(response) {
152
+ const h = response && response.headers;
153
+ if (h && typeof h.entries === 'function') {
154
+ return Object.fromEntries(h.entries());
155
+ }
156
+ return {};
157
+ }
158
+
183
159
  // ─── Base Class ────────────────────────────────────────────────
184
160
 
185
161
  /**
@@ -14,9 +14,7 @@ import {
14
14
  LLMServerError,
15
15
  LLMAbortError,
16
16
  redactRawRequest,
17
- capRawRequest,
18
- capRawString,
19
- RAW_PAYLOAD_CAP_BYTES,
17
+ safeHeaders,
20
18
  } from './adapter.js';
21
19
  import {
22
20
  normalizeEffort,
@@ -184,9 +182,10 @@ export class AnthropicAdapter extends LLMAdapter {
184
182
  'anthropic-version': API_VERSION,
185
183
  };
186
184
 
187
- // task-344: expose raw request (redacted) for debug panel.
188
- // task-344 follow-up (N2): cap body to RAW_PAYLOAD_CAP_BYTES.
189
- const rawRequest = capRawRequest(redactRawRequest({ url, method: 'POST', headers, body }));
185
+ // Expose the raw request (auth-redacted) for the debug panel. The body
186
+ // is captured verbatim never truncated — so "copy request" matches
187
+ // exactly what we POST to the LLM.
188
+ const rawRequest = redactRawRequest({ url, method: 'POST', headers, body });
190
189
 
191
190
  const response = await fetch(url, {
192
191
  method: 'POST',
@@ -197,18 +196,15 @@ export class AnthropicAdapter extends LLMAdapter {
197
196
 
198
197
  if (!response.ok) {
199
198
  const errorBody = await response.text();
200
- // task-344: capture error response too, then throw.
199
+ // Capture error response too, then throw.
201
200
  if (onRawExchange) {
202
201
  try {
203
202
  onRawExchange({
204
203
  rawRequest,
205
204
  rawResponse: {
206
205
  status: response.status,
207
- headers: response.headers && typeof response.headers.entries === 'function'
208
- ? Object.fromEntries(response.headers.entries())
209
- : {},
210
- // task-344 follow-up (N2): cap error body.
211
- body: capRawString(errorBody),
206
+ headers: safeHeaders(response),
207
+ body: errorBody,
212
208
  },
213
209
  });
214
210
  } catch { /* ignore */ }
@@ -223,15 +219,12 @@ export class AnthropicAdapter extends LLMAdapter {
223
219
  let currentToolCallId = null;
224
220
  let currentToolName = null;
225
221
  let currentToolInput = '';
226
- // task-344: accumulate raw SSE body for debug exposure.
227
- // task-344 follow-up (N2): cap growth once past RAW_PAYLOAD_CAP_BYTES
228
- // we freeze the captured body and record total-bytes-seen separately.
229
- let rawSseBody = '';
230
- let rawSseTotalBytes = 0;
231
- let rawSseCapped = false;
232
- const responseHeaders = response.headers && typeof response.headers.entries === 'function'
233
- ? Object.fromEntries(response.headers.entries())
234
- : {};
222
+ // Accumulate raw SSE body verbatim for the debug panel. No truncation:
223
+ // see `redactRawRequest` in adapter.js for the verbatim-design rationale.
224
+ // Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
225
+ // O(n²) string concat).
226
+ const rawSseBodyChunks = [];
227
+ const responseHeaders = safeHeaders(response);
235
228
  const responseStatus = response.status;
236
229
 
237
230
  try {
@@ -241,20 +234,7 @@ export class AnthropicAdapter extends LLMAdapter {
241
234
 
242
235
  const chunkText = decoder.decode(value, { stream: true });
243
236
  buffer += chunkText;
244
- // task-344 follow-up (N2): size-capped capture.
245
- rawSseTotalBytes += value.byteLength;
246
- if (!rawSseCapped) {
247
- if (rawSseTotalBytes <= RAW_PAYLOAD_CAP_BYTES) {
248
- rawSseBody += chunkText;
249
- } else {
250
- // Append enough of this chunk to meet the cap, then freeze.
251
- const remaining = RAW_PAYLOAD_CAP_BYTES - (rawSseTotalBytes - value.byteLength);
252
- if (remaining > 0) {
253
- rawSseBody += chunkText.slice(0, remaining);
254
- }
255
- rawSseCapped = true;
256
- }
257
- }
237
+ rawSseBodyChunks.push(chunkText);
258
238
  const lines = buffer.split('\n');
259
239
  buffer = lines.pop() || ''; // Keep incomplete line
260
240
 
@@ -344,19 +324,16 @@ export class AnthropicAdapter extends LLMAdapter {
344
324
  }
345
325
  } finally {
346
326
  reader.releaseLock();
347
- // task-344: emit raw exchange after stream completes (or errors).
327
+ // Emit raw exchange after stream completes (or errors). Body is the
328
+ // verbatim SSE — never truncated.
348
329
  if (onRawExchange) {
349
330
  try {
350
- // task-344 follow-up (N2): append truncation marker when capped.
351
- const finalBody = rawSseCapped
352
- ? `${rawSseBody}…[truncated, original ${rawSseTotalBytes} bytes]`
353
- : rawSseBody;
354
331
  onRawExchange({
355
332
  rawRequest,
356
333
  rawResponse: {
357
334
  status: responseStatus,
358
335
  headers: responseHeaders,
359
- body: finalBody,
336
+ body: rawSseBodyChunks.join(''),
360
337
  format: 'sse',
361
338
  },
362
339
  });
@@ -32,6 +32,8 @@ import {
32
32
  LLMContextError,
33
33
  LLMServerError,
34
34
  LLMAbortError,
35
+ redactRawRequest,
36
+ safeHeaders,
35
37
  } from './adapter.js';
36
38
  import {
37
39
  normalizeEffort,
@@ -225,9 +227,16 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
225
227
  // ─── Streaming ──────────────────────────────────────────
226
228
 
227
229
  /**
228
- * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal }} params
230
+ * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'max', extraBody?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
231
+ *
232
+ * NOTE on `extraBody`: any keys you spread here are merged verbatim into
233
+ * the wire body and — because the verbatim debug feature is intentionally
234
+ * non-truncating — will surface in the debug panel via `rawRequest.body`.
235
+ * Do NOT put secrets in `extraBody`. Only `Authorization` / `x-api-key` /
236
+ * `api-key` headers are auto-redacted (see `redactRawRequest` in
237
+ * `adapter.js`); request-body fields are caller-controlled.
229
238
  */
230
- async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal }) {
239
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, extraBody, signal, onRawExchange }) {
231
240
  if (signal?.aborted) throw new LLMAbortError();
232
241
 
233
242
  const body = {
@@ -255,14 +264,21 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
255
264
 
256
265
  if (extraBody) Object.assign(body, extraBody);
257
266
 
267
+ const url = `${this.#baseUrl}/responses`;
268
+ const headers = {
269
+ 'Content-Type': 'application/json',
270
+ 'Authorization': `Bearer ${this.#apiKey}`,
271
+ };
272
+
273
+ // Expose the raw request (auth-redacted) for the debug panel. See
274
+ // `redactRawRequest` in adapter.js for the verbatim-design rationale.
275
+ const rawRequest = redactRawRequest({ url, method: 'POST', headers, body });
276
+
258
277
  let response;
259
278
  try {
260
- response = await fetch(`${this.#baseUrl}/responses`, {
279
+ response = await fetch(url, {
261
280
  method: 'POST',
262
- headers: {
263
- 'Content-Type': 'application/json',
264
- 'Authorization': `Bearer ${this.#apiKey}`,
265
- },
281
+ headers,
266
282
  body: JSON.stringify(body),
267
283
  signal,
268
284
  });
@@ -273,6 +289,19 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
273
289
 
274
290
  if (!response.ok) {
275
291
  const errorBody = await response.text();
292
+ // Capture error response too, then throw. Parity with anthropic.js.
293
+ if (onRawExchange) {
294
+ try {
295
+ onRawExchange({
296
+ rawRequest,
297
+ rawResponse: {
298
+ status: response.status,
299
+ headers: safeHeaders(response),
300
+ body: errorBody,
301
+ },
302
+ });
303
+ } catch { /* ignore */ }
304
+ }
276
305
  throw this.#classifyError(response.status, errorBody);
277
306
  }
278
307
 
@@ -287,11 +316,21 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
287
316
  const emittedToolCallIds = new Set();
288
317
  let sawToolCall = false;
289
318
 
319
+ // Accumulate raw SSE body verbatim for the debug panel. No truncation:
320
+ // see `redactRawRequest` in adapter.js for the verbatim-design rationale.
321
+ // Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
322
+ // O(n²) string concat).
323
+ const rawSseBodyChunks = [];
324
+ const responseHeaders = safeHeaders(response);
325
+ const responseStatus = response.status;
326
+
290
327
  try {
291
328
  while (true) {
292
329
  const { done, value } = await reader.read();
293
330
  if (done) break;
294
- buffer += decoder.decode(value, { stream: true });
331
+ const chunkText = decoder.decode(value, { stream: true });
332
+ buffer += chunkText;
333
+ rawSseBodyChunks.push(chunkText);
295
334
 
296
335
  // SSE events are separated by blank lines; split on \n
297
336
  const lines = buffer.split('\n');
@@ -409,11 +448,33 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
409
448
  throw err;
410
449
  } finally {
411
450
  try { reader.releaseLock(); } catch { /* noop */ }
451
+ // Emit raw exchange after stream completes (or errors). Body is the
452
+ // verbatim SSE — never truncated. Parity with anthropic.js.
453
+ if (onRawExchange) {
454
+ try {
455
+ onRawExchange({
456
+ rawRequest,
457
+ rawResponse: {
458
+ status: responseStatus,
459
+ headers: responseHeaders,
460
+ body: rawSseBodyChunks.join(''),
461
+ format: 'sse',
462
+ },
463
+ });
464
+ } catch { /* ignore */ }
465
+ }
412
466
  }
413
467
  }
414
468
 
415
469
  // ─── Non-streaming call() ───────────────────────────────
416
470
 
471
+ /**
472
+ * Side-query (consolidate / dream / recall / light) entry point. Does
473
+ * NOT accept `onRawExchange` — these calls intentionally don't surface
474
+ * in the user-facing debug panel. If a future product change wants to
475
+ * expose them, mirror the stream() instrumentation. Parity with
476
+ * anthropic.js's `call()`.
477
+ */
417
478
  async call({ model, system, messages, maxTokens = 4096, effort, extraBody, signal }) {
418
479
  if (signal?.aborted) throw new LLMAbortError();
419
480