@yeaft/webchat-agent 0.1.510 → 0.1.512

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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * task-330b — Routing metrics counter (Final Spec §B).
3
+ *
4
+ * Centralised observability for crew routing fallbacks. Five canonical
5
+ * reasons that any fallback path MUST pass to `recordRoutingEvent`:
6
+ *
7
+ * - missing-route : turn ended with no parseable ROUTE block
8
+ * - parse-fail : ROUTE block found but parse returned null/invalid
9
+ * - self-route : route.to resolves to the sender (rejected by §A)
10
+ * - state-stopped : message arrived while session was stopped/paused
11
+ * and was diverted/dropped
12
+ * - fallback-forward : auto-forward path engaged (non-PM → PM safety net)
13
+ *
14
+ * Counters are kept in-memory keyed by `${sessionId}::${reason}` and flushed
15
+ * to `${sharedDir}/context/routing-metrics.json` periodically (default 30s)
16
+ * AND on demand via `flushRoutingMetricsNow(session)`. The on-disk format:
17
+ *
18
+ * {
19
+ * "schemaVersion": 1,
20
+ * "lastFlushedAt": <ms>,
21
+ * "counts": {
22
+ * "missing-route": 4,
23
+ * "parse-fail": 1,
24
+ * "self-route": 0,
25
+ * "state-stopped": 2,
26
+ * "fallback-forward": 4
27
+ * },
28
+ * "recent": [
29
+ * { ts, reason, fromRole, toRole?, taskId?, note? },
30
+ * ... // bounded ring buffer (50)
31
+ * ]
32
+ * }
33
+ *
34
+ * Red lines (§330b):
35
+ * - Pure observer; never mutates routing decisions.
36
+ * - Never throws; failures degrade to console.warn so callers can rely on
37
+ * `recordRoutingEvent()` being safe inside hot paths.
38
+ *
39
+ * Red lines (§330a — shared with this PR):
40
+ * - No engine state-machine touch.
41
+ * - PM-self-loop is the responsibility of §A; §B only records the metric
42
+ * when §A rejects.
43
+ */
44
+
45
+ import { promises as fs } from 'fs';
46
+ import { join } from 'path';
47
+
48
+ export const ROUTING_REASONS = Object.freeze([
49
+ 'missing-route',
50
+ 'parse-fail',
51
+ 'self-route',
52
+ 'state-stopped',
53
+ 'fallback-forward',
54
+ ]);
55
+
56
+ const REASON_SET = new Set(ROUTING_REASONS);
57
+ const RECENT_RING_SIZE = 50;
58
+ const FLUSH_INTERVAL_MS = 30_000;
59
+
60
+ /**
61
+ * In-process state — ONE bag per process. Keyed by sessionId so multiple
62
+ * crew sessions running in the same agent each keep their own counts.
63
+ *
64
+ * Shape: Map<sessionId, {
65
+ * sharedDir: string,
66
+ * counts: Record<reason, number>,
67
+ * recent: Array<{ ts, reason, fromRole, toRole?, taskId?, note? }>,
68
+ * dirty: boolean,
69
+ * flushTimer: NodeJS.Timeout | null,
70
+ * }>
71
+ */
72
+ const _state = new Map();
73
+
74
+ function _zeroCounts() {
75
+ const c = {};
76
+ for (const r of ROUTING_REASONS) c[r] = 0;
77
+ return c;
78
+ }
79
+
80
+ function _getOrInit(session) {
81
+ const sid = session?.id;
82
+ if (!sid) return null;
83
+ let bag = _state.get(sid);
84
+ if (!bag) {
85
+ bag = {
86
+ sharedDir: session.sharedDir || null,
87
+ counts: _zeroCounts(),
88
+ recent: [],
89
+ dirty: false,
90
+ flushTimer: null,
91
+ };
92
+ _state.set(sid, bag);
93
+ }
94
+ // sharedDir may not be available at session creation — keep latest.
95
+ if (session.sharedDir) bag.sharedDir = session.sharedDir;
96
+ return bag;
97
+ }
98
+
99
+ /**
100
+ * Record a routing fallback event.
101
+ *
102
+ * @param {object} session — crew session (must have .id; .sharedDir for flush)
103
+ * @param {string} reason — one of ROUTING_REASONS
104
+ * @param {object} [meta]
105
+ * @param {string} [meta.fromRole]
106
+ * @param {string} [meta.toRole]
107
+ * @param {string} [meta.taskId]
108
+ * @param {string} [meta.note]
109
+ * @returns {boolean} true if recorded; false if invalid input
110
+ */
111
+ export function recordRoutingEvent(session, reason, meta = {}) {
112
+ if (!session || !session.id) return false;
113
+ if (!REASON_SET.has(reason)) {
114
+ console.warn(`[routing-metrics] Unknown reason: ${reason} (allowed: ${ROUTING_REASONS.join(', ')})`);
115
+ return false;
116
+ }
117
+ const bag = _getOrInit(session);
118
+ if (!bag) return false;
119
+
120
+ bag.counts[reason] = (bag.counts[reason] || 0) + 1;
121
+ bag.recent.push({
122
+ ts: Date.now(),
123
+ reason,
124
+ fromRole: meta.fromRole || null,
125
+ toRole: meta.toRole || null,
126
+ taskId: meta.taskId || null,
127
+ note: meta.note || null,
128
+ });
129
+ // Bound the ring.
130
+ if (bag.recent.length > RECENT_RING_SIZE) {
131
+ bag.recent.splice(0, bag.recent.length - RECENT_RING_SIZE);
132
+ }
133
+ bag.dirty = true;
134
+ _ensureTimer(session.id, bag);
135
+ return true;
136
+ }
137
+
138
+ function _ensureTimer(sessionId, bag) {
139
+ if (bag.flushTimer) return;
140
+ bag.flushTimer = setTimeout(() => {
141
+ bag.flushTimer = null;
142
+ _flush(sessionId, bag).catch((e) =>
143
+ console.warn(`[routing-metrics] periodic flush failed for ${sessionId}: ${e.message}`),
144
+ );
145
+ }, FLUSH_INTERVAL_MS);
146
+ // Don't keep the event loop alive solely for metrics flush.
147
+ if (typeof bag.flushTimer.unref === 'function') bag.flushTimer.unref();
148
+ }
149
+
150
+ async function _flush(sessionId, bag) {
151
+ if (!bag.dirty) return;
152
+ if (!bag.sharedDir) return; // can't flush without target dir
153
+ const dir = join(bag.sharedDir, 'context');
154
+ const file = join(dir, 'routing-metrics.json');
155
+ const payload = {
156
+ schemaVersion: 1,
157
+ lastFlushedAt: Date.now(),
158
+ counts: { ...bag.counts },
159
+ recent: bag.recent.slice(),
160
+ };
161
+ try {
162
+ await fs.mkdir(dir, { recursive: true });
163
+ // Write-then-rename for atomicity (single-line file is small; tolerate
164
+ // platform quirks).
165
+ const tmp = `${file}.tmp`;
166
+ await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
167
+ await fs.rename(tmp, file);
168
+ bag.dirty = false;
169
+ } catch (e) {
170
+ console.warn(`[routing-metrics] flush write failed: ${e.message}`);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Force a synchronous-ish flush (still returns a Promise). Useful from
176
+ * shutdown paths or tests.
177
+ */
178
+ export async function flushRoutingMetricsNow(session) {
179
+ const bag = _state.get(session?.id);
180
+ if (!bag) return;
181
+ if (bag.flushTimer) {
182
+ clearTimeout(bag.flushTimer);
183
+ bag.flushTimer = null;
184
+ }
185
+ await _flush(session.id, bag);
186
+ }
187
+
188
+ /**
189
+ * Read current counts (test/inspection only; non-mutating snapshot).
190
+ * @returns {{ counts: Record<string, number>, recent: Array<object> } | null}
191
+ */
192
+ export function getRoutingMetricsSnapshot(session) {
193
+ const bag = _state.get(session?.id);
194
+ if (!bag) return null;
195
+ return {
196
+ counts: { ...bag.counts },
197
+ recent: bag.recent.slice(),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Reset (test-only).
203
+ */
204
+ export function _resetRoutingMetricsForTest(sessionId) {
205
+ if (sessionId) {
206
+ const bag = _state.get(sessionId);
207
+ if (bag?.flushTimer) clearTimeout(bag.flushTimer);
208
+ _state.delete(sessionId);
209
+ return;
210
+ }
211
+ for (const [, bag] of _state) {
212
+ if (bag.flushTimer) clearTimeout(bag.flushTimer);
213
+ }
214
+ _state.clear();
215
+ }
package/crew/routing.js CHANGED
@@ -1,12 +1,24 @@
1
1
  /**
2
2
  * Crew — 路由解析与执行
3
3
  * parseRoutes, executeRoute, buildRoutePrompt, dispatchToRole
4
+ *
5
+ * task-330c — Greedy-strip guard:
6
+ * ⚠️ ROUTE-block stripping lives in `parseRoutes()` ONLY. Callers that
7
+ * want the role's prose without ROUTE blocks must consume
8
+ * `parseRoutes(text).displayBody` — never run a second
9
+ * `text.replace(/---ROUTE---[\s\S]*$/g, '')` style strip on already
10
+ * parser-cleaned text. A second strip would (a) re-process text
11
+ * that no longer has ROUTE markers (no-op at best, miscut at worst),
12
+ * (b) reintroduce the greedy tail-eating bug fixed by task-328.
13
+ * The summary-fallback in role-output.js and the recent-routes
14
+ * injector below both honour this contract.
4
15
  */
5
16
  import { join } from 'path';
6
17
  import { sendCrewMessage, sendCrewOutput, sendStatusUpdate } from './ui-messages.js';
7
18
  import { ensureTaskFile, appendTaskRecord, readTaskFile, updateKanban, readKanban, saveRoleWorkSummary } from './task-files.js';
8
19
  import { createRoleQuery, clearRoleSessionId } from './role-query.js';
9
20
  import { saveSessionMeta } from './persistence.js';
21
+ import { recordRoutingEvent } from './routing-metrics.js';
10
22
  import ctx from '../context.js';
11
23
 
12
24
  /** Format role label */
@@ -14,6 +26,54 @@ function roleLabel(r) {
14
26
  return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
15
27
  }
16
28
 
29
+ /**
30
+ * task-330c — Smart truncate for recent-routes / history snippets.
31
+ *
32
+ * Cuts at a sentence/line boundary when possible to avoid mid-sentence
33
+ * truncation; falls back to a hard cut when no good boundary exists in
34
+ * the candidate window. Always appends a marker so downstream readers
35
+ * (LLM roles seeing recent-routes context) know the full text lives
36
+ * elsewhere (feature file).
37
+ *
38
+ * Boundary detection: looks for the last period (`.` `。` `!` `?` `!` `?`)
39
+ * or newline inside the window `[Math.floor(max*0.7), max)`. The 70% lower
40
+ * bound is a quality floor — we don't want to cut so early that we lose
41
+ * meaningful tail context just to hit a clean boundary.
42
+ *
43
+ * Idempotent: text already short enough is returned unchanged (no marker).
44
+ *
45
+ * @param {string} text — input string (may be any length)
46
+ * @param {number} max — maximum chars before truncation
47
+ * @returns {string} — original text or `<truncated>…(truncated, full in feature file)`
48
+ */
49
+ const TRUNCATE_MARKER = '…(truncated, full in feature file)';
50
+ export function smartTruncate(text, max) {
51
+ if (typeof text !== 'string') return '';
52
+ if (!Number.isFinite(max) || max <= 0) return '';
53
+ if (text.length <= max) return text;
54
+
55
+ // Search window: prefer cuts in the last 30% of the limit.
56
+ const windowStart = Math.floor(max * 0.7);
57
+ const windowSlice = text.slice(windowStart, max);
58
+ // Last sentence boundary in window — period family OR newline.
59
+ // We accept a boundary char and cut AFTER it so the sentence stays whole.
60
+ const BOUNDARY_RE = /[.。!?!?\n]/g;
61
+ let bestIdx = -1;
62
+ let m;
63
+ while ((m = BOUNDARY_RE.exec(windowSlice)) !== null) {
64
+ bestIdx = m.index;
65
+ }
66
+ let cutEnd;
67
+ if (bestIdx !== -1) {
68
+ cutEnd = windowStart + bestIdx + 1; // include the boundary char itself
69
+ } else {
70
+ cutEnd = max; // no boundary in window → hard cut
71
+ }
72
+ // Trim trailing whitespace from the cut piece for cleaner output.
73
+ const head = text.slice(0, cutEnd).replace(/\s+$/, '');
74
+ return `${head}${TRUNCATE_MARKER}`;
75
+ }
76
+
17
77
  /**
18
78
  * Append text to content — works for both string and multimodal array content.
19
79
  * For arrays, appends to the last text block (or adds a new one).
@@ -416,6 +476,77 @@ export function resolveRoleName(to, session, fromRole) {
416
476
  export async function executeRoute(session, fromRole, route, turnImages = []) {
417
477
  let { to, summary, taskId, taskTitle } = route;
418
478
 
479
+ // ─── task-330a §A + task-330b §B: self-route hard-reject + metric ───
480
+ // 福勒 Final Spec §A — `route.to` 等同于发送方时直接拒绝,不消费 turn、
481
+ // 不写 kanban、不 dispatch、不 round++(round 已由 role-output 计数)。
482
+ // 解析顺序:先尝试用 resolveRoleName 还原 alias(pm/dev/displayName/
483
+ // pm-乔布斯 等),命中即比较;未命中则退回原始字符串大小写不敏感比较。
484
+ // 拒绝时:先写 330b 的 routing-metrics.json 持久化 metric(observer 路径),
485
+ // 再 emit 330a 的 sendCrewMessage UI 卡片,最后 return(不消费 turn)。
486
+ // alias self-route 漏记 metric 已记入 PM backlog 作 follow-up(330b 的
487
+ // raw 比较 `to === fromRole` 仅命中字面相同的情况;alias 形式由 330a
488
+ // 的 isSelf 兜底,但 330b 的 raw 检查保留为快速路径 + 兼容)。
489
+ if (to !== 'human') {
490
+ const resolvedSelfCheck = resolveRoleName(to, session, fromRole);
491
+ const isSelf = resolvedSelfCheck === fromRole
492
+ || (typeof to === 'string' && to.toLowerCase() === String(fromRole).toLowerCase());
493
+ if (isSelf) {
494
+ console.warn(`[Crew] Self-route rejected: ${fromRole} → ${to} (taskId=${taskId || '-'})`);
495
+ // 330b path — persistent metric counter (routing-metrics.json + ring).
496
+ // Always-safe; never throws (recordRoutingEvent degrades to console.warn).
497
+ recordRoutingEvent(session, 'self-route', {
498
+ fromRole,
499
+ toRole: to,
500
+ taskId: taskId || null,
501
+ note: 'route.to === fromRole at executeRoute entry (rejected by §A)',
502
+ });
503
+ // 330a path — UI broadcast so the role sees rejection in transcript.
504
+ try {
505
+ sendCrewMessage({
506
+ type: 'routing-metrics',
507
+ sessionId: session.id,
508
+ event: 'route_rejected',
509
+ reason: 'self-route',
510
+ fromRole,
511
+ to,
512
+ taskId: taskId || null,
513
+ timestamp: Date.now(),
514
+ });
515
+ } catch (e) {
516
+ console.warn('[Crew] Failed to emit routing-metrics:', e.message);
517
+ }
518
+ try {
519
+ sendCrewMessage({
520
+ type: 'crew_route_rejected',
521
+ sessionId: session.id,
522
+ fromRole,
523
+ to,
524
+ reason: 'self-route',
525
+ message: `自路由被拒绝:${fromRole} 不能给自己发消息。请改用 task_close(taskId, summary) 关闭任务,或 role_standby(role) 进入待命,或选择其他角色作为 ROUTE 目标。`,
526
+ taskId: taskId || null,
527
+ });
528
+ } catch (e) {
529
+ console.warn('[Crew] Failed to emit crew_route_rejected:', e.message);
530
+ }
531
+ // Do NOT decrement session.round — role-output.js already incremented
532
+ // it for this whole turn batch; one rejected route doesn't undo the
533
+ // turn (other routes in the same batch may still be valid).
534
+ return;
535
+ }
536
+ }
537
+
538
+ // task-330b §B item 1: state-stopped metric — message arrived while
539
+ // session was paused/stopped. Behaviour (auto-resume) is unchanged for
540
+ // backward compat; this is observer-only.
541
+ if (session.status === 'paused' || session.status === 'stopped') {
542
+ recordRoutingEvent(session, 'state-stopped', {
543
+ fromRole,
544
+ toRole: to,
545
+ taskId: taskId || null,
546
+ note: `session.status=${session.status} at executeRoute entry`,
547
+ });
548
+ }
549
+
419
550
  // Auto-resume: paused/stopped → running (route execution means work should continue)
420
551
  if (session.status === 'paused' || session.status === 'stopped') {
421
552
  console.log(`[Crew] Auto-resuming session from ${session.status} to running (route from ${fromRole} to ${to})`);
@@ -620,11 +751,21 @@ export async function dispatchToRole(session, roleName, content, fromSource, tas
620
751
  }
621
752
 
622
753
  // 最近路由消息注入(帮助 clear 后的角色恢复上下文)
754
+ // task-330c: each entry is smart-truncated to 400 chars at a sentence
755
+ // boundary (period/newline) so we don't slice key info mid-sentence.
756
+ // The full content lives in the feature file — the marker tells the
757
+ // role where to look if they need more context. The pre-stored
758
+ // `m.content` was already truncated to 200 (history step below) until
759
+ // task-330c bumped it to 400 + smart boundary.
760
+ // ⚠️ DO NOT pass `m.content` through any greedy `.replace(/.../g, '')`
761
+ // here — it has already been derived from displayBody at message
762
+ // time (parser-stripped), and a second strip would re-process
763
+ // text that no longer holds ROUTE markers. See _appendHistory below.
623
764
  if (session.messageHistory.length > 0) {
624
765
  const recentRoutes = session.messageHistory
625
766
  .filter(m => m.from !== 'system')
626
767
  .slice(-5)
627
- .map(m => `[${m.from} → ${m.to}${m.taskId ? ` (${m.taskId})` : ''}] ${m.content}`)
768
+ .map(m => `[${m.from} → ${m.to}${m.taskId ? ` (${m.taskId})` : ''}] ${smartTruncate(m.content, 400)}`)
628
769
  .join('\n');
629
770
  if (recentRoutes) {
630
771
  const ctx = `\n\n---\n<recent-routes>\n${recentRoutes}\n</recent-routes>`;
@@ -633,9 +774,15 @@ export async function dispatchToRole(session, roleName, content, fromSource, tas
633
774
  }
634
775
 
635
776
  // 记录消息历史
777
+ // task-330c: cap raised 200 → 400 to match the recent-routes injection
778
+ // window. Keeping the pre-store cap at 200 would pin every entry below
779
+ // smartTruncate's 400 threshold, making the smart-truncate boundary cut
780
+ // a permanent no-op in production. 400 here lets longer messages flow
781
+ // into history; smartTruncate trims them at sentence boundaries when
782
+ // injected into <recent-routes>.
636
783
  const historyContent = typeof content === 'string'
637
- ? content.substring(0, 200)
638
- : (Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('').substring(0, 200) + (content.some(b => b.type === 'image') ? ' [+images]' : '') : '...');
784
+ ? content.substring(0, 400)
785
+ : (Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('').substring(0, 400) + (content.some(b => b.type === 'image') ? ' [+images]' : '') : '...');
639
786
  session.messageHistory.push({
640
787
  from: fromSource,
641
788
  to: roleName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.510",
3
+ "version": "0.1.512",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -43,6 +43,47 @@ import { normalizeEffort } from './models.js';
43
43
  /** Maximum auto-continue turns when stopReason is 'max_tokens'. */
44
44
  const MAX_CONTINUE_TURNS = 3;
45
45
 
46
+ /**
47
+ * task-331 — Map a conversationMessages entry into the snapshot shape used
48
+ * by `debug_turn.messages`. Preserves the function-calling metadata that
49
+ * the Debug panel needs to render:
50
+ * - `toolCalls` on assistant turns (the LLM's function_call requests)
51
+ * - `toolCallId` + `isError` on tool turns (the paired tool_result)
52
+ *
53
+ * Content is truncated at 50000 chars; each tool_call input is JSON-stringified
54
+ * + sliced at 10000 chars before being re-parsed, so a runaway `input` blob
55
+ * can't blow past the WebSocket frame budget. Unknown roles pass through
56
+ * unchanged.
57
+ *
58
+ * Pure function — no side effects on the input message.
59
+ *
60
+ * @param {{ role: string, content?: any, toolCalls?: Array, toolCallId?: string, isError?: boolean }} m
61
+ * @returns {{ role: string, content: any, toolCalls?: Array, toolCallId?: string, isError?: boolean }}
62
+ */
63
+ export function mapDebugMessage(m) {
64
+ const out = { role: m.role };
65
+ out.content = typeof m.content === 'string' ? m.content.slice(0, 50000) : m.content;
66
+ if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
67
+ out.toolCalls = m.toolCalls.map(tc => {
68
+ let input = tc.input;
69
+ try {
70
+ const s = JSON.stringify(input);
71
+ if (typeof s === 'string' && s.length > 10000) {
72
+ input = { __truncated: true, preview: s.slice(0, 10000) };
73
+ }
74
+ } catch {
75
+ // Non-serializable input — fall through with raw reference; the
76
+ // frontend's JSON.stringify will hit the same failure and replace
77
+ // it with a placeholder string.
78
+ }
79
+ return { id: tc.id, name: tc.name, input };
80
+ });
81
+ }
82
+ if (m.toolCallId) out.toolCallId = m.toolCallId;
83
+ if (m.isError != null) out.isError = m.isError;
84
+ return out;
85
+ }
86
+
46
87
  // ─── Engine Events (superset of adapter events) ──────────────────
47
88
 
48
89
  /**
@@ -289,6 +330,13 @@ export class Engine {
289
330
  conversationStore: this.#conversationStore,
290
331
  adapter: this.#adapter,
291
332
  config: this.#config,
333
+ // ViewImage (task-333b PR-B rev-3 P1-A): expose size cap + allowlist
334
+ // via tool ctx so hosts can override via ~/.yeaft/config.json without
335
+ // touching the tool impl.
336
+ maxImageBytes: this.#config?.unify?.maxImageBytes,
337
+ imageAllowlist: Array.isArray(this.#config?.unify?.imageAllowlist)
338
+ ? this.#config.unify.imageAllowlist
339
+ : [],
292
340
  };
293
341
  }
294
342
 
@@ -508,8 +556,8 @@ export class Engine {
508
556
 
509
557
  // ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
510
558
  // New layout: always inject Memory Index + user-preferences + project
511
- // header excerpt. No per-turn fuzzy recall — LLM calls memory_search /
512
- // memory_query on demand.
559
+ // header excerpt. No per-turn fuzzy recall — LLM calls memory_load /
560
+ // memory_query on demand (memory_search still works as a deprecated alias).
513
561
  let memoryInjection = '';
514
562
  if (this.#yeaftDir) {
515
563
  try {
@@ -634,7 +682,7 @@ export class Engine {
634
682
  turnNumber,
635
683
  model: currentModel,
636
684
  systemPrompt,
637
- messages: conversationMessages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 50000) : m.content })),
685
+ messages: conversationMessages.map(mapDebugMessage),
638
686
  response: responseText || `Error: ${err.message}`,
639
687
  toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
640
688
  usage: { inputTokens: totalUsage.inputTokens, outputTokens: totalUsage.outputTokens },
@@ -702,12 +750,15 @@ export class Engine {
702
750
 
703
751
  // Emit debug_turn event for web UI debug panel
704
752
  // (conversationMessages does NOT yet include the assistant response at this point)
753
+ // task-331: preserve toolCalls / toolCallId / isError on each message so
754
+ // the Debug panel can render function_call requests and their paired
755
+ // tool_result responses across turns.
705
756
  yield {
706
757
  type: 'debug_turn',
707
758
  turnNumber,
708
759
  model: currentModel,
709
760
  systemPrompt,
710
- messages: conversationMessages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 50000) : m.content })),
761
+ messages: conversationMessages.map(mapDebugMessage),
711
762
  response: responseText,
712
763
  toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
713
764
  usage: { inputTokens: totalUsage.inputTokens, outputTokens: totalUsage.outputTokens },
@@ -265,7 +265,7 @@ export function renderIndex(yeaftDir, entryCount) {
265
265
  lines.push('## entries', '', `- ${entryCount} atomic entries (use memory_query to search)`, '');
266
266
 
267
267
  lines.push(
268
- '_Note: use the `memory_search` tool with one or more paths to load a classification',
268
+ '_Note: use the `memory_load` tool with one or more paths to load a classification',
269
269
  'file in full, or `memory_query` to search atomic entries by keywords/tags._',
270
270
  );
271
271
 
package/unify/prompts.js CHANGED
@@ -32,18 +32,53 @@ const TEMPLATES_DIR = join(__dirname, 'templates');
32
32
 
33
33
  /**
34
34
  * Read a template file from the templates/ directory.
35
- * Returns empty string if file doesn't exist or can't be read.
35
+ *
36
+ * task-332c F3 — missing-template guard:
37
+ * Required templates MUST be present. If a required template is missing or
38
+ * unreadable, throw a clear error instead of silently degrading to the
39
+ * hardcoded fallback. Silent skip previously hid misconfigured deployments
40
+ * (empty prompts shipped to production), so we now fail fast at load time.
41
+ *
42
+ * Non-required templates (passed with { required: false }) retain the old
43
+ * "return empty string on absence" behavior for optional inclusions.
44
+ *
36
45
  * @param {string} name — filename (e.g. 'base.md')
46
+ * @param {{ required?: boolean }} [opts]
37
47
  * @returns {string}
48
+ * @throws {Error} when required=true and the file is missing / unreadable / empty
38
49
  */
39
- function readTemplate(name) {
50
+ function readTemplate(name, { required = true } = {}) {
40
51
  const path = join(TEMPLATES_DIR, name);
41
- if (!existsSync(path)) return '';
52
+ if (!existsSync(path)) {
53
+ if (required) {
54
+ throw new Error(
55
+ `[prompts] Required template missing: ${name} ` +
56
+ `(expected at ${path}). Templates are part of the agent package — ` +
57
+ `check the install or build output.`
58
+ );
59
+ }
60
+ return '';
61
+ }
62
+ let content;
42
63
  try {
43
- return readFileSync(path, 'utf8').trim();
44
- } catch {
64
+ content = readFileSync(path, 'utf8');
65
+ } catch (e) {
66
+ if (required) {
67
+ throw new Error(
68
+ `[prompts] Required template unreadable: ${name} ` +
69
+ `(at ${path}): ${e.message}`
70
+ );
71
+ }
45
72
  return '';
46
73
  }
74
+ const trimmed = content.trim();
75
+ if (!trimmed && required) {
76
+ throw new Error(
77
+ `[prompts] Required template is empty: ${name} (at ${path}). ` +
78
+ `An empty system prompt template would ship a degenerate prompt to the LLM.`
79
+ );
80
+ }
81
+ return trimmed;
47
82
  }
48
83
 
49
84
  /**
@@ -20,7 +20,7 @@ import exitWorktree from './exit-worktree.js';
20
20
  import askUser from './ask-user.js';
21
21
  import memoryRead from './memory-read.js';
22
22
  import memoryWrite from './memory-write.js';
23
- import memorySearch from './memory-search.js';
23
+ import memorySearch, { memorySearchAlias } from './memory-search.js';
24
24
  import memoryQuery from './memory-query.js';
25
25
  import webSearch from './web-search.js';
26
26
  import webFetch from './web-fetch.js';
@@ -67,13 +67,14 @@ import {
67
67
  } from './thread-tools.js';
68
68
 
69
69
  // --- P2 Auxiliary tools ---
70
+ // task-333b L1 delete: ToolSearch and WriteStdin removed — the function-call
71
+ // schema already exposes all tools, so ToolSearch was redundant; WriteStdin
72
+ // was a stub returning a hint about Bash piping.
70
73
  import { jsRepl, jsReplReset } from './js-repl.js';
71
74
  import notebookEdit from './notebook-edit.js';
72
75
  import imageGeneration from './image-generation.js';
73
76
  import viewImage from './view-image.js';
74
- import toolSearch from './tool-search.js';
75
77
  import requestPermissions from './request-permissions.js';
76
- import writeStdin from './write-stdin.js';
77
78
 
78
79
  /**
79
80
  * All built-in tools, flattened into a single array.
@@ -92,6 +93,7 @@ export const allTools = [
92
93
  memoryRead,
93
94
  memoryWrite,
94
95
  memorySearch,
96
+ memorySearchAlias,
95
97
  memoryQuery,
96
98
  webSearch,
97
99
  webFetch,
@@ -139,9 +141,7 @@ export const allTools = [
139
141
  notebookEdit,
140
142
  imageGeneration,
141
143
  viewImage,
142
- toolSearch,
143
144
  requestPermissions,
144
- writeStdin,
145
145
  ];
146
146
 
147
147
  /**