@yeaft/webchat-agent 0.1.974 → 0.1.976

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.974",
3
+ "version": "0.1.976",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -48,6 +48,7 @@ const SCHEMA = `
48
48
  tool_name TEXT NOT NULL,
49
49
  tool_input TEXT,
50
50
  tool_output TEXT,
51
+ tool_call_id TEXT,
51
52
  duration_ms INTEGER,
52
53
  is_error INTEGER DEFAULT 0,
53
54
  created_at INTEGER NOT NULL,
@@ -101,12 +102,12 @@ function migrateAddColumn(db, table, column, type) {
101
102
  }
102
103
  }
103
104
 
104
- /** Max tool_output size stored (10KB). Longer outputs are truncated. */
105
- const MAX_TOOL_OUTPUT = 10240;
105
+ /** Max tool input size stored inline. Tool output is persisted raw. */
106
+ const MAX_TOOL_INPUT = 10240;
106
107
 
107
108
  /**
108
109
  * Max per-loop payload (system prompt, messages JSON, raw request /
109
- * response, response text) stored per row. Larger than MAX_TOOL_OUTPUT
110
+ * response, response text) stored per row. Larger than MAX_TOOL_INPUT
110
111
  * because real-world LLM exchanges (system prompt + 30K-token message
111
112
  * trail + raw response) routinely cross 10KB. 256KB lets us replay the
112
113
  * panel verbatim for the most recent traces without bloating the DB.
@@ -207,6 +208,7 @@ export class DebugTrace {
207
208
  // snapshot — `messages.find(role==='user')` would return turn 1's
208
209
  // text for every subsequent turn, mislabeling every Turn header.
209
210
  migrateAddColumn(this.#db, 'trace_turns', 'user_prompt', 'TEXT');
211
+ migrateAddColumn(this.#db, 'trace_tools', 'tool_call_id', 'TEXT');
210
212
  // Indexes on the just-added columns. Must run AFTER the ALTER TABLEs
211
213
  // — running them inside SCHEMA's CREATE INDEX IF NOT EXISTS block
212
214
  // would fail with "no such column: group_id" on a pre-bugfix DB.
@@ -308,11 +310,12 @@ export class DebugTrace {
308
310
  /**
309
311
  * Log a tool call within a turn.
310
312
  * @param {string} turnId
311
- * @param {{ toolName: string, toolInput?: string, toolOutput?: string, durationMs?: number, isError?: boolean }} info
313
+ * @param {{ toolName: string, toolCallId?: string|null, toolInput?: string, toolOutput?: string, durationMs?: number, isError?: boolean }} info
312
314
  * @returns {string} — tool record id
313
315
  */
314
316
  logTool(turnId, {
315
317
  toolName,
318
+ toolCallId = null,
316
319
  toolInput = null,
317
320
  toolOutput = null,
318
321
  durationMs = null,
@@ -321,12 +324,13 @@ export class DebugTrace {
321
324
  const id = randomUUID();
322
325
  const now = Date.now();
323
326
  this.#prepare('insertTool', `
324
- INSERT INTO trace_tools (id, turn_id, tool_name, tool_input, tool_output, duration_ms, is_error, created_at)
325
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
327
+ INSERT INTO trace_tools (id, turn_id, tool_name, tool_input, tool_output, tool_call_id, duration_ms, is_error, created_at)
328
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
326
329
  `).run(
327
330
  id, turnId, toolName,
328
- truncate(toolInput, MAX_TOOL_OUTPUT),
329
- truncate(toolOutput, MAX_TOOL_OUTPUT),
331
+ truncate(toolInput, MAX_TOOL_INPUT),
332
+ toolOutput == null ? null : String(toolOutput),
333
+ toolCallId,
330
334
  durationMs, isError ? 1 : 0, now,
331
335
  );
332
336
  return id;
@@ -518,8 +522,10 @@ export class DebugTrace {
518
522
  if (!t) continue;
519
523
  t.tools.push({
520
524
  loopNumber: owner.turn_number || 0,
521
- callId: tool.id,
525
+ callId: tool.tool_call_id || tool.id,
526
+ traceToolId: tool.id,
522
527
  name: tool.tool_name,
528
+ toolOutput: tool.tool_output == null ? null : String(tool.tool_output),
523
529
  durationMs: tool.duration_ms || 0,
524
530
  isError: !!tool.is_error,
525
531
  });
package/yeaft/engine.js CHANGED
@@ -43,7 +43,7 @@ import { countTurns } from './turn-utils.js';
43
43
  import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
44
44
  import { resolveThinking } from './router/thinking.js';
45
45
  import { approxTokens } from './memory/budget.js';
46
- import { COLLAB_TOOL_POLICY, truncateToolResultIfNeeded } from './tools/registry.js';
46
+ import { COLLAB_TOOL_POLICY, normalizeToolOutput, truncateToolResultIfNeeded } from './tools/registry.js';
47
47
  import { acknowledgePendingNotifications, formatNotificationsForPrompt, peekPendingNotifications } from './sub-agent/notifications.js';
48
48
  import {
49
49
  TOOL_BATCH_SIZE,
@@ -2489,13 +2489,7 @@ export class Engine {
2489
2489
  // is exercised by tests and a few standalone tools. Aligning
2490
2490
  // both paths keeps `ctx.cwd` semantics consistent.
2491
2491
  const rawOutput = await tool.execute(tc.input, toolCtx);
2492
- // Legacy #tools branch must apply the same per-tool cap as
2493
- // ToolRegistry.execute. Otherwise a deployment using the legacy
2494
- // registration path bypasses the defense entirely.
2495
- output = truncateToolResultIfNeeded(rawOutput, {
2496
- toolName: tc.name,
2497
- language: this.#config?.language,
2498
- });
2492
+ output = normalizeToolOutput(rawOutput);
2499
2493
  }
2500
2494
  yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false, threadId: this.currentThreadId };
2501
2495
  } catch (err) {
@@ -2508,9 +2502,8 @@ export class Engine {
2508
2502
  const toolDurationMs = Date.now() - toolStartTime;
2509
2503
 
2510
2504
  // feat-6af5f9f1 PR B: emit a structured `tool_exec` event for the
2511
- // debug panel. Args/output are already in `conversationMessages`
2512
- // and will be visible in the next loop's snapshot, so we don't
2513
- // duplicate them here — only the per-tool timing + status.
2505
+ // debug panel. Keep raw output here; the model-facing tool
2506
+ // message below is deliberately truncated for context budget.
2514
2507
  yield {
2515
2508
  type: 'tool_exec',
2516
2509
  turnId: queryTurnId,
@@ -2520,6 +2513,7 @@ export class Engine {
2520
2513
  name: tc.name,
2521
2514
  durationMs: toolDurationMs,
2522
2515
  isError,
2516
+ toolOutput: output,
2523
2517
  };
2524
2518
 
2525
2519
  // 2026-05-13: feed the per-tool counters. Stays best-effort — a
@@ -2539,17 +2533,25 @@ export class Engine {
2539
2533
  // Log tool to debug trace
2540
2534
  this.#trace.logTool(turnId, {
2541
2535
  toolName: tc.name,
2536
+ toolCallId: tc.id,
2542
2537
  toolInput: JSON.stringify(tc.input),
2543
2538
  toolOutput: output,
2544
2539
  durationMs: toolDurationMs,
2545
2540
  isError,
2541
+ toolOutput: output,
2546
2542
  });
2547
2543
 
2548
- // Append tool result to conversation
2544
+ // Append only the bounded copy to the model message history. Raw
2545
+ // `output` is still used for debug traces, UI events, exec-log, and
2546
+ // persistence so large tool results are not lost outside context.
2547
+ const contextOutput = truncateToolResultIfNeeded(output, {
2548
+ toolName: tc.name,
2549
+ language: this.#config?.language,
2550
+ });
2549
2551
  conversationMessages.push({
2550
2552
  role: 'tool',
2551
2553
  toolCallId: tc.id,
2552
- content: output,
2554
+ content: contextOutput,
2553
2555
  isError,
2554
2556
  });
2555
2557
 
@@ -54,12 +54,28 @@
54
54
 
55
55
  import { estimateTokens } from './conversation/persist.js';
56
56
  import { pairSanitize } from './pair-sanitize.js';
57
+ import { truncateToolResultIfNeeded } from './tools/registry.js';
57
58
  import {
58
59
  countTurns as countTurnsImpl,
59
60
  indexOfNthTurnFromEnd,
60
61
  sliceLastNTurns,
61
62
  } from './turn-utils.js';
62
63
 
64
+
65
+ function truncateToolResultsForModel(messages, opts = {}) {
66
+ if (!Array.isArray(messages) || messages.length === 0) return [];
67
+ return messages.map((m) => {
68
+ if (!m || m.role !== 'tool' || typeof m.content !== 'string') return { ...m };
69
+ return {
70
+ ...m,
71
+ content: truncateToolResultIfNeeded(m.content, {
72
+ toolName: m.name || m.toolName || 'tool_result',
73
+ language: opts.language,
74
+ }),
75
+ };
76
+ });
77
+ }
78
+
63
79
  /**
64
80
  * Re-export `countTurns` so existing callers / tests that import it
65
81
  * from this module continue to work. Implementation now lives in
@@ -706,7 +722,7 @@ export async function compactHistory(messages, options) {
706
722
  * between trim (per-call) and compact (global) explicit.
707
723
  *
708
724
  * @param {Array<object>} snapshot
709
- * @param {{ messageTokenBudget?: number, recentTurnCap?: number, keepToolTurns?: number }} [opts]
725
+ * @param {{ messageTokenBudget?: number, recentTurnCap?: number, keepToolTurns?: number, language?: string }} [opts]
710
726
  * @returns {Array<object>}
711
727
  */
712
728
  export function trimSnapshotForBudget(snapshot, opts = {}) {
@@ -738,6 +754,11 @@ export function trimSnapshotForBudget(snapshot, opts = {}) {
738
754
  keepToolTurns: opts.keepToolTurns,
739
755
  });
740
756
 
741
- // Stage 4: pair-sanitize to drop orphan tool_use/tool_result.
757
+ // Stage 4: bound the raw tool result copy that is fed back into the model.
758
+ // The in-memory/persisted transcript keeps the full content; this transform
759
+ // only affects the per-query snapshot passed to engine.query().
760
+ trimmed = truncateToolResultsForModel(trimmed, { language: opts.language });
761
+
762
+ // Stage 5: pair-sanitize to drop orphan tool_use/tool_result.
742
763
  return pairSanitize(trimmed);
743
764
  }
@@ -33,19 +33,17 @@ export const SUB_AGENT_TOOL_NAMES = Object.freeze([
33
33
  export const FORWARD_TOOL_NAMES = Object.freeze(['RouteForward']);
34
34
 
35
35
  /**
36
- * Per-tool-result hard cap.
36
+ * Per-tool-result model-context cap.
37
37
  *
38
38
  * A single tool can return megabytes (a grep over a large repo, a large file
39
39
  * read, a paginated web fetch). If we forward that verbatim into the next LLM
40
- * request, persistence, or UI event, it bloats context and makes every replay
41
- * expensive. Keep the hard boundary small and deterministic: one tool result
40
+ * request, it bloats context and makes every replay expensive. Keep the
41
+ * boundary small and deterministic for LLM message history: one tool result
42
42
  * gets at most 1 KiB before a visible truncation marker is appended.
43
43
  *
44
- * The truncation lands HERE (not in engine.js when pushing tool results
45
- * into messages) so the UI's `tool_end` event, the exec log, AND the
46
- * model all see the same truncated content. Truncating later would mean
47
- * the user sees the full 2 MB output but the model gets a stub —
48
- * confusing.
44
+ * Do NOT apply this at tool execution time. Debug events, exec logs, and
45
+ * persisted transcripts need the raw result. The engine/history replay path
46
+ * applies this only when building messages for the model.
49
47
  */
50
48
  export const TOOL_RESULT_MAX_BYTES = 1024;
51
49
 
@@ -170,7 +168,7 @@ export class ToolExecutionTimeoutError extends Error {
170
168
  * @param {{ toolName: string, language?: string }} opts
171
169
  * @returns {string}
172
170
  */
173
- export function truncateToolResultIfNeeded(output, { toolName, language } = {}) {
171
+ export function normalizeToolOutput(output) {
174
172
  let text;
175
173
  if (typeof output === 'string') {
176
174
  text = output;
@@ -182,6 +180,11 @@ export function truncateToolResultIfNeeded(output, { toolName, language } = {})
182
180
  text = String(output);
183
181
  }
184
182
  }
183
+ return text;
184
+ }
185
+
186
+ export function truncateToolResultIfNeeded(output, { toolName, language } = {}) {
187
+ const text = normalizeToolOutput(output);
185
188
  const originalBytes = Buffer.byteLength(text, 'utf8');
186
189
  if (originalBytes <= TOOL_RESULT_MAX_BYTES) return text;
187
190
 
@@ -195,8 +198,8 @@ export function truncateToolResultIfNeeded(output, { toolName, language } = {})
195
198
  }
196
199
  const head = chunks.join('');
197
200
  const marker = normalizeLanguage(language) === 'zh'
198
- ? `\n\n[已截断:${toolName} 返回 ${formatSize(originalBytes)},上限为 ${formatSize(TOOL_RESULT_MAX_BYTES)};原因:单个 tool result 超过 1KB,模型和持久化展示不会看到剩余内容]`
199
- : `\n\n[truncated: ${toolName} returned ${formatSize(originalBytes)}, capped at ${formatSize(TOOL_RESULT_MAX_BYTES)}; reason: single tool result exceeded 1KB, the model and persisted display will not see the rest]`;
201
+ ? `\n\n[已截断:${toolName} 返回 ${formatSize(originalBytes)},上限为 ${formatSize(TOOL_RESULT_MAX_BYTES)};原因:单个 tool result 超过 1KB,模型消息历史不会看到剩余内容]`
202
+ : `\n\n[truncated: ${toolName} returned ${formatSize(originalBytes)}, capped at ${formatSize(TOOL_RESULT_MAX_BYTES)}; reason: single tool result exceeded 1KB, the model message history will not see the rest]`;
200
203
  return head + marker;
201
204
  }
202
205
 
@@ -372,9 +375,9 @@ export class ToolRegistry {
372
375
  /**
373
376
  * Execute a tool by name.
374
377
  *
375
- * The result is passed through {@link truncateToolResultIfNeeded} so that
376
- * a single tool result never injects more than 1KB before the visible
377
- * truncation marker.
378
+ * Returns the raw tool result as text. Do not truncate here: debug display,
379
+ * exec logs, and persistence must retain the full result. The engine/history
380
+ * replay path truncates only the copy inserted into model message history.
378
381
  *
379
382
  * @param {string} name
380
383
  * @param {object} input
@@ -398,10 +401,7 @@ export class ToolRegistry {
398
401
  ? await runWithTimeout(tool.execute(input, ctx), rawTimeout, name)
399
402
  : await tool.execute(input, ctx);
400
403
 
401
- return truncateToolResultIfNeeded(output, {
402
- toolName: name,
403
- language: ctx.config?.language,
404
- });
404
+ return normalizeToolOutput(output);
405
405
  }
406
406
 
407
407
  /** Number of registered tools. */
@@ -1312,15 +1312,125 @@ const DEFAULT_VP_PERSONA_ZH = Object.freeze({
1312
1312
  }
1313
1313
  });
1314
1314
 
1315
+
1316
+ function cleanDefaultPersonaFragment(value) {
1317
+ let text = String(value || '').trim();
1318
+ text = text.replace(/cross-VP/g, 'cross-member').replace(/Cross-VP/g, 'Cross-member');
1319
+ text = text.replace(/\bdevelopment VP\b/g, 'developer');
1320
+ text = text.replace(/\bVP\b/g, '');
1321
+ text = text.replace(/generic assistant/gi, 'generic helper');
1322
+ text = text.replace(/generic helper/g, 'generic coordinator');
1323
+ text = text.replace(/\s+,/g, ',').replace(/\s+\./g, '.').replace(/ {2,}/g, ' ');
1324
+ return text.trim();
1325
+ }
1326
+
1327
+ function sentenceFromFragment(value) {
1328
+ const text = cleanDefaultPersonaFragment(value);
1329
+ if (!text) return '';
1330
+ const first = text[0].toUpperCase() + text.slice(1);
1331
+ return /[.!?"”]$/.test(first) ? first : `${first}.`;
1332
+ }
1333
+
1334
+ function firstSection(value) {
1335
+ return String(value || '').split(/\n\n(?:Core capabilities|Traits|Strengths|Problem-solving style|Decision style|Catchphrases|Good for|Bad for|Expected from you|Answer style):/)[0].trim();
1336
+ }
1337
+
1338
+ function extractLabel(value, label) {
1339
+ const text = String(value || '');
1340
+ const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1341
+ const match = text.match(new RegExp(`\\n\\n${escaped}: ([\\s\\S]*?)(?=\\n\\n[A-Z][A-Za-z -]+:|\\nBad for:|$)`));
1342
+ return match ? match[1].trim() : '';
1343
+ }
1344
+
1345
+ function extractBulletDetails(value) {
1346
+ const details = [];
1347
+ for (const match of String(value || '').matchAll(/^- [^:\n]+: ([^\n]+)$/gm)) {
1348
+ details.push(sentenceFromFragment(match[1]));
1349
+ }
1350
+ return details;
1351
+ }
1352
+
1353
+ function cleanDefaultPersonaIdentityEn(value) {
1354
+ let text = String(value || '').trim();
1355
+ text = text.replace(/cross-VP/g, 'cross-member').replace(/Cross-VP/g, 'Cross-member');
1356
+ text = text.replace(/\bdevelopment VP\b/g, 'developer');
1357
+ text = text.replace(/You are Omni, a VP responsible for ([^\.]+)\./g, 'You are Omni. You are responsible for $1.');
1358
+ text = text.replace(/You are ([^\.\n]+?), a ([^\.\n]*?) VP\. /g, 'You are $1. You bring $2 judgment. ');
1359
+ text = text.replace(/\bVP\b/g, '');
1360
+ text = text.replace(/generic assistant/gi, 'generic helper');
1361
+ text = text.replace(/generic helper/g, 'generic coordinator');
1362
+ text = text.replace(/\s+,/g, ',').replace(/\s+\./g, '.').replace(/ {2,}/g, ' ');
1363
+ return text.trim();
1364
+ }
1365
+
1366
+ function naturalizeDefaultPersonaEn(value) {
1367
+ const raw = String(value || '').trim();
1368
+ const identity = cleanDefaultPersonaIdentityEn(firstSection(raw));
1369
+ const core = extractBulletDetails(raw);
1370
+ const traits = extractLabel(raw, 'Traits');
1371
+ const strengths = extractLabel(raw, 'Strengths');
1372
+ const problemSolving = extractLabel(raw, 'Problem-solving style');
1373
+ const decision = extractLabel(raw, 'Decision style');
1374
+ const catchphrases = extractLabel(raw, 'Catchphrases');
1375
+ const goodFor = extractLabel(raw, 'Good for');
1376
+ const badFor = extractLabel(raw, 'Bad for');
1377
+ const expected = extractLabel(raw, 'Expected from you');
1378
+ const answerStyle = extractLabel(raw, 'Answer style');
1379
+
1380
+ const craft = [];
1381
+ if (core.length) craft.push(`You look for the work that matters first: ${core.join(' ')}`);
1382
+ if (traits) craft.push(sentenceFromFragment(`You are ${traits}`));
1383
+ if (strengths) craft.push(sentenceFromFragment(`You are at your best in ${strengths}`));
1384
+
1385
+ const conduct = [];
1386
+ if (decision) conduct.push(sentenceFromFragment(decision));
1387
+ if (problemSolving) conduct.push(sentenceFromFragment(`You work by ${problemSolving}`));
1388
+ if (catchphrases) conduct.push(sentenceFromFragment(`Your familiar lines still matter: ${catchphrases}`));
1389
+ if (goodFor) conduct.push(sentenceFromFragment(`People come to you for ${goodFor}`));
1390
+ if (expected) conduct.push(sentenceFromFragment(`People come to you when they need ${expected}`));
1391
+ if (badFor) conduct.push(sentenceFromFragment(`You are the wrong voice for ${badFor}`));
1392
+ if (answerStyle) conduct.push(sentenceFromFragment(`You answer ${answerStyle}`));
1393
+
1394
+ return [identity, craft.join(' '), conduct.join(' ')].filter(Boolean).join('\n\n').trim();
1395
+ }
1396
+
1397
+ function naturalizeDefaultPersonaZh(value) {
1398
+ let text = String(value || '').trim();
1399
+ text = text.replace(/跨\s*VP\s*协作/g, '跨成员协作');
1400
+ text = text.replace(/开发\s*VP/g, '开发者');
1401
+ text = text.replace(/直接替开发者写代码/g, '直接替开发者写代码');
1402
+ text = text.replace(/安全 VP/g, '安全判断');
1403
+ text = text.replace(/AI 伙伴/g, '伙伴');
1404
+ text = text.replace(/泛用助手/g, '泛泛的协调者');
1405
+ text = text.replace(/你是([^,。]+),一个负责(.+?)的\s*VP。/g, '你是$1。你负责$2。');
1406
+ text = text.replace(/你是([^,。]+),一个以(.+?)为核心的设计\s*VP。/g, '你是$1。你以$2看设计问题。');
1407
+ text = text.replace(/你是([^,。]+),一个以(.+?)为核心的安全\s*VP。/g, '你是$1。你以$2做安全判断。');
1408
+ text = text.replace(/你是([^,。]+),一个以(.+?)为核心的\s*VP。/g, '你是$1。你以$2看问题。');
1409
+ text = text.replace(/\n\n人物特点:([^\n]+)/g, '\n\n你$1');
1410
+ text = text.replace(/\n\n擅长的事情:([^\n]+)/g, '\n\n你最擅长$1');
1411
+ text = text.replace(/\n\n解决问题的方式:([^\n]+)/g, '\n\n处理问题时,$1');
1412
+ text = text.replace(/\n\n用户通常期待你完成:([^\n]+)/g, '\n\n用户来找你,通常是为了$1');
1413
+ text = text.replace(/\n\n回答风格:([^\n]+)/g, '\n\n回答时,$1');
1414
+ text = text.replace(/\bVP\b/g, '');
1415
+ text = text.replace(/\s+,/g, ',').replace(/\s+。/g, '。');
1416
+ return text.trim();
1417
+ }
1418
+
1315
1419
  function localizeDefaultVpPersona(vp) {
1316
1420
  const zh = DEFAULT_VP_PERSONA_ZH[vp.vpId];
1317
1421
  if (!zh) return vp;
1422
+ const legacyPersonaEn = String(vp.persona || '').trim();
1423
+ const legacyPersonaZh = String(zh.persona || '').trim();
1424
+ const personaEn = naturalizeDefaultPersonaEn(legacyPersonaEn);
1425
+ const personaZh = naturalizeDefaultPersonaZh(legacyPersonaZh);
1318
1426
  return {
1319
1427
  ...vp,
1320
1428
  roleZh: zh.roleZh,
1321
- persona: localizedPersonaSections(vp.persona, zh.persona),
1322
- personaEn: vp.persona,
1323
- personaZh: zh.persona,
1429
+ persona: localizedPersonaSections(personaEn, personaZh),
1430
+ personaEn,
1431
+ personaZh,
1432
+ legacyPersonaEn,
1433
+ legacyPersona: localizedPersonaSections(legacyPersonaEn, legacyPersonaZh),
1324
1434
  };
1325
1435
  }
1326
1436
 
@@ -234,9 +234,17 @@ function replaceRoleBody(source, body) {
234
234
  function backfillLocalizedPersonaBody(source, vp) {
235
235
  const body = roleBodyOf(source).trim();
236
236
  const nextBody = typeof vp.persona === 'string' ? vp.persona.trim() : '';
237
- const oldBody = typeof vp.personaEn === 'string' ? vp.personaEn.trim() : '';
238
- if (!body || !nextBody || body.includes('<!-- lang:')) return null;
239
- if (!oldBody || body !== oldBody) return null;
237
+ if (!body || !nextBody || body === nextBody) return null;
238
+
239
+ const acceptedOldBodies = [
240
+ vp.legacyPersonaEn,
241
+ vp.legacyPersona,
242
+ vp.personaEn,
243
+ ]
244
+ .filter(value => typeof value === 'string' && value.trim())
245
+ .map(value => value.trim());
246
+
247
+ if (!acceptedOldBodies.includes(body)) return null;
240
248
  return replaceRoleBody(source, nextBody);
241
249
  }
242
250
 
@@ -2276,6 +2276,7 @@ function handleEngineEvent(event, hctx) {
2276
2276
  name: event.name,
2277
2277
  durationMs: event.durationMs,
2278
2278
  isError: event.isError,
2279
+ toolOutput: event.toolOutput,
2279
2280
  }, envelope);
2280
2281
  break;
2281
2282
 
@@ -3035,6 +3036,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3035
3036
  // array). See `trimSnapshotForBudget` doc-block for policy.
3036
3037
  const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
3037
3038
  messageTokenBudget: session?.config?.messageTokenBudget,
3039
+ language: session?.config?.language,
3038
3040
  });
3039
3041
  for await (const event of vpEngine.query({
3040
3042
  prompt,