@yeaft/webchat-agent 1.0.48 → 1.0.50

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": "1.0.48",
3
+ "version": "1.0.50",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -117,6 +117,117 @@ export class LLMAbortError extends Error {
117
117
  }
118
118
  }
119
119
 
120
+ /**
121
+ * Default cap on a single un-terminated SSE line. A well-formed SSE stream
122
+ * terminates every `data:` line with `\n`, so the live buffer never exceeds
123
+ * one event. A malfunctioning gateway can instead emit a multi-megabyte run
124
+ * with no newline; without a cap, the buffer grows unbounded and (before the
125
+ * incremental scan below) the parse went quadratic, freezing the event loop
126
+ * long enough to starve the WS heartbeat and drop the agent offline. 64 MiB
127
+ * is far above any legitimate single SSE event yet bounds the damage.
128
+ */
129
+ export const DEFAULT_SSE_MAX_LINE_BYTES = 64 * 1024 * 1024;
130
+
131
+ /**
132
+ * Incremental, O(n) line splitter for SSE byte streams.
133
+ *
134
+ * The previous per-adapter pattern was `buffer += chunk; lines =
135
+ * buffer.split('\n'); buffer = lines.pop()`. When a single line spans many
136
+ * chunks (no `\n` yet), each chunk re-scanned and re-split the entire growing
137
+ * buffer — O(n²) total, which on a multi-MiB un-terminated line blocks the
138
+ * main thread for tens of seconds to minutes (measured: ~35 s at 40 MiB),
139
+ * freezing every event-loop task including the heartbeat `setInterval` and the
140
+ * `ws.on('pong')` handler. The agent then sees "No pong" and terminates its
141
+ * own healthy connection.
142
+ *
143
+ * This buffer scans only each newly-arrived chunk for `\n` and holds the
144
+ * still-incomplete trailing line as an array of fragments (joined only when a
145
+ * newline finally completes it), so total work is linear in bytes received
146
+ * regardless of how a line is chunked. It also enforces `maxLineBytes`: a
147
+ * single line that exceeds the cap with no terminator is treated as a
148
+ * malformed stream — `push()` throws a retryable LLMServerError (which the
149
+ * adapter's stream loop propagates through classifyFetchError) rather than
150
+ * accumulating without bound.
151
+ *
152
+ * Note: the cap is measured in JS string `.length` (UTF-16 code units), not
153
+ * exact UTF-8 bytes. It is a coarse upper-bound guard against unbounded
154
+ * growth, not a precise byte accountant.
155
+ */
156
+ export class SseLineBuffer {
157
+ /** @param {{ maxLineBytes?: number }} [opts] */
158
+ constructor({ maxLineBytes = DEFAULT_SSE_MAX_LINE_BYTES } = {}) {
159
+ this.maxLineBytes = Number.isFinite(maxLineBytes) && maxLineBytes > 0
160
+ ? Math.floor(maxLineBytes)
161
+ : DEFAULT_SSE_MAX_LINE_BYTES;
162
+ /**
163
+ * Fragments of the current (incomplete) line, in arrival order. Kept as an
164
+ * array — NOT a concatenated string — because `string += chunk` reallocates
165
+ * and copies the whole growing tail every call, which is itself O(n²) on a
166
+ * long un-terminated line even with an incremental newline scan. Pushing a
167
+ * fragment is O(1); we only `join` when a newline actually completes a line.
168
+ * @type {string[]}
169
+ */
170
+ this._frags = [];
171
+ /** Running byte length of `_frags` — avoids re-summing to enforce the cap. */
172
+ this._pendingLen = 0;
173
+ }
174
+
175
+ /**
176
+ * Append a chunk and return every newly-completed line (newline stripped),
177
+ * in order. The trailing incomplete line stays buffered for the next call.
178
+ * Lines may be empty strings (SSE uses blank lines as event separators);
179
+ * callers filter as needed.
180
+ *
181
+ * @param {string} chunk
182
+ * @returns {string[]}
183
+ * @throws {LLMServerError} when an un-terminated line exceeds `maxLineBytes`
184
+ */
185
+ push(chunk) {
186
+ if (!chunk) return [];
187
+ const lines = [];
188
+ let start = 0;
189
+ // Scan only THIS chunk for newlines. Bytes before the first newline finish
190
+ // the buffered partial line; bytes between newlines are whole lines; bytes
191
+ // after the last newline become the new partial. Work is linear in chunk
192
+ // length, and the buffered tail is never concatenated until a newline lands.
193
+ let nl;
194
+ while ((nl = chunk.indexOf('\n', start)) !== -1) {
195
+ const segment = chunk.slice(start, nl);
196
+ if (this._frags.length > 0) {
197
+ this._frags.push(segment);
198
+ lines.push(this._frags.join(''));
199
+ this._frags = [];
200
+ this._pendingLen = 0;
201
+ } else {
202
+ lines.push(segment);
203
+ }
204
+ start = nl + 1;
205
+ }
206
+ // Trailing fragment after the last newline (or the whole chunk if none):
207
+ // buffer it as an O(1) push rather than a string concat.
208
+ if (start < chunk.length) {
209
+ const tail = start === 0 ? chunk : chunk.slice(start);
210
+ this._frags.push(tail);
211
+ this._pendingLen += tail.length;
212
+ if (this._pendingLen > this.maxLineBytes) {
213
+ // LLMServerError is retryable by class (engine.js retries on
214
+ // `instanceof LLMServerError`), matching the sibling throws in the
215
+ // adapters — no `.retryable` flag needed on the throw path.
216
+ throw new LLMServerError(
217
+ `SSE line exceeded ${this.maxLineBytes} bytes without a newline — treating as malformed stream`,
218
+ 0,
219
+ );
220
+ }
221
+ }
222
+ return lines;
223
+ }
224
+
225
+ /** The unconsumed trailing partial line (no newline yet). */
226
+ get pending() {
227
+ return this._frags.length === 1 ? this._frags[0] : this._frags.join('');
228
+ }
229
+ }
230
+
120
231
  /**
121
232
  * Read one chunk from a Fetch stream with a silence timeout. This is not a
122
233
  * total request deadline: every received chunk gets a fresh budget. A caller
@@ -18,6 +18,7 @@ import {
18
18
  readStreamChunkWithIdleTimeout,
19
19
  redactRawRequest,
20
20
  safeHeaders,
21
+ SseLineBuffer,
21
22
  } from './adapter.js';
22
23
  import {
23
24
  normalizeEffort,
@@ -302,7 +303,12 @@ export class AnthropicAdapter extends LLMAdapter {
302
303
  // Parse SSE stream
303
304
  const reader = response.body.getReader();
304
305
  const decoder = new TextDecoder();
305
- let buffer = '';
306
+ // Incremental O(n) line splitter (see SseLineBuffer): the old
307
+ // `buffer += chunk; buffer.split('\n')` pattern went quadratic on a
308
+ // multi-MiB un-terminated line and froze the event loop long enough to
309
+ // starve the WS heartbeat. The buffer also caps a single newline-less
310
+ // line and throws a retryable error on a malformed stream.
311
+ const sseLines = new SseLineBuffer();
306
312
  // task-327d: index-keyed per-block state. Anthropic streams content
307
313
  // blocks sequentially today, but the protocol exposes `event.index`
308
314
  // precisely because that's not guaranteed. Dispatch in
@@ -333,10 +339,8 @@ export class AnthropicAdapter extends LLMAdapter {
333
339
  if (done) break;
334
340
 
335
341
  const chunkText = decoder.decode(value, { stream: true });
336
- buffer += chunkText;
337
342
  rawSseBodyChunks.push(chunkText);
338
- const lines = buffer.split('\n');
339
- buffer = lines.pop() || ''; // Keep incomplete line
343
+ const lines = sseLines.push(chunkText);
340
344
 
341
345
  for (const line of lines) {
342
346
  if (!line.startsWith('data: ')) continue;
@@ -37,6 +37,7 @@ import {
37
37
  readStreamChunkWithIdleTimeout,
38
38
  redactRawRequest,
39
39
  safeHeaders,
40
+ SseLineBuffer,
40
41
  } from './adapter.js';
41
42
  import {
42
43
  normalizeEffort,
@@ -315,7 +316,12 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
315
316
 
316
317
  const reader = response.body.getReader();
317
318
  const decoder = new TextDecoder();
318
- let buffer = '';
319
+ // Incremental O(n) line splitter (see SseLineBuffer): the old
320
+ // `buffer += chunk; buffer.split('\n')` pattern went quadratic on a
321
+ // multi-MiB un-terminated line and froze the event loop long enough to
322
+ // starve the WS heartbeat. The buffer also caps a single newline-less
323
+ // line and throws a retryable error on a malformed stream.
324
+ const sseLines = new SseLineBuffer();
319
325
 
320
326
  /** Accumulate tool call arguments by output_index.
321
327
  * Value: { callId, name, arguments } */
@@ -342,12 +348,11 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
342
348
  });
343
349
  if (done) break;
344
350
  const chunkText = decoder.decode(value, { stream: true });
345
- buffer += chunkText;
346
351
  rawSseBodyChunks.push(chunkText);
347
352
 
348
- // SSE events are separated by blank lines; split on \n
349
- const lines = buffer.split('\n');
350
- buffer = lines.pop() || '';
353
+ // SSE events are separated by blank lines; SseLineBuffer yields each
354
+ // completed line (newline stripped) in O(n) total.
355
+ const lines = sseLines.push(chunkText);
351
356
 
352
357
  for (const rawLine of lines) {
353
358
  const line = rawLine.trimEnd();
@@ -0,0 +1,74 @@
1
+ import { appendFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const MAX_DETAIL_STRING = 512;
5
+
6
+ function sanitizeString(value, max = MAX_DETAIL_STRING) {
7
+ if (typeof value !== 'string') return value;
8
+ return value.length > max ? `${value.slice(0, max)}...[truncated]` : value;
9
+ }
10
+
11
+ function sanitizeValue(value, depth = 0) {
12
+ if (value == null) return value;
13
+ if (typeof value === 'string') return sanitizeString(value);
14
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
15
+ if (Array.isArray(value)) {
16
+ if (depth >= 4) return `[array:${value.length}]`;
17
+ return value.slice(0, 50).map(v => sanitizeValue(v, depth + 1));
18
+ }
19
+ if (typeof value === 'object') {
20
+ if (depth >= 4) return '[object]';
21
+ const out = {};
22
+ for (const [key, item] of Object.entries(value)) {
23
+ if (key === 'text' || key === 'prompt' || key === 'content' || key === 'data' || key === 'apiKey' || key === 'token') continue;
24
+ out[key] = sanitizeValue(item, depth + 1);
25
+ }
26
+ return out;
27
+ }
28
+ return String(value);
29
+ }
30
+
31
+ export function perfNowMs() {
32
+ return Number(process.hrtime.bigint()) / 1e6;
33
+ }
34
+
35
+ export function recordAgentPerfTrace(config, event = {}) {
36
+ const traceId = typeof event.traceId === 'string' && event.traceId.trim()
37
+ ? event.traceId.trim()
38
+ : (typeof event.perfTraceId === 'string' && event.perfTraceId.trim() ? event.perfTraceId.trim() : null);
39
+ if (!traceId) return false;
40
+ const yeaftDir = config?.yeaftDir;
41
+ if (typeof yeaftDir !== 'string' || !yeaftDir.trim()) return false;
42
+ const root = join(yeaftDir.trim(), 'perf-traces');
43
+ const day = new Date().toISOString().slice(0, 10);
44
+ const row = {
45
+ traceId,
46
+ source: 'agent',
47
+ phase: event.phase || 'unknown',
48
+ at: Date.now(),
49
+ monotonicMs: Number.isFinite(event.monotonicMs) ? event.monotonicMs : perfNowMs(),
50
+ durationMs: Number.isFinite(event.durationMs) ? event.durationMs : null,
51
+ sessionId: event.sessionId || null,
52
+ vpId: event.vpId || null,
53
+ turnId: event.turnId || null,
54
+ threadId: event.threadId || null,
55
+ messageType: event.messageType || null,
56
+ bytes: Number.isFinite(event.bytes) ? event.bytes : null,
57
+ ok: typeof event.ok === 'boolean' ? event.ok : null,
58
+ detail: sanitizeValue(event.detail || null),
59
+ };
60
+ try {
61
+ mkdirSync(root, { recursive: true });
62
+ appendFileSync(join(root, `${day}.jsonl`), `${JSON.stringify(row)}\n`);
63
+ return true;
64
+ } catch (err) {
65
+ if (process.env.YEAFT_PERF_TRACE_DEBUG === '1') {
66
+ console.warn('[Yeaft] perf trace write failed:', err?.message || err);
67
+ }
68
+ return false;
69
+ }
70
+ }
71
+
72
+ export const __perfTraceForTest = {
73
+ sanitizeValue,
74
+ };
@@ -72,6 +72,7 @@ import { listMcpServers, upsertMcpServer, removeMcpServer } from './config-api.j
72
72
  import { buildMcpFlattenedTools } from './tools/mcp-tools.js';
73
73
  import { getAgentRegistry, agentBelongsToScope } from './tools/agent.js';
74
74
  import { isPromptableAgentStatus } from './sub-agent/status.js';
75
+ import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
75
76
 
76
77
  const SKILL_COMMAND_PREFIX = 'skill:';
77
78
 
@@ -1271,6 +1272,17 @@ function getOrCreateSessionContext(sessionId, sessionHandle) {
1271
1272
  * @param {object} envelope — coordinator envelope `{sessionId, taskId, msg, trigger}`
1272
1273
  */
1273
1274
  function enqueueForVp(sessionId, vpId, envelope) {
1275
+ const perfTraceId = envelope?._perfTraceId || envelope?.perfTraceId || null;
1276
+ if (perfTraceId) {
1277
+ recordAgentPerfTrace(ctx.CONFIG, {
1278
+ traceId: perfTraceId,
1279
+ phase: 'vp.enqueue',
1280
+ sessionId,
1281
+ vpId,
1282
+ turnId: envelope?.msg?.id || null,
1283
+ messageType: envelope?.trigger || 'user',
1284
+ });
1285
+ }
1274
1286
  const routePromise = routeEnvelopeToVpThread(sessionId, vpId, envelope);
1275
1287
  registerRoutePromise(envelope?.msg?.id, routePromise);
1276
1288
  }
@@ -1360,6 +1372,7 @@ function scheduleTaskResultReentry(event) {
1360
1372
  }
1361
1373
 
1362
1374
  async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
1375
+ const routeStart = perfNowMs();
1363
1376
  const { text, prompt, promptParts } = buildVpPromptPayload(vpId, envelope);
1364
1377
  const runningThreads = getRunningThreads(sessionId, vpId);
1365
1378
  let thread = null;
@@ -1404,6 +1417,19 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
1404
1417
  if (!thread) return null;
1405
1418
  rememberThreadMessage(thread, envelope?.msg);
1406
1419
  const turnId = `${randomUUID().slice(0, 8)}:${vpId}`;
1420
+ const perfTraceId = envelope?._perfTraceId || envelope?.perfTraceId || null;
1421
+ if (perfTraceId) {
1422
+ recordAgentPerfTrace(ctx.CONFIG, {
1423
+ traceId: perfTraceId,
1424
+ phase: 'vp.route_thread',
1425
+ durationMs: perfNowMs() - routeStart,
1426
+ sessionId,
1427
+ vpId,
1428
+ turnId,
1429
+ threadId: thread.threadId,
1430
+ detail: { related, runningThreadCount: runningThreads.length },
1431
+ });
1432
+ }
1407
1433
 
1408
1434
  if (related) {
1409
1435
  const content = promptParts || prompt;
@@ -1717,11 +1743,12 @@ function resolveGroupDefaultVpId(sessionId) {
1717
1743
  }
1718
1744
  }
1719
1745
 
1720
- function sendSessionOutputFrame(data, { sessionId, chatId, vpId, turnId, threadId } = {}) {
1746
+ function sendSessionOutputFrame(data, { sessionId, chatId, vpId, turnId, threadId, perfTraceId } = {}) {
1721
1747
  const resolvedVpId = vpId || (sessionId ? resolveGroupDefaultVpId(sessionId) : null);
1722
1748
  sendToServer({
1723
1749
  type: 'yeaft_output',
1724
1750
  conversationId: yeaftConversationId,
1751
+ ...(perfTraceId ? { perfTraceId } : {}),
1725
1752
  ...(sessionId ? { sessionId } : {}),
1726
1753
  ...(chatId ? { chatId } : {}),
1727
1754
  ...(resolvedVpId ? { vpId: resolvedVpId } : {}),
@@ -1763,10 +1790,11 @@ function broadcastSkillSlashCommands(sessionLike) {
1763
1790
  }
1764
1791
 
1765
1792
  /** Send a Yeaft Session metadata event over the legacy-compatible envelope. */
1766
- function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId } = {}) {
1793
+ function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId, perfTraceId } = {}) {
1767
1794
  sendToServer({
1768
1795
  type: 'yeaft_output',
1769
1796
  conversationId: yeaftConversationId,
1797
+ ...(perfTraceId ? { perfTraceId } : {}),
1770
1798
  ...(sessionId ? { sessionId } : {}),
1771
1799
  ...(chatId ? { chatId } : {}),
1772
1800
  ...(vpId ? { vpId } : {}),
@@ -3033,6 +3061,33 @@ export async function handleYeaftSessionSend(msg) {
3033
3061
  const sessionId = (typeof msg.sessionId === 'string' && msg.sessionId.trim())
3034
3062
  ? msg.sessionId.trim()
3035
3063
  : 'grp_default';
3064
+ const perfTraceId = typeof msg.perfTraceId === 'string' && msg.perfTraceId.trim()
3065
+ ? msg.perfTraceId.trim()
3066
+ : null;
3067
+ const perfStart = perfNowMs();
3068
+ const tracePerf = (phase, extra = {}) => {
3069
+ if (!perfTraceId) return;
3070
+ recordAgentPerfTrace(ctx.CONFIG, {
3071
+ traceId: perfTraceId,
3072
+ phase,
3073
+ sessionId,
3074
+ messageType: msg.type,
3075
+ ...extra,
3076
+ });
3077
+ };
3078
+ const traceDuration = (phase, start, extra = {}) => {
3079
+ tracePerf(phase, {
3080
+ durationMs: perfNowMs() - start,
3081
+ ...extra,
3082
+ });
3083
+ };
3084
+ tracePerf('session_send.received', {
3085
+ turnId: typeof msg.id === 'string' ? msg.id : null,
3086
+ detail: {
3087
+ mentionCount: mentions.length,
3088
+ attachmentCount: Array.isArray(msg.files) ? msg.files.length : 0,
3089
+ },
3090
+ });
3036
3091
 
3037
3092
  // Entry gate: if a compact is in flight from the previous turn IN
3038
3093
  // THIS GROUP, wait for it to finish before reading the group's
@@ -3043,7 +3098,9 @@ export async function handleYeaftSessionSend(msg) {
3043
3098
  // a session has loaded (or in test paths that never call
3044
3099
  // `ensureSessionLoaded`) it may be unavailable; skip gracefully.
3045
3100
  if (session?.compactor) {
3101
+ const compactWaitStart = perfNowMs();
3046
3102
  await session.compactor.awaitInFlight(sessionId);
3103
+ traceDuration('session_send.await_compactor', compactWaitStart);
3047
3104
  }
3048
3105
 
3049
3106
  // yeaftDir is a hard prerequisite for both session boot and group seeding;
@@ -3059,13 +3116,16 @@ export async function handleYeaftSessionSend(msg) {
3059
3116
  return;
3060
3117
  }
3061
3118
 
3119
+ const ensureSessionStart = perfNowMs();
3062
3120
  await ensureSessionLoaded();
3121
+ traceDuration('session_send.ensure_session_loaded', ensureSessionStart);
3063
3122
 
3064
3123
  // Open the session. The default `grp_default` no longer self-seeds
3065
3124
  // here — session creation happens up-front via `handleYeaftCreateSession`,
3066
3125
  // so a missing dir surfaces a clear error rather than masking it.
3067
3126
  let sessionHandle = null;
3068
3127
  let sessionRoot = null;
3128
+ const openSessionStart = perfNowMs();
3069
3129
  try {
3070
3130
  const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
3071
3131
  sessionRoot = sessionsRoot(groupYeaftDir);
@@ -3085,6 +3145,8 @@ export async function handleYeaftSessionSend(msg) {
3085
3145
  console.warn('[Yeaft] yeaft_session_chat: session open failed', err?.message || err);
3086
3146
  }
3087
3147
 
3148
+ traceDuration('session_send.open_session', openSessionStart, { ok: !!sessionHandle });
3149
+
3088
3150
  if (!sessionHandle) {
3089
3151
  sendSessionOutputFrame({
3090
3152
  type: 'assistant',
@@ -3156,6 +3218,7 @@ export async function handleYeaftSessionSend(msg) {
3156
3218
  // driver receives.
3157
3219
  const inboundFiles = Array.isArray(msg.files) ? msg.files : [];
3158
3220
  let attachmentBundle = { promptAttachments: [], promptSuffix: '', promptParts: [], failed: [] };
3221
+ const attachmentsStart = perfNowMs();
3159
3222
  if (inboundFiles.length > 0) {
3160
3223
  try {
3161
3224
  attachmentBundle = persistYeaftAttachments(inboundFiles, { subdir: sessionId });
@@ -3176,11 +3239,19 @@ export async function handleYeaftSessionSend(msg) {
3176
3239
  }, { sessionId });
3177
3240
  }
3178
3241
  const persistedAttachments = attachmentsForPersistence(attachmentBundle.promptAttachments);
3242
+ traceDuration('session_send.attachments', attachmentsStart, {
3243
+ detail: {
3244
+ inputFileCount: inboundFiles.length,
3245
+ persistedFileCount: persistedAttachments.length,
3246
+ failedFileCount: Array.isArray(attachmentBundle.failed) ? attachmentBundle.failed.length : 0,
3247
+ },
3248
+ });
3179
3249
 
3180
3250
  // Ingest user text. The coordinator persists, applies mention/fanout
3181
3251
  // rules, and calls deliver() (== enqueueForVp) for each chosen VP —
3182
3252
  // which both (a) emits vp_typing_start and (b) ensures a driver runs.
3183
3253
  let report;
3254
+ const ingestStart = perfNowMs();
3184
3255
  try {
3185
3256
  report = coord.ingest({
3186
3257
  id: typeof msg.id === 'string' && msg.id ? msg.id : undefined,
@@ -3198,6 +3269,7 @@ export async function handleYeaftSessionSend(msg) {
3198
3269
  // back to disk on every fan-out target. NOT persisted.
3199
3270
  _promptParts: attachmentBundle.promptParts,
3200
3271
  _promptSuffix: attachmentBundle.promptSuffix,
3272
+ _perfTraceId: perfTraceId,
3201
3273
  });
3202
3274
  } catch (err) {
3203
3275
  console.warn('[Yeaft] yeaft_session_chat: coord.ingest failed', err?.message || err);
@@ -3209,6 +3281,14 @@ export async function handleYeaftSessionSend(msg) {
3209
3281
  return;
3210
3282
  }
3211
3283
 
3284
+ traceDuration('session_send.coordinator_ingest', ingestStart, {
3285
+ turnId: report?.message?.id || null,
3286
+ detail: {
3287
+ dispatchedCount: Array.isArray(report?.dispatched) ? report.dispatched.length : 0,
3288
+ fallback: typeof report?.fallback === 'string' ? report.fallback : null,
3289
+ },
3290
+ });
3291
+
3212
3292
  // Thread ownership is resolved inside enqueueForVp()/routeEnvelopeToVpThread
3213
3293
  // before persistence. Do not write a canonical 'main' user row here: a
3214
3294
  // route to an active VP may append to an existing thread, while an
@@ -3233,7 +3313,15 @@ export async function handleYeaftSessionSend(msg) {
3233
3313
  // Wait only for routing/classification promises spawned by this message.
3234
3314
  // Do not wait for every driver in the group: unrelated older threads may keep
3235
3315
  // running for minutes and must not hold this request lifecycle hostage.
3316
+ const routeWaitStart = perfNowMs();
3236
3317
  await waitForRoutePromises(report?.message?.id);
3318
+ traceDuration('session_send.wait_route_promises', routeWaitStart, {
3319
+ turnId: report?.message?.id || null,
3320
+ });
3321
+ traceDuration('session_send.handler_total', perfStart, {
3322
+ turnId: report?.message?.id || null,
3323
+ ok: true,
3324
+ });
3237
3325
 
3238
3326
  // Post-turn compaction. Fire-and-forget — does NOT block the response
3239
3327
  // path. The Compactor's own precheck (`shouldCompactHistory`) decides
@@ -3602,7 +3690,24 @@ async function raceWithEscalation(inner, { deadlineMs, onEscalate }) {
3602
3690
  async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId = 'main', thread = null, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
3603
3691
  if (!prompt?.trim()) return;
3604
3692
 
3605
- const envelope = { sessionId, vpId, threadId, turnId };
3693
+ const perfTraceId = typeof inboundEnvelope?._perfTraceId === 'string' && inboundEnvelope._perfTraceId.trim()
3694
+ ? inboundEnvelope._perfTraceId.trim()
3695
+ : (typeof inboundEnvelope?.perfTraceId === 'string' && inboundEnvelope.perfTraceId.trim()
3696
+ ? inboundEnvelope.perfTraceId.trim()
3697
+ : null);
3698
+ const envelope = { sessionId, vpId, threadId, turnId, ...(perfTraceId ? { perfTraceId } : {}) };
3699
+ const vpTurnPerfStart = perfNowMs();
3700
+ if (perfTraceId) {
3701
+ recordAgentPerfTrace(ctx.CONFIG, {
3702
+ traceId: perfTraceId,
3703
+ phase: 'vp.turn_start',
3704
+ sessionId,
3705
+ vpId,
3706
+ turnId,
3707
+ threadId,
3708
+ detail: { promptBytes: Buffer.byteLength(prompt || '') },
3709
+ });
3710
+ }
3606
3711
 
3607
3712
  // Per-message turn lifecycle: track start ts + which terminal reason
3608
3713
  // we'll emit. `emitVpTurnEnd` is idempotent (route_forward emits inside
@@ -3713,10 +3818,25 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3713
3818
  // the second-line defense (history-compact only fires above 30K
3714
3819
  // tokens — small chats with many turns still bloat the messages
3715
3820
  // array). See `trimSnapshotForBudget` doc-block for policy.
3821
+ const trimStart = perfNowMs();
3716
3822
  const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
3717
3823
  messageTokenBudget: session?.config?.messageTokenBudget,
3718
3824
  language: session?.config?.language,
3719
3825
  });
3826
+ if (perfTraceId) {
3827
+ recordAgentPerfTrace(ctx.CONFIG, {
3828
+ traceId: perfTraceId,
3829
+ phase: 'vp.trim_snapshot',
3830
+ durationMs: perfNowMs() - trimStart,
3831
+ sessionId,
3832
+ vpId,
3833
+ turnId,
3834
+ threadId,
3835
+ detail: { beforeMessages: baseSnapshot.length, afterMessages: trimmedMessages.length },
3836
+ });
3837
+ }
3838
+ const engineStart = perfNowMs();
3839
+ let firstEngineEvent = false;
3720
3840
  for await (const event of vpEngine.query({
3721
3841
  prompt,
3722
3842
  promptParts,
@@ -3738,9 +3858,33 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3738
3858
  },
3739
3859
  ...queryOpts,
3740
3860
  })) {
3861
+ if (perfTraceId && !firstEngineEvent) {
3862
+ firstEngineEvent = true;
3863
+ recordAgentPerfTrace(ctx.CONFIG, {
3864
+ traceId: perfTraceId,
3865
+ phase: 'vp.engine_first_event',
3866
+ durationMs: perfNowMs() - engineStart,
3867
+ sessionId,
3868
+ vpId,
3869
+ turnId,
3870
+ threadId,
3871
+ messageType: event?.type || null,
3872
+ });
3873
+ }
3741
3874
  resetQueryTimer();
3742
3875
  handleEngineEvent(event, handlerCtx);
3743
3876
  }
3877
+ if (perfTraceId) {
3878
+ recordAgentPerfTrace(ctx.CONFIG, {
3879
+ traceId: perfTraceId,
3880
+ phase: 'vp.engine_complete',
3881
+ durationMs: perfNowMs() - engineStart,
3882
+ sessionId,
3883
+ vpId,
3884
+ turnId,
3885
+ threadId,
3886
+ });
3887
+ }
3744
3888
 
3745
3889
  flushStreamTextBatch(handlerCtx, envelope, { resetImmediate: true });
3746
3890
 
@@ -3750,7 +3894,24 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3750
3894
  // the target VP turn; otherwise UI replay can show a trailing handoff
3751
3895
  // block after the target response.
3752
3896
  const visiblePrompts = inboundIsInternal ? appendedUserPrompts : [prompt, ...appendedUserPrompts];
3897
+ const appendHistoryStart = perfNowMs();
3753
3898
  appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, { turnId });
3899
+ if (perfTraceId) {
3900
+ recordAgentPerfTrace(ctx.CONFIG, {
3901
+ traceId: perfTraceId,
3902
+ phase: 'vp.append_history',
3903
+ durationMs: perfNowMs() - appendHistoryStart,
3904
+ sessionId,
3905
+ vpId,
3906
+ turnId,
3907
+ threadId,
3908
+ detail: {
3909
+ assistantBytes: Buffer.byteLength(assistantTextParts.join('')),
3910
+ toolCallCount: toolCallsAccum.length,
3911
+ toolResultCount: toolResultsAccum.length,
3912
+ },
3913
+ });
3914
+ }
3754
3915
 
3755
3916
  sendSessionOutputFrame({
3756
3917
  type: 'assistant',
@@ -3780,6 +3941,19 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3780
3941
  return;
3781
3942
  }
3782
3943
 
3944
+ if (perfTraceId) {
3945
+ recordAgentPerfTrace(ctx.CONFIG, {
3946
+ traceId: perfTraceId,
3947
+ phase: 'vp.turn_error',
3948
+ durationMs: perfNowMs() - vpTurnPerfStart,
3949
+ sessionId,
3950
+ vpId,
3951
+ turnId,
3952
+ threadId,
3953
+ ok: false,
3954
+ detail: { message: err?.message || String(err) },
3955
+ });
3956
+ }
3783
3957
  console.error('[Yeaft] query error:', err);
3784
3958
  turnEndReason = 'errored';
3785
3959
  turnEndDetail = { message: err?.message || String(err) };
@@ -3858,6 +4032,19 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3858
4032
  thread.status = 'idle';
3859
4033
  thread.updatedAt = Date.now();
3860
4034
  }
4035
+ if (perfTraceId) {
4036
+ recordAgentPerfTrace(ctx.CONFIG, {
4037
+ traceId: perfTraceId,
4038
+ phase: 'vp.turn_total',
4039
+ durationMs: perfNowMs() - vpTurnPerfStart,
4040
+ sessionId,
4041
+ vpId,
4042
+ turnId,
4043
+ threadId,
4044
+ ok: turnEndReason !== 'errored',
4045
+ detail: { reason: turnEndReason },
4046
+ });
4047
+ }
3861
4048
  }
3862
4049
  }
3863
4050