@yeaft/webchat-agent 1.0.351 → 1.0.352

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.
Files changed (45) hide show
  1. package/connection/message-router.js +29 -1
  2. package/index.js +2 -0
  3. package/local-runtime/server/handlers/agent-sync.js +14 -0
  4. package/local-runtime/server/handlers/client-misc.js +21 -0
  5. package/local-runtime/version.json +1 -1
  6. package/local-runtime/web/app.bundle.js +52 -33
  7. package/local-runtime/web/app.bundle.js.gz +0 -0
  8. package/local-runtime/web/index.html +2 -2
  9. package/local-runtime/web/style.bundle.css +1 -1
  10. package/local-runtime/web/style.bundle.css.gz +0 -0
  11. package/package.json +1 -1
  12. package/yeaft/config-api.js +53 -1
  13. package/yeaft/config.js +54 -0
  14. package/yeaft/conversation/history-index-worker.js +13 -10
  15. package/yeaft/conversation/internal-control.js +1 -0
  16. package/yeaft/debug-trace.js +164 -47
  17. package/yeaft/engine.js +318 -28
  18. package/yeaft/llm/adapter.js +38 -0
  19. package/yeaft/llm/anthropic.js +11 -8
  20. package/yeaft/llm/openai-responses.js +11 -8
  21. package/yeaft/llm/router.js +1 -1
  22. package/yeaft/perf-trace.js +156 -24
  23. package/yeaft/session.js +7 -0
  24. package/yeaft/sessions/session-crud.js +19 -4
  25. package/yeaft/sub-agent/runner.js +4 -0
  26. package/yeaft/tools/agent.js +4 -0
  27. package/yeaft/tools/ask-user.js +1 -0
  28. package/yeaft/tools/bash.js +4 -0
  29. package/yeaft/tools/create-work-item.js +3 -0
  30. package/yeaft/tools/file-read.js +1 -0
  31. package/yeaft/tools/glob.js +1 -0
  32. package/yeaft/tools/grep.js +1 -0
  33. package/yeaft/tools/history-search.js +74 -20
  34. package/yeaft/tools/js-repl.js +1 -0
  35. package/yeaft/tools/list-agents.js +1 -0
  36. package/yeaft/tools/list-dir.js +1 -0
  37. package/yeaft/tools/list-tasks.js +1 -0
  38. package/yeaft/tools/read-task-log.js +1 -0
  39. package/yeaft/tools/route-forward.js +4 -0
  40. package/yeaft/tools/send-message.js +3 -0
  41. package/yeaft/tools/types.js +8 -0
  42. package/yeaft/tools/wait-agent.js +1 -0
  43. package/yeaft/utf8.js +44 -0
  44. package/yeaft/web-bridge.js +6 -0
  45. package/yeaft/work-center/runner.js +1 -0
@@ -20,6 +20,7 @@ import {
20
20
  redactRawRequest,
21
21
  safeHeaders,
22
22
  SseLineBuffer,
23
+ createBoundedTextAccumulator,
23
24
  toWellFormedJson,
24
25
  } from './adapter.js';
25
26
  import {
@@ -242,7 +243,7 @@ export class AnthropicAdapter extends LLMAdapter {
242
243
  * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', effortContext?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
243
244
  * @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
244
245
  */
245
- async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext, signal, onRawExchange, onRequestStart }) {
246
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext, signal, onRawExchange, rawExchangeMaxBytes = 512 * 1024, onRequestStart }) {
246
247
  if (signal?.aborted) throw new LLMAbortError();
247
248
 
248
249
  const body = {
@@ -323,11 +324,10 @@ export class AnthropicAdapter extends LLMAdapter {
323
324
  // signature → next turn 400s identically).
324
325
  /** @type {Map<number, { kind: string, [k: string]: any }>} */
325
326
  const blockByIndex = new Map();
326
- // Accumulate raw SSE body verbatim for the debug panel. No truncation:
327
- // see `redactRawRequest` in adapter.js for the verbatim-design rationale.
328
- // Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
329
- // O(n²) string concat).
330
- const rawSseBodyChunks = [];
327
+ // Keep raw SSE chunks only until the engine receives the bounded exchange
328
+ // callback. The engine owns the configured byte budget; the adapter avoids
329
+ // quadratic string concatenation by storing chunks separately.
330
+ const rawSseBody = createBoundedTextAccumulator(rawExchangeMaxBytes);
331
331
  const responseHeaders = safeHeaders(response);
332
332
  const responseStatus = response.status;
333
333
  let sawStop = false;
@@ -344,7 +344,7 @@ export class AnthropicAdapter extends LLMAdapter {
344
344
  if (done) break;
345
345
 
346
346
  const chunkText = decoder.decode(value, { stream: true });
347
- rawSseBodyChunks.push(chunkText);
347
+ rawSseBody.push(chunkText);
348
348
  const lines = sseLines.push(chunkText);
349
349
 
350
350
  for (const line of lines) {
@@ -515,8 +515,11 @@ export class AnthropicAdapter extends LLMAdapter {
515
515
  rawResponse: {
516
516
  status: responseStatus,
517
517
  headers: responseHeaders,
518
- body: rawSseBodyChunks.join(''),
518
+ body: rawSseBody.text(),
519
519
  format: 'sse',
520
+ truncated: rawSseBody.truncated,
521
+ originalBytes: rawSseBody.totalBytes,
522
+ maxBytes: rawSseBody.maxBytes,
520
523
  },
521
524
  });
522
525
  } catch { /* ignore */ }
@@ -39,6 +39,7 @@ import {
39
39
  redactRawRequest,
40
40
  safeHeaders,
41
41
  SseLineBuffer,
42
+ createBoundedTextAccumulator,
42
43
  toWellFormedJson,
43
44
  } from './adapter.js';
44
45
  import {
@@ -259,7 +260,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
259
260
  * `api-key` headers are auto-redacted (see `redactRawRequest` in
260
261
  * `adapter.js`); request-body fields are caller-controlled.
261
262
  */
262
- async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext = {}, extraBody, signal, onRawExchange, onRequestStart }) {
263
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext = {}, extraBody, signal, onRawExchange, rawExchangeMaxBytes = 512 * 1024, onRequestStart }) {
263
264
  if (signal?.aborted) throw new LLMAbortError();
264
265
 
265
266
  const body = {
@@ -346,11 +347,10 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
346
347
  const emittedToolCallIds = new Set();
347
348
  let sawToolCall = false;
348
349
 
349
- // Accumulate raw SSE body verbatim for the debug panel. No truncation:
350
- // see `redactRawRequest` in adapter.js for the verbatim-design rationale.
351
- // Push-then-join keeps allocation bounded for multi-MiB payloads (avoids
352
- // O(n²) string concat).
353
- const rawSseBodyChunks = [];
350
+ // Keep raw SSE chunks only until the engine receives the bounded exchange
351
+ // callback. The engine owns the configured byte budget; the adapter avoids
352
+ // quadratic string concatenation by storing chunks separately.
353
+ const rawSseBody = createBoundedTextAccumulator(rawExchangeMaxBytes);
354
354
  const responseHeaders = safeHeaders(response);
355
355
  const responseStatus = response.status;
356
356
  let sawTerminalEvent = false;
@@ -364,7 +364,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
364
364
  });
365
365
  if (done) break;
366
366
  const chunkText = decoder.decode(value, { stream: true });
367
- rawSseBodyChunks.push(chunkText);
367
+ rawSseBody.push(chunkText);
368
368
 
369
369
  // SSE events are separated by blank lines; SseLineBuffer yields each
370
370
  // completed line (newline stripped) in O(n) total.
@@ -503,8 +503,11 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
503
503
  rawResponse: {
504
504
  status: responseStatus,
505
505
  headers: responseHeaders,
506
- body: rawSseBodyChunks.join(''),
506
+ body: rawSseBody.text(),
507
507
  format: 'sse',
508
+ truncated: rawSseBody.truncated,
509
+ originalBytes: rawSseBody.totalBytes,
510
+ maxBytes: rawSseBody.maxBytes,
508
511
  },
509
512
  });
510
513
  } catch { /* ignore */ }
@@ -610,7 +610,7 @@ export class AdapterRouter extends LLMAdapter {
610
610
  const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
611
611
  const sanitized = sanitizeMessagesForWire(filtered);
612
612
  try {
613
- yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId, effortContext });
613
+ yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId, effortContext, rawExchangeMaxBytes: params.rawExchangeMaxBytes });
614
614
  return;
615
615
  } catch (err) {
616
616
  this.#annotateAuthError(err, provider, params.model);
@@ -1,20 +1,88 @@
1
1
  import { appendFileSync, mkdirSync, readdirSync, rmSync } from 'fs';
2
2
  import { join } from 'path';
3
+ import { normalizeUtf8ByteBudget, toWellFormedText, utf8PrefixWithinBytes } from './utf8.js';
3
4
 
4
5
  const MAX_DETAIL_STRING = 512;
5
6
  const DEFAULT_RETENTION_DAYS = 3;
6
- let lastCleanupDay = null;
7
+ const DEFAULT_FLUSH_INTERVAL_MS = 1_000;
8
+ const DEFAULT_MAX_QUEUE_SIZE = 5_000;
9
+ const DEFAULT_RAW_EXCHANGE_MAX_BYTES = 512 * 1024;
10
+ const lastCleanupDays = new Map();
11
+ const queues = new Map();
12
+ const flushTimers = new Map();
7
13
 
8
- function retentionDays() {
9
- const raw = Number(process.env.PERF_TRACE_RETENTION_DAYS || process.env.YEAFT_PERF_TRACE_RETENTION_DAYS || DEFAULT_RETENTION_DAYS);
10
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_RETENTION_DAYS;
14
+ function telemetryConfig(config) {
15
+ const value = config?.telemetry && typeof config.telemetry === 'object' ? config.telemetry : {};
16
+ const number = (candidate, fallback, min, max) => {
17
+ const parsed = Number(candidate);
18
+ if (!Number.isFinite(parsed)) return fallback;
19
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
20
+ };
21
+ return {
22
+ enabled: value.enabled !== false,
23
+ retentionDays: number(value.retentionDays, DEFAULT_RETENTION_DAYS, 1, 3650),
24
+ flushIntervalMs: number(value.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS, 0, 60_000),
25
+ maxQueueSize: number(value.maxQueueSize, DEFAULT_MAX_QUEUE_SIZE, 100, 50_000),
26
+ rawExchangeMaxBytes: number(value.rawExchangeMaxBytes, DEFAULT_RAW_EXCHANGE_MAX_BYTES, 0, 4 * 1024 * 1024),
27
+ traceTextMaxBytes: number(value.traceTextMaxBytes, 256 * 1024, 0, 4 * 1024 * 1024),
28
+ };
29
+ }
30
+
31
+ export function resolveAgentLocalRoot(config) {
32
+ for (const candidate of [config?.yeaftDir, config?.dir]) {
33
+ if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function queueKey(config) {
39
+ return resolveAgentLocalRoot(config);
40
+ }
41
+
42
+ export function truncateUtf8Text(value, maxBytes = DEFAULT_RAW_EXCHANGE_MAX_BYTES) {
43
+ const limit = normalizeUtf8ByteBudget(maxBytes, DEFAULT_RAW_EXCHANGE_MAX_BYTES);
44
+ const text = toWellFormedText(value);
45
+ const originalBytes = Buffer.byteLength(text, 'utf8');
46
+ if (originalBytes <= limit) return { value: text, truncated: false, originalBytes };
47
+
48
+ // One code-point pass avoids the old repeated slice + Buffer.byteLength()
49
+ // backtracking loop, which could stall the event loop on a large raw
50
+ // request or response. The shared helper also refuses to split surrogate
51
+ // pairs and normalizes lone surrogates before producing a preview.
52
+ const prefix = utf8PrefixWithinBytes(text, limit);
53
+ return { value: prefix.text, truncated: true, originalBytes };
11
54
  }
12
55
 
13
- function cleanupOldTraceFiles(root) {
56
+ export function boundRawExchange(value, maxBytes = DEFAULT_RAW_EXCHANGE_MAX_BYTES) {
57
+ const limit = normalizeUtf8ByteBudget(maxBytes, DEFAULT_RAW_EXCHANGE_MAX_BYTES);
58
+ if (value == null || limit <= 0) return limit <= 0 && value != null ? { __truncated: true, maxBytes: limit } : value;
59
+ if (typeof value === 'string') {
60
+ const result = truncateUtf8Text(value, limit);
61
+ return result.truncated ? { __truncated: true, maxBytes: limit, originalBytes: result.originalBytes, preview: result.value } : result.value;
62
+ }
63
+ try {
64
+ const json = JSON.stringify(value);
65
+ const result = truncateUtf8Text(json, limit);
66
+ if (!result.truncated) return JSON.parse(json);
67
+ return { __truncated: true, maxBytes: limit, originalBytes: result.originalBytes, preview: result.value };
68
+ } catch {
69
+ return { __truncated: true, maxBytes: limit };
70
+ }
71
+ }
72
+
73
+ function retentionDays(config) {
74
+ const configured = Number(telemetryConfig(config).retentionDays);
75
+ const env = Number(process.env.PERF_TRACE_RETENTION_DAYS || process.env.YEAFT_PERF_TRACE_RETENTION_DAYS);
76
+ if (Number.isFinite(env) && env > 0) return Math.floor(env);
77
+ return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_RETENTION_DAYS;
78
+ }
79
+
80
+ function cleanupOldTraceFiles(root, config = null) {
14
81
  const day = new Date().toISOString().slice(0, 10);
15
- if (lastCleanupDay === day) return;
16
- lastCleanupDay = day;
17
- const keepMs = retentionDays() * 24 * 60 * 60 * 1000;
82
+ const key = root;
83
+ if (lastCleanupDays.get(key) === day) return;
84
+ lastCleanupDays.set(key, day);
85
+ const keepMs = retentionDays(config) * 24 * 60 * 60 * 1000;
18
86
  const cutoff = Date.now() - keepMs;
19
87
  try {
20
88
  for (const file of readdirSync(root)) {
@@ -57,15 +125,74 @@ export function perfNowMs() {
57
125
  return Number(process.hrtime.bigint()) / 1e6;
58
126
  }
59
127
 
128
+ function flushQueue(key, config) {
129
+ const queue = queues.get(key);
130
+ if (!queue || queue.length === 0) return 0;
131
+ const batch = queue.splice(0, queue.length);
132
+ const root = join(key, 'perf-traces');
133
+ const day = new Date().toISOString().slice(0, 10);
134
+ try {
135
+ mkdirSync(root, { recursive: true });
136
+ cleanupOldTraceFiles(root, config);
137
+ appendFileSync(join(root, `${day}.jsonl`), batch.map(row => JSON.stringify(row)).join('\n') + '\n');
138
+ return batch.length;
139
+ } catch (err) {
140
+ // Put the batch back only when the queue has room. Losing diagnostics is
141
+ // preferable to blocking the engine or growing memory without a bound.
142
+ const limit = telemetryConfig(config).maxQueueSize;
143
+ queues.set(key, [...batch.slice(-limit), ...(queues.get(key) || [])].slice(-limit));
144
+ if (process.env.YEAFT_PERF_TRACE_DEBUG === '1') {
145
+ console.warn('[Yeaft] perf trace write failed:', err?.message || err);
146
+ }
147
+ return 0;
148
+ }
149
+ }
150
+
151
+ function scheduleFlush(key, config) {
152
+ if (flushTimers.has(key)) return;
153
+ const delay = telemetryConfig(config).flushIntervalMs;
154
+ if (delay <= 0) {
155
+ queueMicrotask(() => flushQueue(key, config));
156
+ return;
157
+ }
158
+ const timer = setTimeout(() => {
159
+ flushTimers.delete(key);
160
+ flushQueue(key, config);
161
+ }, delay);
162
+ if (typeof timer.unref === 'function') timer.unref();
163
+ flushTimers.set(key, timer);
164
+ }
165
+
166
+ export function flushAgentPerfTrace(config) {
167
+ const key = queueKey(config);
168
+ if (!key) return 0;
169
+ const timer = flushTimers.get(key);
170
+ if (timer) {
171
+ clearTimeout(timer);
172
+ flushTimers.delete(key);
173
+ }
174
+ return flushQueue(key, config);
175
+ }
176
+
177
+ export function flushAllAgentPerfTraces() {
178
+ let count = 0;
179
+ for (const [key, queue] of queues) {
180
+ if (!queue.length) continue;
181
+ const config = { yeaftDir: key };
182
+ count += flushQueue(key, config);
183
+ }
184
+ for (const timer of flushTimers.values()) clearTimeout(timer);
185
+ flushTimers.clear();
186
+ return count;
187
+ }
188
+
60
189
  export function recordAgentPerfTrace(config, event = {}) {
61
190
  const traceId = typeof event.traceId === 'string' && event.traceId.trim()
62
191
  ? event.traceId.trim()
63
192
  : (typeof event.perfTraceId === 'string' && event.perfTraceId.trim() ? event.perfTraceId.trim() : null);
64
- if (!traceId) return false;
65
- const yeaftDir = config?.yeaftDir;
66
- if (typeof yeaftDir !== 'string' || !yeaftDir.trim()) return false;
67
- const root = join(yeaftDir.trim(), 'perf-traces');
68
- const day = new Date().toISOString().slice(0, 10);
193
+ const key = queueKey(config);
194
+ const settings = telemetryConfig(config);
195
+ if (!traceId || !key || !settings.enabled) return false;
69
196
  const row = {
70
197
  traceId,
71
198
  source: 'agent',
@@ -82,20 +209,25 @@ export function recordAgentPerfTrace(config, event = {}) {
82
209
  ok: typeof event.ok === 'boolean' ? event.ok : null,
83
210
  detail: sanitizeValue(event.detail || null),
84
211
  };
85
- try {
86
- mkdirSync(root, { recursive: true });
87
- cleanupOldTraceFiles(root);
88
- appendFileSync(join(root, `${day}.jsonl`), `${JSON.stringify(row)}\n`);
89
- return true;
90
- } catch (err) {
91
- if (process.env.YEAFT_PERF_TRACE_DEBUG === '1') {
92
- console.warn('[Yeaft] perf trace write failed:', err?.message || err);
93
- }
94
- return false;
95
- }
212
+ const queue = queues.get(key) || [];
213
+ if (queue.length >= settings.maxQueueSize) queue.shift();
214
+ queue.push(row);
215
+ queues.set(key, queue);
216
+ // Never write synchronously from the engine phase hook. The timer batches
217
+ // events across the turn; session shutdown calls flushAgentPerfTrace() as a
218
+ // durability boundary for short-lived CLI runs.
219
+ if (queue.length >= settings.maxQueueSize) flushQueue(key, config);
220
+ else scheduleFlush(key, config);
221
+ return true;
96
222
  }
97
223
 
98
224
  export const __perfTraceForTest = {
99
225
  sanitizeValue,
100
226
  cleanupOldTraceFiles,
227
+ telemetryConfig,
228
+ queues,
229
+ flushTimers,
230
+ boundRawExchange,
231
+ truncateUtf8Text,
232
+ flushAllAgentPerfTraces,
101
233
  };
package/yeaft/session.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { initYeaftDir, DEFAULT_YEAFT_DIR, isWritable } from './init.js';
17
17
  import { loadConfig, loadMCPConfig } from './config.js';
18
18
  import { createTrace } from './debug-trace.js';
19
+ import { flushAgentPerfTrace } from './perf-trace.js';
19
20
  import { createLLMAdapter } from './llm/adapter.js';
20
21
  import { withUsageAccounting } from './llm/usage-accounting.js';
21
22
  import { recordAgentTokenUsage } from '../metrics.js';
@@ -254,6 +255,7 @@ export async function loadSession(options = {}) {
254
255
  const trace = createTrace({
255
256
  enabled: config.debug === true,
256
257
  dirPath: yeaftDir,
258
+ textMaxBytes: config.telemetry?.traceTextMaxBytes,
257
259
  });
258
260
 
259
261
  // ─── 4. Create LLM adapter ────────────────────────────
@@ -591,6 +593,11 @@ export async function loadSession(options = {}) {
591
593
  } catch {
592
594
  // Trace might not have close() (NullTrace)
593
595
  }
596
+ try {
597
+ flushAgentPerfTrace(config);
598
+ } catch {
599
+ // Performance telemetry is best-effort and must not block shutdown.
600
+ }
594
601
  try {
595
602
  if (memoryIndex) memoryIndex.close();
596
603
  } catch {
@@ -403,14 +403,17 @@ export function restoreSessionToRegistry(defaultYeaftDir, sessionId, workDir) {
403
403
  return importedMeta;
404
404
  }
405
405
 
406
- export function resolveSessionYeaftDir(defaultYeaftDir, sessionId) {
406
+ /**
407
+ * Resolve an already-existing Session owner without bootstrapping or migrating
408
+ * Session storage. Read-only callers must use this instead of
409
+ * resolveSessionYeaftDir(): the latter intentionally repairs the manifest for
410
+ * runtime/CRUD entry points.
411
+ */
412
+ export function findExistingSessionYeaftDir(defaultYeaftDir, sessionId) {
407
413
  if (!defaultYeaftDir || !sessionId) return defaultYeaftDir;
408
414
 
409
- const manifestReady = hasSessionManifest(defaultYeaftDir);
410
- ensureSessionManifestReady(defaultYeaftDir);
411
415
  const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
412
416
  if (manifestDir) return join(manifestDir, '..', '..');
413
- if (manifestReady) return defaultYeaftDir;
414
417
 
415
418
  const registry = readWorkDirRegistry(defaultYeaftDir);
416
419
  const workDir = normalizeWorkDir(registry[sessionId]);
@@ -426,6 +429,18 @@ export function resolveSessionYeaftDir(defaultYeaftDir, sessionId) {
426
429
  return defaultYeaftDir;
427
430
  }
428
431
 
432
+ export function resolveSessionYeaftDir(defaultYeaftDir, sessionId) {
433
+ if (!defaultYeaftDir || !sessionId) return defaultYeaftDir;
434
+
435
+ const manifestReady = hasSessionManifest(defaultYeaftDir);
436
+ ensureSessionManifestReady(defaultYeaftDir);
437
+ const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
438
+ if (manifestDir) return join(manifestDir, '..', '..');
439
+ if (manifestReady) return defaultYeaftDir;
440
+
441
+ return findExistingSessionYeaftDir(defaultYeaftDir, sessionId);
442
+ }
443
+
429
444
  /** Build a safe group id from a display name (slug + ulid-lite suffix). */
430
445
  export function makeSessionId(name) {
431
446
  const slug = String(name || 'session')
@@ -422,6 +422,10 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
422
422
  scenario: 'chat',
423
423
  vpPersona,
424
424
  sessionId: agent.parentSessionId || deps.parentSessionId || null,
425
+ // SpawnAgent records the caller-provided cwd on the agent. Thread it
426
+ // into the child Engine just like a parent query's workDir so child
427
+ // file tools resolve relative paths in the requested workspace.
428
+ workDir: agent.cwd,
425
429
  projectSessionIds: queuedPrompt.projectSessionIds,
426
430
  projectLabel: queuedPrompt.projectLabel,
427
431
  projectInstruction: queuedPrompt.projectInstruction,
@@ -341,6 +341,10 @@ use it as the default workflow or call it repeatedly in a loop.`,
341
341
  },
342
342
  isConcurrencySafe: () => false,
343
343
  isReadOnly: () => false,
344
+ // The child driver is fire-and-forget and inherits writable workspace
345
+ // tools. A parent query therefore cannot safely reuse filesystem snapshots
346
+ // after a SpawnAgent call returns.
347
+ mayMutateWorkspaceAfterReturn: () => true,
344
348
  async execute(input, ctx) {
345
349
  // NB: every envelope below puts `next_steps` (or `error_next_steps`) at
346
350
  // the FIRST position because `agent/yeaft/tools/registry.js` caps tool
@@ -56,6 +56,7 @@ Guidelines:
56
56
  },
57
57
  isConcurrencySafe: () => false,
58
58
  isReadOnly: () => true,
59
+ cacheWithinQuery: false,
59
60
  // This is an intentionally user-driven wait, not a stalled tool call.
60
61
  timeoutMs: 0,
61
62
  async execute(input, ctx) {
@@ -171,6 +171,10 @@ Guidelines:
171
171
  timeoutMs: 0,
172
172
  isConcurrencySafe: () => false,
173
173
  isReadOnly: () => false,
174
+ // A detached shell task can write after this tool has returned. Once one is
175
+ // launched, same-query filesystem reads must execute instead of reusing a
176
+ // snapshot captured before the task's eventual mutation.
177
+ mayMutateWorkspaceAfterReturn: input => input?.background === true,
174
178
  isDestructive: (input) => {
175
179
  if (!input?.command) return false;
176
180
  const cmd = input.command.toLowerCase();
@@ -49,6 +49,9 @@ Use this when work must continue beyond the current turn, needs role handoffs, r
49
49
  },
50
50
  isConcurrencySafe: () => false,
51
51
  isReadOnly: () => false,
52
+ // The Work Center watcher can advance from triage into a writable Action
53
+ // after creation returns. A paused item has no watcher-owned execution.
54
+ mayMutateWorkspaceAfterReturn: input => input?.start !== false,
52
55
  async execute(input, ctx = {}) {
53
56
  const sessionId = typeof ctx.sessionId === 'string' ? ctx.sessionId.trim() : '';
54
57
  if (!sessionId) throw new Error('CreateWorkItem requires an active Session');
@@ -139,6 +139,7 @@ Guidelines:
139
139
  },
140
140
  isConcurrencySafe: () => true,
141
141
  isReadOnly: () => true,
142
+ cacheWithinQuery: true,
142
143
  async execute(input, ctx) {
143
144
  const { file_path, offset = 0, column_offset = 0, limit = DEFAULT_LIMIT } = input;
144
145
  if (!file_path) return JSON.stringify({ error: 'file_path is required' });
@@ -164,6 +164,7 @@ Guidelines:
164
164
  },
165
165
  isConcurrencySafe: () => true,
166
166
  isReadOnly: () => true,
167
+ cacheWithinQuery: true,
167
168
  async execute(input, ctx) {
168
169
  const { pattern, path: searchPath, limit = 500 } = input;
169
170
  if (!pattern) return JSON.stringify({ error: 'pattern is required' });
@@ -922,6 +922,7 @@ Guidelines:
922
922
  },
923
923
  isConcurrencySafe: () => true,
924
924
  isReadOnly: () => true,
925
+ cacheWithinQuery: true,
925
926
  async execute(input, ctx) {
926
927
  const {
927
928
  pattern, path: searchPath, output_mode = 'files_with_matches',
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { defineTool } from './types.js';
9
9
  import { searchMessages } from '../conversation/search.js';
10
+ import { findExistingSessionYeaftDir } from '../sessions/session-crud.js';
10
11
 
11
12
  const DEFAULT_RESULT_LIMIT = 10;
12
13
  const MAX_SNIPPET_CHARS = 1000;
@@ -137,7 +138,7 @@ export default defineTool({
137
138
  description: {
138
139
  en: `Search through past conversation history.
139
140
 
140
- Searches message content for all whitespace-separated terms (case-insensitive).
141
+ Searches message content for all whitespace-separated terms (case-insensitive) from persisted Session history.
141
142
  Inside a Session, search is limited to that Session plus sibling Sessions in the same Project on this Agent. Tool-result messages are excluded. Useful for finding previous discussions, decisions, or code snippets.
142
143
 
143
144
  Results are returned newest-first with a bounded matching snippet and source metadata.`,
@@ -171,7 +172,8 @@ Results are returned newest-first with a bounded matching snippet and source met
171
172
  isReadOnly: () => true,
172
173
  async execute(input, ctx) {
173
174
  const { keyword, limit = DEFAULT_RESULT_LIMIT } = input;
174
- if (!keyword) return JSON.stringify({ error: 'keyword is required' });
175
+ const normalizedKeyword = typeof keyword === 'string' ? keyword.trim() : '';
176
+ if (!normalizedKeyword) return JSON.stringify({ error: 'keyword is required' });
175
177
 
176
178
  const yeaftDir = ctx?.yeaftDir;
177
179
  if (!yeaftDir) {
@@ -179,44 +181,96 @@ Results are returned newest-first with a bounded matching snippet and source met
179
181
  }
180
182
 
181
183
  try {
182
- const telemetry = {};
183
184
  const projectSessionIds = Array.isArray(ctx?.projectSessionIds)
184
185
  ? ctx.projectSessionIds.filter(id => typeof id === 'string' && id.trim()).map(id => id.trim())
185
186
  : [];
186
187
  const scopedSessionIds = ctx?.sessionId
187
188
  ? Array.from(new Set([ctx.sessionId, ...projectSessionIds]))
188
189
  : null;
189
- const results = searchMessages(yeaftDir, keyword, limit, {
190
- telemetry,
191
- ...(scopedSessionIds ? { sessionIds: scopedSessionIds } : {}),
190
+ const telemetry = {};
191
+ let indexedResults = [];
192
+
193
+ if (scopedSessionIds) {
194
+ // The index manager builds/rebuilds SQLite state and Session location
195
+ // repair may migrate the manifest. Both are writes, so they cannot run
196
+ // behind a read-only/cachable tool declaration. Scan only existing
197
+ // transcript files; normal Session boot and maintenance own indexing.
198
+ for (const sessionId of scopedSessionIds) {
199
+ const storeDir = findExistingSessionYeaftDir(yeaftDir, sessionId);
200
+ const scanTelemetry = {};
201
+ const messages = searchMessages(storeDir, normalizedKeyword, limit, {
202
+ telemetry: scanTelemetry,
203
+ sessionIds: [sessionId],
204
+ });
205
+ telemetry.scannedSessions = (telemetry.scannedSessions || 0) + 1;
206
+ telemetry.scannedFiles = (telemetry.scannedFiles || 0) + (scanTelemetry.scannedFiles || 0);
207
+ telemetry.scannedMessages = (telemetry.scannedMessages || 0) + (scanTelemetry.scannedMessages || 0);
208
+ telemetry.scannedBytes = (telemetry.scannedBytes || 0) + (scanTelemetry.scannedBytes || 0);
209
+ for (const message of messages) {
210
+ indexedResults.push({
211
+ messageId: message.id || null,
212
+ sessionId: message.sessionId || sessionId,
213
+ role: message.role,
214
+ content: buildSnippet(message.content || '', normalizedKeyword),
215
+ mode: message.mode || null,
216
+ time: message.time || message.timestamp || null,
217
+ source: message.historySource || 'session-scan',
218
+ turnId: message.turnId || null,
219
+ _seq: 0,
220
+ });
221
+ }
222
+ }
223
+ } else {
224
+ // CLI and legacy callers without a Session context retain the old
225
+ // global search path. The indexed path requires an explicit session
226
+ // because it is intentionally scoped and owner-safe.
227
+ const legacyResults = searchMessages(yeaftDir, normalizedKeyword, limit, { telemetry });
228
+ indexedResults = legacyResults.map(msg => ({
229
+ messageId: msg.id || null,
230
+ sessionId: msg.sessionId || null,
231
+ role: msg.role,
232
+ content: buildSnippet(msg.content, normalizedKeyword),
233
+ mode: msg.mode,
234
+ time: msg.time || msg.timestamp || null,
235
+ source: msg.historySource || null,
236
+ _seq: 0,
237
+ }));
238
+ }
239
+
240
+ indexedResults.sort((a, b) => {
241
+ const time = String(b.time || '').localeCompare(String(a.time || ''));
242
+ if (time !== 0) return time;
243
+ return (b._seq || 0) - (a._seq || 0);
192
244
  });
245
+ const results = indexedResults.slice(0, Math.max(1, Math.min(100, Number(limit) || DEFAULT_RESULT_LIMIT)));
193
246
  const searchTelemetry = {
194
247
  resultCount: results.length,
195
- scannedFiles: telemetry.scannedFiles || 0,
196
- scannedMessages: telemetry.scannedMessages || 0,
197
- scannedBytes: telemetry.scannedBytes || 0,
248
+ ...(scopedSessionIds
249
+ ? {
250
+ scannedSessions: telemetry.scannedSessions || 0,
251
+ scannedFiles: telemetry.scannedFiles || 0,
252
+ scannedMessages: telemetry.scannedMessages || 0,
253
+ scannedBytes: telemetry.scannedBytes || 0,
254
+ }
255
+ : {
256
+ scannedFiles: telemetry.scannedFiles || 0,
257
+ scannedMessages: telemetry.scannedMessages || 0,
258
+ scannedBytes: telemetry.scannedBytes || 0,
259
+ }),
198
260
  };
199
261
 
200
262
  if (results.length === 0) {
201
263
  return serializeHistorySearchOutput({
202
264
  results: [],
203
- message: `No matches found for "${keyword}"`,
265
+ message: `No matches found for "${normalizedKeyword}"`,
204
266
  telemetry: searchTelemetry,
205
267
  });
206
268
  }
207
269
 
208
270
  return serializeHistorySearchOutput({
209
- results: results.map(msg => ({
210
- messageId: msg.id || null,
211
- sessionId: msg.sessionId || null,
212
- role: msg.role,
213
- content: buildSnippet(msg.content, keyword),
214
- mode: msg.mode,
215
- time: msg.time || msg.timestamp || null,
216
- source: msg.historySource || null,
217
- })),
271
+ results: results.map(({ _seq, ...result }) => result),
218
272
  totalResults: results.length,
219
- keyword,
273
+ keyword: normalizedKeyword,
220
274
  telemetry: searchTelemetry,
221
275
  });
222
276
  } catch (err) {
@@ -100,6 +100,7 @@ REPL 上下文在多次调用间保持——一次调用中定义的变量和函
100
100
  },
101
101
  isConcurrencySafe: () => false,
102
102
  isReadOnly: () => true,
103
+ cacheWithinQuery: false,
103
104
  async execute(input, ctx) {
104
105
  const { code, reset } = input || {};
105
106
 
@@ -57,6 +57,7 @@ stale/stalled 诊断、result 尾部和消息数量。将此作为异步子 Agen
57
57
  },
58
58
  isConcurrencySafe: () => true,
59
59
  isReadOnly: () => true,
60
+ cacheWithinQuery: false,
60
61
  async execute(input, ctx) {
61
62
  const includeTerminal = Boolean(input?.include_closed || input?.include_terminal);
62
63
  const agents = getAgentRegistry();
@@ -52,6 +52,7 @@ This is better than using Bash with 'ls' because it provides structured output.`
52
52
  },
53
53
  isConcurrencySafe: () => true,
54
54
  isReadOnly: () => true,
55
+ cacheWithinQuery: true,
55
56
  async execute(input, ctx) {
56
57
  const { path: dirPath, show_hidden = true } = input;
57
58