@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.
- package/connection/message-router.js +11 -0
- package/crew/builtin-actions.js +154 -0
- package/crew/role-output.js +64 -11
- package/crew/role-states.js +180 -0
- package/crew/routing-fallback.js +64 -0
- package/crew/routing-metrics.js +215 -0
- package/crew/routing.js +150 -3
- package/package.json +1 -1
- package/unify/engine.js +55 -4
- package/unify/memory/layout.js +1 -1
- package/unify/prompts.js +40 -5
- package/unify/tools/index.js +5 -5
- package/unify/tools/js-repl.js +42 -15
- package/unify/tools/memory-query.js +3 -1
- package/unify/tools/memory-search.js +102 -63
- package/unify/tools/task-tools.js +22 -5
- package/unify/tools/thread-tools.js +16 -11
- package/unify/tools/view-image.js +249 -50
- package/unify/web-bridge.js +9 -0
- package/unify/tools/tool-search.js +0 -88
- package/unify/tools/write-stdin.js +0 -53
|
@@ -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,
|
|
638
|
-
: (Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('').substring(0,
|
|
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
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
|
|
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(
|
|
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(
|
|
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 },
|
package/unify/memory/layout.js
CHANGED
|
@@ -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 `
|
|
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
|
-
*
|
|
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))
|
|
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
|
-
|
|
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
|
/**
|
package/unify/tools/index.js
CHANGED
|
@@ -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
|
/**
|