@yeaft/webchat-agent 0.1.720 → 0.1.722
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/unify/attachments.js +224 -0
- package/unify/engine.js +29 -5
- package/unify/groups/coordinator.js +38 -5
- package/unify/groups/group-store.js +12 -0
- package/unify/llm/router.js +67 -4
- package/unify/web-bridge.js +67 -4
package/package.json
CHANGED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* attachments.js — Unify (group/feature) attachment handling.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the Chat-mode pipeline implemented in `agent/workbench/transfer.js`,
|
|
5
|
+
* adapted to the Unify architecture:
|
|
6
|
+
*
|
|
7
|
+
* - Chat mode has a per-conversation `state.workDir` and a single
|
|
8
|
+
* in-flight Claude SDK query — `transfer.js` enqueues a constructed
|
|
9
|
+
* user message into that query's `inputStream`.
|
|
10
|
+
* - Unify mode has many VPs taking turns inside a group, no per-VP
|
|
11
|
+
* workDir, and the Engine accepts the user message via `query()`
|
|
12
|
+
* args. So we (a) save attachments to a shared per-group folder
|
|
13
|
+
* under the agent's CWD (so file-tools using `ctx.cwd` can read
|
|
14
|
+
* them with relative paths) and (b) hand back the persisted-form
|
|
15
|
+
* metadata AND a `promptParts` content array (image blocks +
|
|
16
|
+
* synthesized [Uploaded files] suffix) for the LLM call.
|
|
17
|
+
*
|
|
18
|
+
* Inputs (`files`) come from the server-side resolver in
|
|
19
|
+
* `client-conversation.js` / `client-crew.js`: each entry is
|
|
20
|
+
* `{ name, mimeType, data: <base64>, isImage }` — the `pendingFiles`
|
|
21
|
+
* `fileId` was already consumed by the server before `forwardToAgent`.
|
|
22
|
+
*
|
|
23
|
+
* Output (single bundle, all named for the role each piece plays in
|
|
24
|
+
* the LLM call):
|
|
25
|
+
* - `promptAttachments`: persisted metadata `{ name, path, mimeType,
|
|
26
|
+
* isImage }` suitable for the group jsonl-log (NO base64 — must
|
|
27
|
+
* stay small).
|
|
28
|
+
* - `promptSuffix`: text to append to the user's prompt so the model
|
|
29
|
+
* sees the file list in the same form Chat mode uses.
|
|
30
|
+
* - `promptParts`: an array of `{ type:'image', source:{ data, mediaType } }`
|
|
31
|
+
* blocks for images, ready to be combined with a text block for
|
|
32
|
+
* `engine.query({ promptParts })`. Empty when no images are present.
|
|
33
|
+
* - `failed`: list of `{ name, error }` for entries that could not
|
|
34
|
+
* be persisted (disk full, bad base64, ...). The caller surfaces
|
|
35
|
+
* this so the UI can tell the user *which* file blew up rather
|
|
36
|
+
* than swallowing it in a console.warn.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
40
|
+
import { basename, extname, join } from 'node:path';
|
|
41
|
+
import { randomBytes } from 'node:crypto';
|
|
42
|
+
|
|
43
|
+
// Same dir name Chat mode uses, so ".gitignore" rules and tool-side
|
|
44
|
+
// expectations stay identical.
|
|
45
|
+
const TEMP_UPLOAD_DIR = '.claude-tmp-attachments';
|
|
46
|
+
|
|
47
|
+
// Caps. Any ingestion path without caps is a denial-of-service waiting
|
|
48
|
+
// to be discovered. Cheap insurance.
|
|
49
|
+
// - MAX_FILES_PER_TURN: matches the UI's per-message attachment cap.
|
|
50
|
+
// - MAX_TOTAL_BYTES: 50 MiB across all files in one turn.
|
|
51
|
+
export const MAX_FILES_PER_TURN = 16;
|
|
52
|
+
export const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sanitize a user-supplied filename's basename for use as an on-disk
|
|
56
|
+
* path component. We KEEP Unicode (CJK, emoji, accented letters) —
|
|
57
|
+
* the disk path is opaque, the UI uses `promptAttachments[].name` for
|
|
58
|
+
* display, and tool consumers (file-read, bash) handle UTF-8 paths
|
|
59
|
+
* fine on Linux/macOS. We only strip what is structurally dangerous:
|
|
60
|
+
* - path separators (`/`, `\`)
|
|
61
|
+
* - NUL bytes
|
|
62
|
+
* - leading dots (so a user can't write `.bashrc` into the temp dir)
|
|
63
|
+
* - leading `-` (so the path can't be mistaken for a CLI flag)
|
|
64
|
+
* - control characters
|
|
65
|
+
*/
|
|
66
|
+
function sanitizeBaseName(base) {
|
|
67
|
+
let s = String(base ?? '')
|
|
68
|
+
.replace(/[\/\\\0]/g, '_')
|
|
69
|
+
// Strip C0 controls (\u0000–\u001F) and DEL (\u007F).
|
|
70
|
+
.replace(/[\u0000-\u001f\u007f]/g, '_')
|
|
71
|
+
// Trim runs of leading dots/dashes that would create dotfiles or
|
|
72
|
+
// CLI-flag-looking paths.
|
|
73
|
+
.replace(/^[.\-]+/, '');
|
|
74
|
+
if (!s) s = 'file';
|
|
75
|
+
// Hard cap on length — most filesystems are fine with 255 bytes per
|
|
76
|
+
// name, and the random suffix + extension still need to fit.
|
|
77
|
+
if (s.length > 80) s = s.slice(0, 80);
|
|
78
|
+
return s;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Persist resolved files to disk and build the LLM-side payload pieces.
|
|
83
|
+
*
|
|
84
|
+
* @param {Array<{name:string, mimeType:string, data:string, isImage?:boolean}>} files
|
|
85
|
+
* Resolved files from server (pendingFiles → base64).
|
|
86
|
+
* @param {Object} [opts]
|
|
87
|
+
* @param {string} [opts.subdir] Sub-folder under TEMP_UPLOAD_DIR
|
|
88
|
+
* (e.g. groupId). Lets multiple groups co-exist without clobbering.
|
|
89
|
+
* @param {string} [opts.cwd] Override base dir; defaults to process.cwd()
|
|
90
|
+
* which is what unify tools (file-read, bash, ...) resolve relative
|
|
91
|
+
* paths against.
|
|
92
|
+
* @returns {{
|
|
93
|
+
* promptAttachments: Array<{name:string, path:string, mimeType:string, isImage:boolean}>,
|
|
94
|
+
* promptSuffix: string,
|
|
95
|
+
* promptParts: Array<{type:'image', source:{type:'base64', mediaType:string, data:string}}>,
|
|
96
|
+
* failed: Array<{name:string, error:string}>
|
|
97
|
+
* }}
|
|
98
|
+
*/
|
|
99
|
+
export function persistUnifyAttachments(files, opts = {}) {
|
|
100
|
+
const cwd = opts.cwd || process.cwd();
|
|
101
|
+
// Subdir is OURS — it must remain ASCII-safe because we generate it
|
|
102
|
+
// from groupId. Keep the existing strict policy here; this is NOT
|
|
103
|
+
// user-visible.
|
|
104
|
+
const subdir = opts.subdir ? String(opts.subdir).replace(/[^a-zA-Z0-9._-]/g, '_') : '';
|
|
105
|
+
const uploadDir = subdir
|
|
106
|
+
? join(cwd, TEMP_UPLOAD_DIR, subdir)
|
|
107
|
+
: join(cwd, TEMP_UPLOAD_DIR);
|
|
108
|
+
|
|
109
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
110
|
+
return { promptAttachments: [], promptSuffix: '', promptParts: [], failed: [] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Enforce per-turn file count cap. Excess entries are surfaced as
|
|
114
|
+
// failures so the UI can tell the user what got dropped.
|
|
115
|
+
const accepted = files.slice(0, MAX_FILES_PER_TURN);
|
|
116
|
+
const rejectedByCount = files.slice(MAX_FILES_PER_TURN);
|
|
117
|
+
|
|
118
|
+
if (!existsSync(uploadDir)) {
|
|
119
|
+
mkdirSync(uploadDir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const promptAttachments = [];
|
|
123
|
+
const promptParts = [];
|
|
124
|
+
const failed = rejectedByCount.map((f) => ({
|
|
125
|
+
name: f?.name || '<unknown>',
|
|
126
|
+
error: `too many files (cap=${MAX_FILES_PER_TURN})`,
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
let totalBytes = 0;
|
|
130
|
+
|
|
131
|
+
for (const file of accepted) {
|
|
132
|
+
if (!file || !file.name || !file.data) {
|
|
133
|
+
// Silently skip null/empty entries — these are caller bugs, not
|
|
134
|
+
// user errors, and the existing test suite asserts they don't
|
|
135
|
+
// appear in `failed`. (Backwards compatible.)
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const ext = extname(file.name);
|
|
140
|
+
const base = basename(file.name, ext);
|
|
141
|
+
const safeBase = sanitizeBaseName(base);
|
|
142
|
+
// Identity comes from random bytes — a clock is not an identity.
|
|
143
|
+
// 4 bytes (2^32) is plenty for a 16-file cap.
|
|
144
|
+
const suffix = randomBytes(4).toString('hex');
|
|
145
|
+
const uniqueName = `${safeBase}_${suffix}${ext || ''}`;
|
|
146
|
+
const absPath = join(uploadDir, uniqueName);
|
|
147
|
+
const relPath = subdir
|
|
148
|
+
? join(TEMP_UPLOAD_DIR, subdir, uniqueName)
|
|
149
|
+
: join(TEMP_UPLOAD_DIR, uniqueName);
|
|
150
|
+
|
|
151
|
+
const buffer = Buffer.from(file.data, 'base64');
|
|
152
|
+
|
|
153
|
+
// Total-bytes cap. Check BEFORE write so we don't half-fill the
|
|
154
|
+
// disk and then bail.
|
|
155
|
+
if (totalBytes + buffer.length > MAX_TOTAL_BYTES) {
|
|
156
|
+
failed.push({
|
|
157
|
+
name: file.name,
|
|
158
|
+
error: `total upload exceeds ${MAX_TOTAL_BYTES} bytes`,
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
totalBytes += buffer.length;
|
|
163
|
+
|
|
164
|
+
writeFileSync(absPath, buffer);
|
|
165
|
+
|
|
166
|
+
const isImage = !!file.isImage || (file.mimeType || '').startsWith('image/');
|
|
167
|
+
promptAttachments.push({
|
|
168
|
+
name: file.name,
|
|
169
|
+
path: relPath,
|
|
170
|
+
mimeType: file.mimeType || 'application/octet-stream',
|
|
171
|
+
isImage,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (isImage) {
|
|
175
|
+
promptParts.push({
|
|
176
|
+
type: 'image',
|
|
177
|
+
source: {
|
|
178
|
+
type: 'base64',
|
|
179
|
+
// Both adapters accept either `mediaType` or `media_type`
|
|
180
|
+
// (see openai-responses.js:#translateUserContent and the
|
|
181
|
+
// Anthropic upstream contract). Use the camelCase form to
|
|
182
|
+
// match `openai-responses.js` exactly.
|
|
183
|
+
mediaType: file.mimeType || 'image/png',
|
|
184
|
+
data: file.data,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
failed.push({
|
|
190
|
+
name: file?.name || '<unknown>',
|
|
191
|
+
error: err?.message || String(err),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let promptSuffix = '';
|
|
197
|
+
if (promptAttachments.length > 0) {
|
|
198
|
+
const lines = promptAttachments.map((f) =>
|
|
199
|
+
`- ${f.path} (${f.isImage ? 'image' : f.mimeType})`
|
|
200
|
+
);
|
|
201
|
+
promptSuffix = `\n\n[Uploaded files]\n${lines.join('\n')}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { promptAttachments, promptSuffix, promptParts, failed };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Strip base64 data from a resolved-files array so it can be safely
|
|
209
|
+
* persisted (e.g. into a group jsonl-log or memory entry). Keeps name,
|
|
210
|
+
* mimeType, isImage, and the on-disk path returned by
|
|
211
|
+
* `persistUnifyAttachments`.
|
|
212
|
+
*
|
|
213
|
+
* @param {Array<{name:string, path:string, mimeType:string, isImage:boolean}>} promptAttachments
|
|
214
|
+
* @returns {Array<{name:string, path:string, mimeType:string, isImage:boolean}>}
|
|
215
|
+
*/
|
|
216
|
+
export function attachmentsForPersistence(promptAttachments) {
|
|
217
|
+
if (!Array.isArray(promptAttachments)) return [];
|
|
218
|
+
return promptAttachments.map((f) => ({
|
|
219
|
+
name: f.name,
|
|
220
|
+
path: f.path,
|
|
221
|
+
mimeType: f.mimeType,
|
|
222
|
+
isImage: !!f.isImage,
|
|
223
|
+
}));
|
|
224
|
+
}
|
package/unify/engine.js
CHANGED
|
@@ -991,9 +991,19 @@ export class Engine {
|
|
|
991
991
|
* @param {string} [params.scenario='chat'] - task-327b: scenario tag
|
|
992
992
|
* forwarded to the effort decision tree. See effort.js
|
|
993
993
|
* SCENARIO_EFFORT. Unknown values fall through to 'high'.
|
|
994
|
+
* @param {Array<{type:string, source?:object, text?:string}>} [params.promptParts] -
|
|
995
|
+
* PR #721: optional content-array form of the user message used
|
|
996
|
+
* when attachments are present. Each entry is either an
|
|
997
|
+
* `{type:'image', source:{type:'base64', mediaType, data}}` block
|
|
998
|
+
* (one per uploaded image) or a `{type:'text', text}` block (the
|
|
999
|
+
* text prompt body, including any [Uploaded files] suffix). When
|
|
1000
|
+
* supplied and non-empty, the LLM call uses this array as the
|
|
1001
|
+
* user-message content; the string `prompt` is then only used for
|
|
1002
|
+
* logging / history. When omitted the engine falls back to the
|
|
1003
|
+
* string-prompt shape (no regression for existing callers).
|
|
994
1004
|
* @yields {EngineEvent}
|
|
995
1005
|
*/
|
|
996
|
-
async *query({ prompt, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement } = {}) {
|
|
1006
|
+
async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement } = {}) {
|
|
997
1007
|
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
998
1008
|
yield {
|
|
999
1009
|
type: 'error',
|
|
@@ -1002,6 +1012,14 @@ export class Engine {
|
|
|
1002
1012
|
};
|
|
1003
1013
|
return;
|
|
1004
1014
|
}
|
|
1015
|
+
// promptParts (optional): a content-array form of the user message
|
|
1016
|
+
// (e.g. [{type:'image',source:{...}}, {type:'text',text:'@vp-x ...'}]).
|
|
1017
|
+
// When supplied, it REPLACES the trailing `{role:'user',content:prompt}`
|
|
1018
|
+
// entry built into conversationMessages — the string `prompt` is still
|
|
1019
|
+
// used for memory recall, system prompt rendering, and turn previews
|
|
1020
|
+
// because those layers all need plain text. Adapter side already
|
|
1021
|
+
// accepts content arrays for user messages (anthropic.js:72,
|
|
1022
|
+
// openai-responses.js:#translateUserContent).
|
|
1005
1023
|
|
|
1006
1024
|
// task-327b: `/max` / `/high` / `/medium` / `/low` prefix override.
|
|
1007
1025
|
// Explicit caller-supplied userEffort wins over the prefix.
|
|
@@ -1044,7 +1062,7 @@ export class Engine {
|
|
|
1044
1062
|
const runSignal = abortCtrl.signal;
|
|
1045
1063
|
|
|
1046
1064
|
try {
|
|
1047
|
-
yield* this.#runQuery({ prompt: effectivePrompt, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement });
|
|
1065
|
+
yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement });
|
|
1048
1066
|
} finally {
|
|
1049
1067
|
if (signal) {
|
|
1050
1068
|
try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
|
|
@@ -1062,7 +1080,7 @@ export class Engine {
|
|
|
1062
1080
|
* in a try/finally without indenting the whole loop.
|
|
1063
1081
|
* @private
|
|
1064
1082
|
*/
|
|
1065
|
-
async *#runQuery({ prompt, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement }) {
|
|
1083
|
+
async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement }) {
|
|
1066
1084
|
|
|
1067
1085
|
// ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
|
|
1068
1086
|
// Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
|
|
@@ -1189,11 +1207,17 @@ export class Engine {
|
|
|
1189
1207
|
]
|
|
1190
1208
|
: [];
|
|
1191
1209
|
|
|
1192
|
-
// Build conversation: optional compact head + existing messages + new user message
|
|
1210
|
+
// Build conversation: optional compact head + existing messages + new user message.
|
|
1211
|
+
// If `promptParts` was supplied (image/file attachments), use the array form
|
|
1212
|
+
// so the adapter sees image content blocks alongside the text. Otherwise the
|
|
1213
|
+
// legacy string form keeps prompt-cache behavior identical.
|
|
1214
|
+
const finalUserContent = (Array.isArray(promptParts) && promptParts.length > 0)
|
|
1215
|
+
? promptParts
|
|
1216
|
+
: prompt;
|
|
1193
1217
|
const conversationMessages = [
|
|
1194
1218
|
...compactMessages,
|
|
1195
1219
|
...messages,
|
|
1196
|
-
{ role: 'user', content:
|
|
1220
|
+
{ role: 'user', content: finalUserContent },
|
|
1197
1221
|
];
|
|
1198
1222
|
|
|
1199
1223
|
// PR-L: T2 carry-forward. If a previous query()'s end-of-turn
|
|
@@ -68,8 +68,37 @@ export function createCoordinator(group, options = {}) {
|
|
|
68
68
|
const mentions = parseMentions(input.text);
|
|
69
69
|
|
|
70
70
|
// Persist first — audit log / replay works even if dispatch has bugs.
|
|
71
|
+
//
|
|
72
|
+
// Convention: any field on `input` that starts with `_` is treated
|
|
73
|
+
// as ephemeral and is forwarded to the envelope (so per-turn driver
|
|
74
|
+
// payloads — image base64 blocks, prompt suffixes — reach the LLM
|
|
75
|
+
// call) but is NEVER passed to appendMessage. The jsonl-log must
|
|
76
|
+
// stay lean: base64 in audit history would blow up replay.
|
|
77
|
+
//
|
|
78
|
+
// The split is enforced structurally — see the assertion below the
|
|
79
|
+
// partition loop. Don't loosen it. If a new ephemeral key is added,
|
|
80
|
+
// it gets the `_` prefix at its source and inherits the protection
|
|
81
|
+
// for free; no allowlist to maintain.
|
|
82
|
+
const persistInput = {};
|
|
83
|
+
const ephemeral = {};
|
|
84
|
+
for (const [k, v] of Object.entries(input)) {
|
|
85
|
+
if (typeof k === 'string' && k.startsWith('_')) {
|
|
86
|
+
ephemeral[k] = v;
|
|
87
|
+
} else {
|
|
88
|
+
persistInput[k] = v;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Structural guarantee: nothing with a `_` prefix may reach the
|
|
92
|
+
// jsonl-log via `persistInput`. If this ever throws, the `_` rule
|
|
93
|
+
// got bypassed — fix the caller, not this assertion.
|
|
94
|
+
{
|
|
95
|
+
const leaked = Object.keys(persistInput).filter((k) => typeof k === 'string' && k.startsWith('_'));
|
|
96
|
+
if (leaked.length > 0) {
|
|
97
|
+
throw new Error(`coordinator.ingest: ephemeral fields leaked into persisted record: ${leaked.join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
71
100
|
const stored = group.appendMessage({
|
|
72
|
-
...
|
|
101
|
+
...persistInput,
|
|
73
102
|
mentions,
|
|
74
103
|
role: input.role || (fromUser ? 'user' : 'assistant'),
|
|
75
104
|
});
|
|
@@ -96,7 +125,7 @@ export function createCoordinator(group, options = {}) {
|
|
|
96
125
|
}
|
|
97
126
|
|
|
98
127
|
if (selection.reason === 'broadcast') {
|
|
99
|
-
const envelope = makeEnvelope(stored, meta, 'broadcast');
|
|
128
|
+
const envelope = makeEnvelope(stored, meta, 'broadcast', ephemeral);
|
|
100
129
|
for (const vpId of selection.dispatched) deliver(vpId, envelope);
|
|
101
130
|
return {
|
|
102
131
|
message: stored,
|
|
@@ -110,7 +139,7 @@ export function createCoordinator(group, options = {}) {
|
|
|
110
139
|
|
|
111
140
|
if (selection.reason === 'mention') {
|
|
112
141
|
for (const vpId of selection.dispatched) {
|
|
113
|
-
deliver(vpId, makeEnvelope(stored, meta, 'mention'));
|
|
142
|
+
deliver(vpId, makeEnvelope(stored, meta, 'mention', ephemeral));
|
|
114
143
|
}
|
|
115
144
|
return {
|
|
116
145
|
message: stored,
|
|
@@ -121,7 +150,7 @@ export function createCoordinator(group, options = {}) {
|
|
|
121
150
|
}
|
|
122
151
|
|
|
123
152
|
if (selection.reason === 'fallback' && selection.fallback) {
|
|
124
|
-
deliver(selection.fallback, makeEnvelope(stored, meta, 'fallback'));
|
|
153
|
+
deliver(selection.fallback, makeEnvelope(stored, meta, 'fallback', ephemeral));
|
|
125
154
|
return {
|
|
126
155
|
message: stored,
|
|
127
156
|
dispatched: selection.dispatched,
|
|
@@ -146,12 +175,16 @@ export function createCoordinator(group, options = {}) {
|
|
|
146
175
|
};
|
|
147
176
|
}
|
|
148
177
|
|
|
149
|
-
function makeEnvelope(msg, meta, trigger) {
|
|
178
|
+
function makeEnvelope(msg, meta, trigger, ephemeral = {}) {
|
|
150
179
|
return {
|
|
151
180
|
groupId: meta.id,
|
|
152
181
|
taskId: msg.taskId || null,
|
|
153
182
|
msg,
|
|
154
183
|
trigger, // 'broadcast' | 'mention' | 'fallback'
|
|
184
|
+
// Ephemeral fields (any `_`-prefixed key on coord.ingest input).
|
|
185
|
+
// Used to ferry per-turn payloads (e.g. image base64 blocks) that
|
|
186
|
+
// must reach the driver but must NOT be persisted to the group log.
|
|
187
|
+
...ephemeral,
|
|
155
188
|
};
|
|
156
189
|
}
|
|
157
190
|
|
|
@@ -69,11 +69,23 @@ export function openGroup(groupsRoot, groupId) {
|
|
|
69
69
|
/**
|
|
70
70
|
* Append a message to the group log. Assigns an id if absent.
|
|
71
71
|
* Returns the stored record (with id + ts).
|
|
72
|
+
*
|
|
73
|
+
* Structural invariant: NO field on `record` may start with `_`.
|
|
74
|
+
* The `_` prefix is reserved for ephemeral per-turn payloads (image
|
|
75
|
+
* base64, prompt suffixes) that must reach the driver but must
|
|
76
|
+
* never hit the persisted jsonl-log. If this throws, the caller
|
|
77
|
+
* forgot to partition ephemeral fields off — fix the caller.
|
|
72
78
|
*/
|
|
73
79
|
appendMessage(record) {
|
|
74
80
|
if (!record || typeof record !== 'object') {
|
|
75
81
|
throw new Error('appendMessage: record required');
|
|
76
82
|
}
|
|
83
|
+
{
|
|
84
|
+
const leaked = Object.keys(record).filter((k) => typeof k === 'string' && k.startsWith('_'));
|
|
85
|
+
if (leaked.length > 0) {
|
|
86
|
+
throw new Error(`appendMessage: ephemeral fields leaked into log: ${leaked.join(', ')}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
77
89
|
const stored = {
|
|
78
90
|
id: record.id || nextMsgId(),
|
|
79
91
|
ts: record.ts || new Date().toISOString(),
|
package/unify/llm/router.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { LLMAdapter } from './adapter.js';
|
|
19
19
|
import { getThinkingCapability, normalizeEffort } from '../models.js';
|
|
20
|
+
import { pairSanitize } from '../pair-sanitize.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* task-327a: feature-flag accessor. Read lazily so tests can flip.
|
|
@@ -58,6 +59,66 @@ export function filterEffortForModel(params) {
|
|
|
58
59
|
return { ...params, effort: norm };
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* task-715: last-line-of-defense pair sanitize at the wire.
|
|
64
|
+
*
|
|
65
|
+
* `pairSanitize` already runs in two upstream paths
|
|
66
|
+
* (`conversation/persist.js#loadRecentByGroup` and
|
|
67
|
+
* `history-compact.js#compactHistory`), but the engine's main loop
|
|
68
|
+
* mutates `conversationMessages` AFTER those — appending tool results
|
|
69
|
+
* mid-loop, archiving bulky tool results into stubs, and (in failure
|
|
70
|
+
* paths) potentially leaving an assistant `tool_use` whose matching
|
|
71
|
+
* `role:'tool'` was dropped or never produced. Anthropic's Messages
|
|
72
|
+
* API rejects either shape with HTTP 400 ("Each tool_use block must
|
|
73
|
+
* have a corresponding tool_result block in the next message").
|
|
74
|
+
*
|
|
75
|
+
* The router is the SINGLE choke point through which every
|
|
76
|
+
* adapter.stream() / adapter.call() flows. Sanitizing here means no
|
|
77
|
+
* caller can accidentally bypass the guard — including the per-VP
|
|
78
|
+
* group-mode path that surfaced the bug.
|
|
79
|
+
*
|
|
80
|
+
* Implementation: call `pairSanitize` exactly once and compare the
|
|
81
|
+
* result to the input. If nothing changed (length matches AND each
|
|
82
|
+
* assistant kept its toolCalls count), return the original `params`
|
|
83
|
+
* reference so the happy path is one O(n) walk + one comparison
|
|
84
|
+
* walk, no allocation downstream. If something WAS dropped, return
|
|
85
|
+
* `{ ...params, messages: cleaned }` and log a diagnostic so a
|
|
86
|
+
* recurrence stays traceable in agent logs.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} params
|
|
89
|
+
* @returns {object}
|
|
90
|
+
*/
|
|
91
|
+
export function sanitizeMessagesForWire(params) {
|
|
92
|
+
if (!params || !Array.isArray(params.messages)) return params;
|
|
93
|
+
const cleaned = pairSanitize(params.messages);
|
|
94
|
+
if (sliceUnchanged(params.messages, cleaned)) return params;
|
|
95
|
+
console.warn(
|
|
96
|
+
`[router] dropped tool_use/tool_result orphans before wire send: ` +
|
|
97
|
+
`${params.messages.length} → ${cleaned.length} messages`
|
|
98
|
+
);
|
|
99
|
+
return { ...params, messages: cleaned };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Cheap structural equality between the original messages array and
|
|
104
|
+
* the post-sanitize result. Same length AND every assistant kept its
|
|
105
|
+
* toolCalls count = no orphans were dropped. We do NOT compare full
|
|
106
|
+
* deep equality — `pairSanitize` only ever shrinks, never reorders or
|
|
107
|
+
* mutates payloads.
|
|
108
|
+
*/
|
|
109
|
+
function sliceUnchanged(original, cleaned) {
|
|
110
|
+
if (original.length !== cleaned.length) return false;
|
|
111
|
+
for (let i = 0; i < original.length; i += 1) {
|
|
112
|
+
const a = original[i];
|
|
113
|
+
const b = cleaned[i];
|
|
114
|
+
if (!a || !b) continue;
|
|
115
|
+
const aCalls = Array.isArray(a.toolCalls) ? a.toolCalls.length : 0;
|
|
116
|
+
const bCalls = Array.isArray(b.toolCalls) ? b.toolCalls.length : 0;
|
|
117
|
+
if (aCalls !== bCalls) return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
61
122
|
/**
|
|
62
123
|
* AdapterRouter — Implements LLMAdapter, routes by model → provider.
|
|
63
124
|
*/
|
|
@@ -178,8 +239,9 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
178
239
|
*/
|
|
179
240
|
async *stream(params) {
|
|
180
241
|
const filtered = filterEffortForModel(params);
|
|
181
|
-
const
|
|
182
|
-
|
|
242
|
+
const sanitized = sanitizeMessagesForWire(filtered);
|
|
243
|
+
const adapter = await this.#resolveAdapter(sanitized.model);
|
|
244
|
+
yield* adapter.stream(sanitized);
|
|
183
245
|
}
|
|
184
246
|
|
|
185
247
|
/**
|
|
@@ -190,8 +252,9 @@ export class AdapterRouter extends LLMAdapter {
|
|
|
190
252
|
*/
|
|
191
253
|
async call(params) {
|
|
192
254
|
const filtered = filterEffortForModel(params);
|
|
193
|
-
const
|
|
194
|
-
|
|
255
|
+
const sanitized = sanitizeMessagesForWire(filtered);
|
|
256
|
+
const adapter = await this.#resolveAdapter(sanitized.model);
|
|
257
|
+
return adapter.call(sanitized);
|
|
195
258
|
}
|
|
196
259
|
|
|
197
260
|
/**
|
package/unify/web-bridge.js
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
} from './history-compact.js';
|
|
54
54
|
import { createFeatureArc } from './feature-arc.js';
|
|
55
55
|
import { getFeatureStore } from './tools/feature-tools.js';
|
|
56
|
+
import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
|
|
56
57
|
|
|
57
58
|
/** @type {import('./session.js').Session | null} */
|
|
58
59
|
let session = null;
|
|
@@ -384,10 +385,24 @@ function ensureDriverRunning(groupId, vpId) {
|
|
|
384
385
|
// the legacy fan-out path so the model sees the same surface form
|
|
385
386
|
// it always has.
|
|
386
387
|
const text = envelope?.msg?.text || '';
|
|
387
|
-
|
|
388
|
+
// Attachments ride on the envelope's ephemeral fields (set by
|
|
389
|
+
// handleUnifyGroupChat → coord.ingest). When images are present
|
|
390
|
+
// we append the file list to the text prompt (same surface as
|
|
391
|
+
// Chat mode) AND build a content-array form so the LLM sees the
|
|
392
|
+
// image bytes alongside the text. Pure file-only uploads only
|
|
393
|
+
// need the suffix; engine.query falls back to string mode.
|
|
394
|
+
const inboundSuffix = envelope?._promptSuffix || '';
|
|
395
|
+
const inboundParts = Array.isArray(envelope?._promptParts)
|
|
396
|
+
? envelope._promptParts
|
|
397
|
+
: [];
|
|
398
|
+
const prompt = `@vp-${vpId} ${text}${inboundSuffix}`;
|
|
399
|
+
const promptParts = inboundParts.length > 0
|
|
400
|
+
? [...inboundParts, { type: 'text', text: prompt }]
|
|
401
|
+
: null;
|
|
388
402
|
try {
|
|
389
403
|
await runVpTurn({
|
|
390
404
|
prompt,
|
|
405
|
+
promptParts,
|
|
391
406
|
groupId,
|
|
392
407
|
vpId,
|
|
393
408
|
turnId,
|
|
@@ -1170,7 +1185,13 @@ function handleEngineEvent(event, hctx) {
|
|
|
1170
1185
|
export async function handleUnifyGroupChat(msg) {
|
|
1171
1186
|
if (!msg || typeof msg !== 'object') return;
|
|
1172
1187
|
const { text } = msg;
|
|
1173
|
-
|
|
1188
|
+
// PR #721: image-only send is allowed — text may be empty when the
|
|
1189
|
+
// user attached files only. The frontend synthesizes a placeholder
|
|
1190
|
+
// string in `sendUnifyGroupChat`, so by the time we get here `text`
|
|
1191
|
+
// should always be non-empty; but defend anyway in case an API
|
|
1192
|
+
// caller sends a bare attachment payload.
|
|
1193
|
+
const hasFiles = Array.isArray(msg.files) && msg.files.length > 0;
|
|
1194
|
+
if (!text?.trim() && !hasFiles) return;
|
|
1174
1195
|
const mentions = Array.isArray(msg.mentions) ? msg.mentions : [];
|
|
1175
1196
|
const groupId = (typeof msg.groupId === 'string' && msg.groupId.trim())
|
|
1176
1197
|
? msg.groupId.trim()
|
|
@@ -1328,6 +1349,38 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1328
1349
|
console.warn('[Unify] unify_group_chat: selective abort pre-pass failed', err?.message || err);
|
|
1329
1350
|
}
|
|
1330
1351
|
|
|
1352
|
+
// ── Attachments (images + files) ───────────────────────────────
|
|
1353
|
+
// Server has already resolved fileId → { name, mimeType, data:base64,
|
|
1354
|
+
// isImage } via the same path crew uses (client-conversation.js relay
|
|
1355
|
+
// for `unify_*`). We persist files to disk under the agent's CWD so
|
|
1356
|
+
// file-tools (file-read / bash) can pick them up with relative paths,
|
|
1357
|
+
// and we build per-image content blocks for the LLM call. The
|
|
1358
|
+
// resolved metadata WITHOUT base64 rides on coord.ingest meta so it
|
|
1359
|
+
// shows up in the persisted group log and on the envelope every VP
|
|
1360
|
+
// driver receives.
|
|
1361
|
+
const inboundFiles = Array.isArray(msg.files) ? msg.files : [];
|
|
1362
|
+
let attachmentBundle = { promptAttachments: [], promptSuffix: '', promptParts: [], failed: [] };
|
|
1363
|
+
if (inboundFiles.length > 0) {
|
|
1364
|
+
try {
|
|
1365
|
+
attachmentBundle = persistUnifyAttachments(inboundFiles, { subdir: groupId });
|
|
1366
|
+
} catch (err) {
|
|
1367
|
+
console.warn('[Unify] unify_group_chat: attachment persist failed', err?.message || err);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
// Surface partial / total upload failures to the user. We don't abort
|
|
1371
|
+
// the turn — the LLM can still answer the text-only portion — but the
|
|
1372
|
+
// user must know which files didn't make it.
|
|
1373
|
+
if (Array.isArray(attachmentBundle.failed) && attachmentBundle.failed.length > 0) {
|
|
1374
|
+
const detail = attachmentBundle.failed
|
|
1375
|
+
.map((f) => ` - ${f.name}: ${f.error}`)
|
|
1376
|
+
.join('\n');
|
|
1377
|
+
sendUnifyOutput({
|
|
1378
|
+
type: 'assistant',
|
|
1379
|
+
message: { content: [{ type: 'text', text: `⚠️ ${attachmentBundle.failed.length} file(s) could not be attached:\n${detail}` }] },
|
|
1380
|
+
}, { groupId });
|
|
1381
|
+
}
|
|
1382
|
+
const persistedAttachments = attachmentsForPersistence(attachmentBundle.promptAttachments);
|
|
1383
|
+
|
|
1331
1384
|
// Ingest user text. The coordinator persists, applies mention/fanout
|
|
1332
1385
|
// rules, and calls deliver() (== enqueueForVp) for each chosen VP —
|
|
1333
1386
|
// which both (a) emits vp_typing_start and (b) ensures a driver runs.
|
|
@@ -1337,7 +1390,16 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1337
1390
|
from: 'user',
|
|
1338
1391
|
role: 'user',
|
|
1339
1392
|
text,
|
|
1340
|
-
meta: {
|
|
1393
|
+
meta: {
|
|
1394
|
+
mentions,
|
|
1395
|
+
// Persisted form (no base64) — safe for jsonl-log.
|
|
1396
|
+
attachments: persistedAttachments,
|
|
1397
|
+
},
|
|
1398
|
+
// Live form — adapters need the base64 image blocks; runVpTurn
|
|
1399
|
+
// reads `_promptParts` off the envelope rather than going
|
|
1400
|
+
// back to disk on every fan-out target. NOT persisted.
|
|
1401
|
+
_promptParts: attachmentBundle.promptParts,
|
|
1402
|
+
_promptSuffix: attachmentBundle.promptSuffix,
|
|
1341
1403
|
});
|
|
1342
1404
|
} catch (err) {
|
|
1343
1405
|
console.warn('[Unify] unify_group_chat: coord.ingest failed', err?.message || err);
|
|
@@ -1581,7 +1643,7 @@ async function ensureSessionLoaded() {
|
|
|
1581
1643
|
*
|
|
1582
1644
|
* @param {{ prompt: string, groupId: string, vpId: string, turnId: string, envelope: object, vpAbort: AbortController, baseSnapshot: Array }} args
|
|
1583
1645
|
*/
|
|
1584
|
-
async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
|
|
1646
|
+
async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, envelope: inboundEnvelope, vpAbort, baseSnapshot }) {
|
|
1585
1647
|
if (!prompt?.trim()) return;
|
|
1586
1648
|
|
|
1587
1649
|
const envelope = { groupId, vpId, turnId };
|
|
@@ -1727,6 +1789,7 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
|
|
|
1727
1789
|
});
|
|
1728
1790
|
for await (const event of vpEngine.query({
|
|
1729
1791
|
prompt,
|
|
1792
|
+
promptParts,
|
|
1730
1793
|
messages: trimmedMessages,
|
|
1731
1794
|
signal: vpAbort.signal,
|
|
1732
1795
|
...queryOpts,
|