@yeaft/webchat-agent 1.0.49 → 1.0.51
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/history.js +4 -3
- package/package.json +1 -1
- package/sdk/message-normalize.js +129 -0
- package/sdk/query.js +37 -18
- package/yeaft/perf-trace.js +74 -0
- package/yeaft/web-bridge.js +190 -3
package/history.js
CHANGED
|
@@ -2,6 +2,7 @@ import { homedir } from 'os';
|
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, fstatSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import ctx from './context.js';
|
|
5
|
+
import { normalizeClaudeMessage } from './sdk/message-normalize.js';
|
|
5
6
|
import { getProvider, DEFAULT_PROVIDER } from './providers/index.js';
|
|
6
7
|
|
|
7
8
|
// Claude 项目目录
|
|
@@ -227,7 +228,7 @@ function readTailMessages(filePath, limit) {
|
|
|
227
228
|
const line = lines[i];
|
|
228
229
|
if (!line || !line.trim()) continue;
|
|
229
230
|
try {
|
|
230
|
-
const data = JSON.parse(line);
|
|
231
|
+
const data = normalizeClaudeMessage(JSON.parse(line));
|
|
231
232
|
if (data.type === 'user' || data.type === 'assistant') {
|
|
232
233
|
collected.push(data);
|
|
233
234
|
if (collected.length >= limit) break;
|
|
@@ -241,7 +242,7 @@ function readTailMessages(filePath, limit) {
|
|
|
241
242
|
const headLine = carry.toString('utf-8').trim();
|
|
242
243
|
if (headLine) {
|
|
243
244
|
try {
|
|
244
|
-
const data = JSON.parse(headLine);
|
|
245
|
+
const data = normalizeClaudeMessage(JSON.parse(headLine));
|
|
245
246
|
if (data.type === 'user' || data.type === 'assistant') {
|
|
246
247
|
collected.push(data);
|
|
247
248
|
}
|
|
@@ -287,7 +288,7 @@ export function loadSessionHistory(workDir, claudeSessionId, limit = 500) {
|
|
|
287
288
|
|
|
288
289
|
for (const line of lines) {
|
|
289
290
|
try {
|
|
290
|
-
const data = JSON.parse(line);
|
|
291
|
+
const data = normalizeClaudeMessage(JSON.parse(line));
|
|
291
292
|
if (data.type === 'user' || data.type === 'assistant') {
|
|
292
293
|
messages.push(data);
|
|
293
294
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const TOOL_LIKE_BLOCK_TYPES = new Set([
|
|
2
|
+
'tool_use',
|
|
3
|
+
'tool_call',
|
|
4
|
+
'function_call',
|
|
5
|
+
'server_tool_use',
|
|
6
|
+
'call',
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Claude Code stream-json has changed its tool-call block spelling more than
|
|
11
|
+
* once. Normalize every known tool-like assistant content block to the
|
|
12
|
+
* Anthropic-compatible `tool_use` shape used by the rest of the app.
|
|
13
|
+
*
|
|
14
|
+
* @param {unknown} block
|
|
15
|
+
* @returns {unknown}
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeAssistantContentBlock(block) {
|
|
18
|
+
if (!block || typeof block !== 'object') return block;
|
|
19
|
+
if (block.type === 'tool_use') return block;
|
|
20
|
+
if (!TOOL_LIKE_BLOCK_TYPES.has(block.type)) return block;
|
|
21
|
+
|
|
22
|
+
const input = normalizeToolInput(block);
|
|
23
|
+
const name = block.name
|
|
24
|
+
|| block.tool_name
|
|
25
|
+
|| block.toolName
|
|
26
|
+
|| block.function?.name
|
|
27
|
+
|| block.action?.name
|
|
28
|
+
|| input?.name
|
|
29
|
+
|| 'tool';
|
|
30
|
+
const id = block.id
|
|
31
|
+
|| block.tool_use_id
|
|
32
|
+
|| block.toolUseId
|
|
33
|
+
|| block.tool_call_id
|
|
34
|
+
|| block.toolCallId
|
|
35
|
+
|| block.call_id
|
|
36
|
+
|| block.callId
|
|
37
|
+
|| stableFallbackToolId(name, block);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
type: 'tool_use',
|
|
41
|
+
id,
|
|
42
|
+
name,
|
|
43
|
+
input,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {unknown} content
|
|
49
|
+
* @returns {unknown}
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeAssistantContent(content) {
|
|
52
|
+
if (!Array.isArray(content)) return content;
|
|
53
|
+
return content.map(normalizeAssistantContentBlock);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {unknown} message
|
|
58
|
+
* @returns {unknown}
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeClaudeMessage(message) {
|
|
61
|
+
if (!message || typeof message !== 'object') return message;
|
|
62
|
+
if (message.type !== 'assistant' || !message.message) return message;
|
|
63
|
+
const content = message.message.content;
|
|
64
|
+
const normalized = normalizeAssistantContent(content);
|
|
65
|
+
if (normalized === content) return message;
|
|
66
|
+
return {
|
|
67
|
+
...message,
|
|
68
|
+
message: {
|
|
69
|
+
...message.message,
|
|
70
|
+
content: normalized,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Stream events should only emit text deltas from actual text blocks. Newer
|
|
77
|
+
* Claude Code builds may emit text-looking deltas while a tool-call block is
|
|
78
|
+
* being assembled (`call`, transient id, JSON argument text). Those bytes are
|
|
79
|
+
* transport detail, not assistant prose; the final complete assistant message
|
|
80
|
+
* carries the real tool_use block.
|
|
81
|
+
*
|
|
82
|
+
* @param {string|undefined|null} blockType
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
export function shouldForwardTextDeltaForBlockType(blockType) {
|
|
86
|
+
if (!blockType) return true; // Back-compat for older CLIs without start events.
|
|
87
|
+
return blockType === 'text';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stableFallbackToolId(name, block) {
|
|
91
|
+
const raw = stableStringify(block);
|
|
92
|
+
let hash = 2166136261;
|
|
93
|
+
for (let i = 0; i < raw.length; i++) {
|
|
94
|
+
hash ^= raw.charCodeAt(i);
|
|
95
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
96
|
+
}
|
|
97
|
+
return `${name}-${hash.toString(36)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stableStringify(value) {
|
|
101
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
102
|
+
if (value && typeof value === 'object') {
|
|
103
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
104
|
+
}
|
|
105
|
+
return JSON.stringify(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeToolInput(block) {
|
|
109
|
+
const raw = block.input
|
|
110
|
+
?? block.arguments
|
|
111
|
+
?? block.args
|
|
112
|
+
?? block.parameters
|
|
113
|
+
?? block.function?.arguments
|
|
114
|
+
?? block.action?.arguments
|
|
115
|
+
?? {};
|
|
116
|
+
|
|
117
|
+
if (typeof raw === 'string') {
|
|
118
|
+
const trimmed = raw.trim();
|
|
119
|
+
if (!trimmed) return {};
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(trimmed);
|
|
122
|
+
return parsed && typeof parsed === 'object' ? parsed : { value: parsed };
|
|
123
|
+
} catch {
|
|
124
|
+
return { arguments: raw };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (raw && typeof raw === 'object') return raw;
|
|
128
|
+
return {};
|
|
129
|
+
}
|
package/sdk/query.js
CHANGED
|
@@ -7,6 +7,7 @@ import { spawn } from 'child_process';
|
|
|
7
7
|
import { createInterface } from 'readline';
|
|
8
8
|
import { Stream } from './stream.js';
|
|
9
9
|
import { AbortError } from './types.js';
|
|
10
|
+
import { normalizeClaudeMessage, shouldForwardTextDeltaForBlockType } from './message-normalize.js';
|
|
10
11
|
import { getCleanEnv, logDebug, streamToStdin, resolveClaudeCommand } from './utils.js';
|
|
11
12
|
|
|
12
13
|
|
|
@@ -82,6 +83,7 @@ export class Query {
|
|
|
82
83
|
// Track whether we've forwarded text deltas for the current assistant turn.
|
|
83
84
|
// When true, the next complete `assistant` message's text blocks are redundant.
|
|
84
85
|
let hasStreamedTextDeltas = false;
|
|
86
|
+
const streamBlockTypes = new Map();
|
|
85
87
|
|
|
86
88
|
try {
|
|
87
89
|
for await (const line of rl) {
|
|
@@ -114,36 +116,52 @@ export class Query {
|
|
|
114
116
|
const event = message.event;
|
|
115
117
|
if (!event) continue;
|
|
116
118
|
|
|
117
|
-
|
|
119
|
+
if (event.type === 'content_block_start') {
|
|
120
|
+
streamBlockTypes.set(event.index, event.content_block?.type || null);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (event.type === 'content_block_stop') {
|
|
124
|
+
streamBlockTypes.delete(event.index);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// content_block_delta with text_delta → convert to assistant message for streaming,
|
|
128
|
+
// but only while the active block is actually text. Some Claude Code builds
|
|
129
|
+
// expose tool-call assembly as text-looking deltas; forwarding those leaks
|
|
130
|
+
// raw `call`, transient ids, and JSON arguments into the chat transcript.
|
|
118
131
|
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
const blockType = streamBlockTypes.get(event.index);
|
|
133
|
+
if (shouldForwardTextDeltaForBlockType(blockType)) {
|
|
134
|
+
hasStreamedTextDeltas = true;
|
|
135
|
+
this.inputStream.enqueue({
|
|
136
|
+
type: 'assistant',
|
|
137
|
+
message: {
|
|
138
|
+
role: 'assistant',
|
|
139
|
+
content: [{ type: 'text', text: event.delta.text }]
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
127
143
|
}
|
|
128
|
-
// All other stream events (message_start,
|
|
129
|
-
//
|
|
130
|
-
//
|
|
144
|
+
// All other stream events (message_start, input_json_delta,
|
|
145
|
+
// message_stop) are ignored. Tool use is handled via the complete
|
|
146
|
+
// assistant message after block normalization below.
|
|
131
147
|
continue;
|
|
132
148
|
}
|
|
133
149
|
|
|
150
|
+
const normalizedMessage = normalizeClaudeMessage(message);
|
|
151
|
+
|
|
134
152
|
// Deduplicate: when a complete assistant message arrives after we've
|
|
135
153
|
// already streamed text deltas, strip the text blocks (already sent).
|
|
136
154
|
// Keep tool_use blocks which are NOT sent incrementally.
|
|
137
|
-
if (
|
|
155
|
+
if (normalizedMessage.type === 'assistant' && hasStreamedTextDeltas) {
|
|
138
156
|
hasStreamedTextDeltas = false; // Reset for next assistant turn
|
|
139
157
|
|
|
140
|
-
const content =
|
|
158
|
+
const content = normalizedMessage.message?.content;
|
|
141
159
|
if (Array.isArray(content)) {
|
|
142
160
|
const nonTextBlocks = content.filter(b => b.type !== 'text');
|
|
143
161
|
if (nonTextBlocks.length > 0) {
|
|
144
162
|
// Forward only tool_use blocks (text already sent via deltas)
|
|
145
|
-
|
|
146
|
-
this.inputStream.enqueue(
|
|
163
|
+
normalizedMessage.message.content = nonTextBlocks;
|
|
164
|
+
this.inputStream.enqueue(normalizedMessage);
|
|
147
165
|
} else {
|
|
148
166
|
// Pure text message fully streamed — send finish-streaming signal
|
|
149
167
|
// so frontend clears isStreaming and typing dots can reappear
|
|
@@ -165,11 +183,12 @@ export class Query {
|
|
|
165
183
|
|
|
166
184
|
// Reset delta tracking on non-assistant messages
|
|
167
185
|
// (e.g., user, result, system — a new turn boundary)
|
|
168
|
-
if (
|
|
186
|
+
if (normalizedMessage.type !== 'assistant') {
|
|
169
187
|
hasStreamedTextDeltas = false;
|
|
188
|
+
streamBlockTypes.clear();
|
|
170
189
|
}
|
|
171
190
|
|
|
172
|
-
this.inputStream.enqueue(
|
|
191
|
+
this.inputStream.enqueue(normalizedMessage);
|
|
173
192
|
} catch (e) {
|
|
174
193
|
logDebug(`Non-JSON line: ${line.substring(0, 100)}`);
|
|
175
194
|
}
|
|
@@ -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
|
+
};
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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
|
|
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
|
|