u-foo 3.0.2 → 3.0.4
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/src/agents/prompts/native/toolDescriptions/readImage.js +23 -0
- package/src/code/context/assembler.js +17 -1
- package/src/code/context/planMode.js +3 -2
- package/src/code/context/promptLayers.js +7 -5
- package/src/code/context/reducers.js +35 -0
- package/src/code/context/transcriptSync.js +25 -5
- package/src/code/context/userNudge.js +49 -0
- package/src/code/dispatch.js +4 -0
- package/src/code/imageIngest.js +367 -0
- package/src/code/nativeRunner.js +63 -1
- package/src/code/providers/anthropicMessagesTransport.js +28 -1
- package/src/code/providers/index.js +1 -0
- package/src/code/providers/openaiChatTransport.js +19 -1
- package/src/code/providers/visionBlocks.js +110 -0
- package/src/code/tools/readImage.js +110 -0
- package/src/ui/format/index.js +55 -2
- package/src/ui/ink/MultilineInput.js +38 -2
- package/src/ui/ink/UcodeApp.js +74 -11
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { execFileSync } = require("child_process");
|
|
7
|
+
const { mediaTypeFromPath, sniffMediaType, MAX_IMAGE_BYTES } = require("./tools/readImage");
|
|
8
|
+
|
|
9
|
+
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp)$/i;
|
|
10
|
+
const FILE_URL_RE = /^file:\/\//i;
|
|
11
|
+
|
|
12
|
+
function uploadsDir(workspaceRoot = "", sessionId = "") {
|
|
13
|
+
const root = path.resolve(String(workspaceRoot || process.cwd()));
|
|
14
|
+
const sid = String(sessionId || "session").trim().replace(/[^a-zA-Z0-9._-]+/g, "_") || "session";
|
|
15
|
+
return path.join(root, ".ufoo", "agent", "ucode", "uploads", sid);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function safeBaseName(filePath = "") {
|
|
19
|
+
const base = path.basename(String(filePath || "image.png"));
|
|
20
|
+
const cleaned = base.replace(/[^\w.\-()+ ]+/g, "_").replace(/\s+/g, " ").trim();
|
|
21
|
+
if (!cleaned) return "image.png";
|
|
22
|
+
if (!IMAGE_EXT_RE.test(cleaned)) return `${cleaned}.png`;
|
|
23
|
+
return cleaned.slice(0, 120);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function decodeFileUrl(value = "") {
|
|
27
|
+
const text = String(value || "").trim();
|
|
28
|
+
if (!FILE_URL_RE.test(text)) return text;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = new URL(text);
|
|
31
|
+
if (parsed.protocol !== "file:") return text;
|
|
32
|
+
return decodeURIComponent(parsed.pathname || "");
|
|
33
|
+
} catch {
|
|
34
|
+
return text.replace(FILE_URL_RE, "");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function looksLikeImagePath(candidate = "") {
|
|
39
|
+
const text = decodeFileUrl(String(candidate || "").trim().replace(/^['"]|['"]$/g, ""));
|
|
40
|
+
if (!text || !IMAGE_EXT_RE.test(text)) return false;
|
|
41
|
+
if (text.startsWith("/") || /^[A-Za-z]:[\\/]/.test(text) || text.startsWith("~")) return true;
|
|
42
|
+
// Relative paths ending in image ext (drag from cwd listings)
|
|
43
|
+
if (!/\s/.test(text) && IMAGE_EXT_RE.test(text)) return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function expandHome(filePath = "") {
|
|
48
|
+
const text = String(filePath || "");
|
|
49
|
+
if (text.startsWith("~/")) return path.join(os.homedir(), text.slice(2));
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract image file paths from terminal paste / drag-drop text.
|
|
55
|
+
* Supports file://, quoted paths with spaces, and bare absolute paths.
|
|
56
|
+
*/
|
|
57
|
+
function extractImagePathsFromPaste(text = "") {
|
|
58
|
+
const raw = String(text || "");
|
|
59
|
+
if (!raw.trim()) return [];
|
|
60
|
+
const found = [];
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
|
|
63
|
+
function pushPath(candidate) {
|
|
64
|
+
let next = decodeFileUrl(String(candidate || "").trim());
|
|
65
|
+
next = next.replace(/^['"]|['"]$/g, "");
|
|
66
|
+
if (!looksLikeImagePath(next)) return;
|
|
67
|
+
next = expandHome(next);
|
|
68
|
+
const key = path.resolve(next);
|
|
69
|
+
if (seen.has(key)) return;
|
|
70
|
+
seen.add(key);
|
|
71
|
+
found.push(next);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Quoted paths (possibly with spaces)
|
|
75
|
+
const quoted = /["']([^"']+\.(?:png|jpe?g|gif|webp))["']/gi;
|
|
76
|
+
let match;
|
|
77
|
+
while ((match = quoted.exec(raw))) {
|
|
78
|
+
pushPath(match[1]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// file:// URLs
|
|
82
|
+
const fileUrls = /file:\/\/[^\s"'<>]+/gi;
|
|
83
|
+
while ((match = fileUrls.exec(raw))) {
|
|
84
|
+
pushPath(match[0]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Bare tokens / lines
|
|
88
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
89
|
+
const trimmed = line.trim();
|
|
90
|
+
if (!trimmed) continue;
|
|
91
|
+
if (looksLikeImagePath(trimmed)) {
|
|
92
|
+
pushPath(trimmed);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
for (const token of trimmed.split(/\s+/)) {
|
|
96
|
+
if (looksLikeImagePath(token)) pushPath(token);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stripExtractedPathsFromText(text = "", paths = []) {
|
|
104
|
+
let out = String(text || "");
|
|
105
|
+
for (const p of paths) {
|
|
106
|
+
const variants = [
|
|
107
|
+
`"${p}"`,
|
|
108
|
+
`'${p}'`,
|
|
109
|
+
p,
|
|
110
|
+
p.startsWith("/") ? `file://${p}` : "",
|
|
111
|
+
p.startsWith("/") ? `file://${encodeURI(p)}` : "",
|
|
112
|
+
].filter(Boolean);
|
|
113
|
+
for (const v of variants) {
|
|
114
|
+
out = out.split(v).join(" ");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
119
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
120
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
121
|
+
.replace(/\s+"/g, " ")
|
|
122
|
+
.replace(/"\s+/g, " ")
|
|
123
|
+
.replace(/\s+'/g, " ")
|
|
124
|
+
.replace(/'\s+/g, " ")
|
|
125
|
+
.trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatImageLogLabel({ relPath = "", fileName = "", path: pathText = "" } = {}) {
|
|
129
|
+
const name = String(fileName || "").trim()
|
|
130
|
+
|| path.basename(String(relPath || pathText || "").trim())
|
|
131
|
+
|| "image";
|
|
132
|
+
return `[image: ${name}]`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatUserLogWithAttachments(userText = "", attachments = []) {
|
|
136
|
+
const labels = (Array.isArray(attachments) ? attachments : [])
|
|
137
|
+
.map((item) => formatImageLogLabel(item))
|
|
138
|
+
.filter(Boolean);
|
|
139
|
+
const body = String(userText || "").trim();
|
|
140
|
+
if (labels.length === 0) return body;
|
|
141
|
+
if (!body) return labels.join(" ");
|
|
142
|
+
return `${labels.join(" ")} ${body}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildAttachedImagesPromptPrefix(attachments = []) {
|
|
146
|
+
const list = Array.isArray(attachments) ? attachments : [];
|
|
147
|
+
if (list.length === 0) return "";
|
|
148
|
+
const lines = [
|
|
149
|
+
"[Attached images — call read_image on each path]",
|
|
150
|
+
...list.map((item) => `- ${item.relPath || item.path || ""}`).filter((line) => line !== "- "),
|
|
151
|
+
"",
|
|
152
|
+
];
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ingestImageFile({
|
|
157
|
+
sourcePath = "",
|
|
158
|
+
workspaceRoot = process.cwd(),
|
|
159
|
+
sessionId = "",
|
|
160
|
+
buffer = null,
|
|
161
|
+
preferredName = "",
|
|
162
|
+
} = {}) {
|
|
163
|
+
const root = path.resolve(String(workspaceRoot || process.cwd()));
|
|
164
|
+
let data = buffer;
|
|
165
|
+
let fromPath = String(sourcePath || "").trim();
|
|
166
|
+
|
|
167
|
+
if (!data) {
|
|
168
|
+
if (!fromPath) {
|
|
169
|
+
return { ok: false, error: "sourcePath or buffer required" };
|
|
170
|
+
}
|
|
171
|
+
fromPath = expandHome(decodeFileUrl(fromPath));
|
|
172
|
+
try {
|
|
173
|
+
const stat = fs.statSync(fromPath);
|
|
174
|
+
if (!stat.isFile()) return { ok: false, error: `not a file: ${fromPath}` };
|
|
175
|
+
if (stat.size > MAX_IMAGE_BYTES) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: `image too large (${stat.size} bytes); max ${MAX_IMAGE_BYTES}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
data = fs.readFileSync(fromPath);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return { ok: false, error: err && err.message ? err.message : "read failed" };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!Buffer.isBuffer(data)) {
|
|
188
|
+
return { ok: false, error: "image buffer required" };
|
|
189
|
+
}
|
|
190
|
+
if (data.length > MAX_IMAGE_BYTES) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
error: `image too large (${data.length} bytes); max ${MAX_IMAGE_BYTES}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const sniffed = sniffMediaType(data);
|
|
198
|
+
const fromName = mediaTypeFromPath(preferredName || fromPath);
|
|
199
|
+
const mediaType = sniffed || fromName;
|
|
200
|
+
if (!mediaType) {
|
|
201
|
+
return { ok: false, error: "unsupported image type (use png, jpeg, gif, or webp)" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const ext = mediaType === "image/jpeg"
|
|
205
|
+
? ".jpg"
|
|
206
|
+
: mediaType === "image/gif"
|
|
207
|
+
? ".gif"
|
|
208
|
+
: mediaType === "image/webp"
|
|
209
|
+
? ".webp"
|
|
210
|
+
: ".png";
|
|
211
|
+
|
|
212
|
+
let base = safeBaseName(preferredName || fromPath || `clipboard${ext}`);
|
|
213
|
+
if (!IMAGE_EXT_RE.test(base)) base = `${base}${ext}`;
|
|
214
|
+
// Normalize extension to sniffed type
|
|
215
|
+
base = `${path.basename(base, path.extname(base))}${ext}`;
|
|
216
|
+
|
|
217
|
+
const dir = uploadsDir(root, sessionId);
|
|
218
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
219
|
+
const stamp = Date.now().toString(36);
|
|
220
|
+
const destName = `${stamp}-${base}`;
|
|
221
|
+
const absPath = path.join(dir, destName);
|
|
222
|
+
fs.writeFileSync(absPath, data);
|
|
223
|
+
|
|
224
|
+
const relPath = path.relative(root, absPath).split(path.sep).join("/");
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
relPath,
|
|
228
|
+
absPath,
|
|
229
|
+
fileName: base,
|
|
230
|
+
mediaType,
|
|
231
|
+
bytes: data.length,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function tryIngestClipboardImage({
|
|
236
|
+
workspaceRoot = process.cwd(),
|
|
237
|
+
sessionId = "",
|
|
238
|
+
platform = process.platform,
|
|
239
|
+
execFile = execFileSync,
|
|
240
|
+
} = {}) {
|
|
241
|
+
if (platform !== "darwin") {
|
|
242
|
+
return { ok: false, error: "clipboard image ingest is only supported on macOS" };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const tmpPath = path.join(
|
|
246
|
+
os.tmpdir(),
|
|
247
|
+
`ufoo-clipboard-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.png`,
|
|
248
|
+
);
|
|
249
|
+
// AppleScript: write clipboard PNGf to a temp file.
|
|
250
|
+
const script = [
|
|
251
|
+
`set outPath to POSIX file ${JSON.stringify(tmpPath)}`,
|
|
252
|
+
"try",
|
|
253
|
+
" set pngData to the clipboard as «class PNGf»",
|
|
254
|
+
" set fileRef to open for access outPath with write permission",
|
|
255
|
+
" set eof of fileRef to 0",
|
|
256
|
+
" write pngData to fileRef",
|
|
257
|
+
" close access fileRef",
|
|
258
|
+
' return "ok"',
|
|
259
|
+
"on error errMsg number errNum",
|
|
260
|
+
" try",
|
|
261
|
+
" close access outPath",
|
|
262
|
+
" end try",
|
|
263
|
+
' return "err:" & errMsg',
|
|
264
|
+
"end try",
|
|
265
|
+
].join("\n");
|
|
266
|
+
|
|
267
|
+
let resultText = "";
|
|
268
|
+
try {
|
|
269
|
+
resultText = String(execFile("osascript", ["-e", script], {
|
|
270
|
+
encoding: "utf8",
|
|
271
|
+
timeout: 5000,
|
|
272
|
+
maxBuffer: 1024 * 1024,
|
|
273
|
+
}) || "").trim();
|
|
274
|
+
} catch (err) {
|
|
275
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
error: err && err.message ? err.message : "clipboard read failed",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!resultText.startsWith("ok")) {
|
|
283
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
error: resultText.replace(/^err:/, "").trim() || "no PNG image on clipboard",
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const ingested = ingestImageFile({
|
|
292
|
+
sourcePath: tmpPath,
|
|
293
|
+
workspaceRoot,
|
|
294
|
+
sessionId,
|
|
295
|
+
preferredName: `clipboard-${Date.now().toString(36)}.png`,
|
|
296
|
+
});
|
|
297
|
+
return ingested;
|
|
298
|
+
} finally {
|
|
299
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Handle a paste chunk: ingest image paths and/or macOS clipboard bitmap.
|
|
305
|
+
* Returns text to insert into the editor (paths removed) plus attachments.
|
|
306
|
+
*/
|
|
307
|
+
function handleImagePaste(text = "", {
|
|
308
|
+
workspaceRoot = process.cwd(),
|
|
309
|
+
sessionId = "",
|
|
310
|
+
tryClipboard = true,
|
|
311
|
+
platform = process.platform,
|
|
312
|
+
execFile = execFileSync,
|
|
313
|
+
} = {}) {
|
|
314
|
+
const raw = String(text || "");
|
|
315
|
+
const paths = extractImagePathsFromPaste(raw);
|
|
316
|
+
const attachments = [];
|
|
317
|
+
const errors = [];
|
|
318
|
+
|
|
319
|
+
for (const sourcePath of paths) {
|
|
320
|
+
const ingested = ingestImageFile({ sourcePath, workspaceRoot, sessionId });
|
|
321
|
+
if (ingested.ok) attachments.push(ingested);
|
|
322
|
+
else errors.push(ingested.error || "ingest failed");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let remaining = stripExtractedPathsFromText(raw, paths);
|
|
326
|
+
|
|
327
|
+
// If paste had no usable text/paths, try clipboard PNG (Cmd+V of a screenshot).
|
|
328
|
+
const trimmedRemaining = remaining.trim();
|
|
329
|
+
const looksEmptyOrBinary = !trimmedRemaining
|
|
330
|
+
|| /[\x00-\x08\x0e-\x1f]/.test(raw)
|
|
331
|
+
|| (Buffer.byteLength(raw, "utf8") > 200 && paths.length === 0 && !/\s/.test(raw.slice(0, 40)));
|
|
332
|
+
|
|
333
|
+
if (tryClipboard && attachments.length === 0 && looksEmptyOrBinary) {
|
|
334
|
+
const clip = tryIngestClipboardImage({
|
|
335
|
+
workspaceRoot,
|
|
336
|
+
sessionId,
|
|
337
|
+
platform,
|
|
338
|
+
execFile,
|
|
339
|
+
});
|
|
340
|
+
if (clip.ok) {
|
|
341
|
+
attachments.push(clip);
|
|
342
|
+
remaining = "";
|
|
343
|
+
} else if (paths.length === 0 && !trimmedRemaining) {
|
|
344
|
+
errors.push(clip.error || "clipboard ingest failed");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
text: remaining,
|
|
350
|
+
attachments,
|
|
351
|
+
errors,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
module.exports = {
|
|
356
|
+
IMAGE_EXT_RE,
|
|
357
|
+
uploadsDir,
|
|
358
|
+
safeBaseName,
|
|
359
|
+
extractImagePathsFromPaste,
|
|
360
|
+
stripExtractedPathsFromText,
|
|
361
|
+
formatImageLogLabel,
|
|
362
|
+
formatUserLogWithAttachments,
|
|
363
|
+
buildAttachedImagesPromptPrefix,
|
|
364
|
+
ingestImageFile,
|
|
365
|
+
tryIngestClipboardImage,
|
|
366
|
+
handleImagePaste,
|
|
367
|
+
};
|
package/src/code/nativeRunner.js
CHANGED
|
@@ -27,6 +27,8 @@ const {
|
|
|
27
27
|
clearUserPrompts,
|
|
28
28
|
formatUserReminderMessage,
|
|
29
29
|
ensurePendingUserPrompts,
|
|
30
|
+
shouldAutoContinuePlan,
|
|
31
|
+
buildPlanAutoContinueReminder,
|
|
30
32
|
} = require("./context/userNudge");
|
|
31
33
|
const {
|
|
32
34
|
runAskUserTool,
|
|
@@ -50,12 +52,14 @@ const {
|
|
|
50
52
|
} = require("./protocol");
|
|
51
53
|
const { stableStringify } = require("./context/stableJson");
|
|
52
54
|
const { getReadToolDescription } = require("../agents/prompts/native/toolDescriptions/read");
|
|
55
|
+
const { getReadImageToolDescription } = require("../agents/prompts/native/toolDescriptions/readImage");
|
|
53
56
|
const { getWriteToolDescription } = require("../agents/prompts/native/toolDescriptions/write");
|
|
54
57
|
const { getEditToolDescription } = require("../agents/prompts/native/toolDescriptions/edit");
|
|
55
58
|
const { getBashToolDescription } = require("../agents/prompts/native/toolDescriptions/bash");
|
|
56
59
|
|
|
57
60
|
const CORE_TOOL_NAMES = new Set([
|
|
58
61
|
"read",
|
|
62
|
+
"read_image",
|
|
59
63
|
"write",
|
|
60
64
|
"edit",
|
|
61
65
|
"bash",
|
|
@@ -64,7 +68,14 @@ const CORE_TOOL_NAMES = new Set([
|
|
|
64
68
|
"task_run",
|
|
65
69
|
"ask_user",
|
|
66
70
|
]);
|
|
67
|
-
const EXECUTABLE_GRAPH_TOOLS = new Set([
|
|
71
|
+
const EXECUTABLE_GRAPH_TOOLS = new Set([
|
|
72
|
+
"read",
|
|
73
|
+
"read_image",
|
|
74
|
+
"write",
|
|
75
|
+
"edit",
|
|
76
|
+
"bash",
|
|
77
|
+
"artifact_read",
|
|
78
|
+
]);
|
|
68
79
|
const CONTROL_PLANE_TOOLS = new Set(["plan_graph", "task_run"]);
|
|
69
80
|
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
70
81
|
const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
|
|
@@ -76,6 +87,8 @@ const DEFAULT_KIMI_MODEL = "k3";
|
|
|
76
87
|
const DEFAULT_MAX_NATIVE_TOOL_CALLS = 100;
|
|
77
88
|
const DEFAULT_MAX_NATIVE_TOOL_ERRORS = 20;
|
|
78
89
|
const DEFAULT_NATIVE_TIMEOUT_MS = 43200000; // 12 hours
|
|
90
|
+
/** Max text-only auto-continues while a plan is waiting on a task (per user submit). */
|
|
91
|
+
const DEFAULT_MAX_PLAN_AUTO_CONTINUES = 24;
|
|
79
92
|
// Anthropic Messages rejects max_tokens above the model's real cap (64K on
|
|
80
93
|
// current models), so the transports use different defaults. Override either
|
|
81
94
|
// via UFOO_UCODE_MAX_TOKENS (positive integer).
|
|
@@ -392,6 +405,23 @@ function buildCoreToolSpecs() {
|
|
|
392
405
|
},
|
|
393
406
|
},
|
|
394
407
|
},
|
|
408
|
+
{
|
|
409
|
+
type: "function",
|
|
410
|
+
function: {
|
|
411
|
+
name: "read_image",
|
|
412
|
+
description: getReadImageToolDescription(),
|
|
413
|
+
parameters: {
|
|
414
|
+
type: "object",
|
|
415
|
+
properties: {
|
|
416
|
+
path: {
|
|
417
|
+
type: "string",
|
|
418
|
+
description: "Workspace-relative path to a png, jpeg, gif, or webp image.",
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
required: ["path"],
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
},
|
|
395
425
|
{
|
|
396
426
|
type: "function",
|
|
397
427
|
function: {
|
|
@@ -1733,6 +1763,9 @@ async function runNativeLoop({
|
|
|
1733
1763
|
// materialize Provider messages yet. STRICT via UFOO_UCODE_PROTOCOL_STRICT=1.
|
|
1734
1764
|
let activeLedger = null;
|
|
1735
1765
|
let lastProtocolLedger = null;
|
|
1766
|
+
let planAutoContinues = 0;
|
|
1767
|
+
let lastAutoContinueWaitingId = "";
|
|
1768
|
+
let consecutiveEmptyAutoContinues = 0;
|
|
1736
1769
|
|
|
1737
1770
|
if (resume) {
|
|
1738
1771
|
await withFaultPoint("before_provider_resume", () => {});
|
|
@@ -1749,6 +1782,29 @@ async function runNativeLoop({
|
|
|
1749
1782
|
messages.push({ role: "user", content });
|
|
1750
1783
|
}
|
|
1751
1784
|
|
|
1785
|
+
function tryInjectPlanAutoContinue() {
|
|
1786
|
+
if (!shouldAutoContinuePlan(executionState)) return false;
|
|
1787
|
+
if (planAutoContinues >= DEFAULT_MAX_PLAN_AUTO_CONTINUES) return false;
|
|
1788
|
+
const waitingId = String(
|
|
1789
|
+
(executionState.planGraph && executionState.planGraph.waitingFor
|
|
1790
|
+
&& executionState.planGraph.waitingFor.id) || ""
|
|
1791
|
+
).trim();
|
|
1792
|
+
if (
|
|
1793
|
+
consecutiveEmptyAutoContinues >= 2
|
|
1794
|
+
&& waitingId
|
|
1795
|
+
&& waitingId === lastAutoContinueWaitingId
|
|
1796
|
+
) {
|
|
1797
|
+
return false;
|
|
1798
|
+
}
|
|
1799
|
+
const reminder = buildPlanAutoContinueReminder(executionState);
|
|
1800
|
+
if (!reminder) return false;
|
|
1801
|
+
messages.push({ role: "user", content: reminder });
|
|
1802
|
+
planAutoContinues += 1;
|
|
1803
|
+
lastAutoContinueWaitingId = waitingId;
|
|
1804
|
+
consecutiveEmptyAutoContinues += 1;
|
|
1805
|
+
return true;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1752
1808
|
while (true) {
|
|
1753
1809
|
guards.ensureActive();
|
|
1754
1810
|
|
|
@@ -1820,6 +1876,9 @@ async function runNativeLoop({
|
|
|
1820
1876
|
if (!aggregated.trim() && text) {
|
|
1821
1877
|
aggregated = text;
|
|
1822
1878
|
}
|
|
1879
|
+
if (tryInjectPlanAutoContinue()) {
|
|
1880
|
+
continue;
|
|
1881
|
+
}
|
|
1823
1882
|
return {
|
|
1824
1883
|
text: aggregated,
|
|
1825
1884
|
streamed,
|
|
@@ -1831,6 +1890,9 @@ async function runNativeLoop({
|
|
|
1831
1890
|
};
|
|
1832
1891
|
}
|
|
1833
1892
|
|
|
1893
|
+
// A tool-using turn resets the empty auto-continue streak (progress possible).
|
|
1894
|
+
consecutiveEmptyAutoContinues = 0;
|
|
1895
|
+
|
|
1834
1896
|
const pendingCalls = transport.prepareToolCalls({ messages, turnResult, toolCalls });
|
|
1835
1897
|
if (!pendingCalls) {
|
|
1836
1898
|
return {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const { assertTransport } = require("./transportContract");
|
|
4
|
+
const {
|
|
5
|
+
extractVisionPayload,
|
|
6
|
+
stripVisionBase64,
|
|
7
|
+
visionSummaryText,
|
|
8
|
+
toAnthropicImageBlock,
|
|
9
|
+
} = require("./visionBlocks");
|
|
4
10
|
|
|
5
11
|
/**
|
|
6
12
|
* Anthropic Messages API transport adapter.
|
|
@@ -70,11 +76,32 @@ function createAnthropicMessagesTransport(deps = {}) {
|
|
|
70
76
|
}));
|
|
71
77
|
},
|
|
72
78
|
appendToolResult({ collected, call, toolResult }) {
|
|
79
|
+
const vision = extractVisionPayload(toolResult);
|
|
80
|
+
const isError = Boolean(!toolResult || toolResult.ok === false);
|
|
81
|
+
if (vision) {
|
|
82
|
+
const textPayload = stripVisionBase64(toolResult);
|
|
83
|
+
collected.push({
|
|
84
|
+
type: "tool_result",
|
|
85
|
+
tool_use_id: String(call.source.id || ""),
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "text",
|
|
89
|
+
text: clipText(
|
|
90
|
+
`${visionSummaryText(vision, toolResult)}\n${toJsonString(textPayload)}`,
|
|
91
|
+
12000,
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
toAnthropicImageBlock(vision),
|
|
95
|
+
],
|
|
96
|
+
is_error: isError,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
73
100
|
collected.push({
|
|
74
101
|
type: "tool_result",
|
|
75
102
|
tool_use_id: String(call.source.id || ""),
|
|
76
103
|
content: clipText(toJsonString(toolResult), 12000),
|
|
77
|
-
is_error:
|
|
104
|
+
is_error: isError,
|
|
78
105
|
});
|
|
79
106
|
},
|
|
80
107
|
flushToolResults({ messages, collected }) {
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
const { randomUUID } = require("crypto");
|
|
4
4
|
const { assertTransport } = require("./transportContract");
|
|
5
|
+
const {
|
|
6
|
+
extractVisionPayload,
|
|
7
|
+
stripVisionBase64,
|
|
8
|
+
visionSummaryText,
|
|
9
|
+
toOpenAiImagePart,
|
|
10
|
+
} = require("./visionBlocks");
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* OpenAI-compatible chat-completions transport adapter.
|
|
@@ -82,11 +88,23 @@ function createOpenAiChatTransport(deps = {}) {
|
|
|
82
88
|
}));
|
|
83
89
|
},
|
|
84
90
|
appendToolResult({ messages, call, toolResult }) {
|
|
91
|
+
const vision = extractVisionPayload(toolResult);
|
|
92
|
+
const payload = vision ? stripVisionBase64(toolResult) : toolResult;
|
|
85
93
|
messages.push({
|
|
86
94
|
role: "tool",
|
|
87
95
|
tool_call_id: call.source.id,
|
|
88
|
-
content: clipText(toJsonString(
|
|
96
|
+
content: clipText(toJsonString(payload), 12000),
|
|
89
97
|
});
|
|
98
|
+
if (vision) {
|
|
99
|
+
const imagePart = toOpenAiImagePart(vision);
|
|
100
|
+
messages.push({
|
|
101
|
+
role: "user",
|
|
102
|
+
content: [
|
|
103
|
+
{ type: "text", text: visionSummaryText(vision, toolResult) },
|
|
104
|
+
imagePart,
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
90
108
|
},
|
|
91
109
|
};
|
|
92
110
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Helpers for expanding read_image tool results into provider vision blocks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
function extractVisionPayload(toolResult = null) {
|
|
8
|
+
if (!toolResult || typeof toolResult !== "object") return null;
|
|
9
|
+
const base64 = String(toolResult.base64 || "").trim();
|
|
10
|
+
const mediaType = String(toolResult.mediaType || "").trim().toLowerCase();
|
|
11
|
+
if (!base64 || !mediaType.startsWith("image/")) return null;
|
|
12
|
+
if (toolResult.ok === false) return null;
|
|
13
|
+
return {
|
|
14
|
+
path: String(toolResult.path || "").trim(),
|
|
15
|
+
mediaType,
|
|
16
|
+
bytes: Number.isFinite(toolResult.bytes) ? toolResult.bytes : null,
|
|
17
|
+
base64,
|
|
18
|
+
artifactId: String(toolResult.artifactId || "").trim(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isVisionToolResult(toolResult = null) {
|
|
23
|
+
return Boolean(extractVisionPayload(toolResult));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stripVisionBase64(value) {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.map((item) => stripVisionBase64(item));
|
|
29
|
+
}
|
|
30
|
+
if (!value || typeof value !== "object") return value;
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
33
|
+
if (key === "base64") continue;
|
|
34
|
+
out[key] = stripVisionBase64(entry);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function visionSummaryText(vision = null, toolResult = null) {
|
|
40
|
+
const pathText = (vision && vision.path) || (toolResult && toolResult.path) || "image";
|
|
41
|
+
const mediaType = (vision && vision.mediaType) || (toolResult && toolResult.mediaType) || "image/*";
|
|
42
|
+
const bytes = (vision && vision.bytes) != null
|
|
43
|
+
? vision.bytes
|
|
44
|
+
: (toolResult && toolResult.bytes);
|
|
45
|
+
const parts = [
|
|
46
|
+
`Image loaded for vision: ${pathText}`,
|
|
47
|
+
`mediaType=${mediaType}`,
|
|
48
|
+
];
|
|
49
|
+
if (Number.isFinite(bytes)) parts.push(`bytes=${bytes}`);
|
|
50
|
+
if (vision && vision.artifactId) parts.push(`artifactId=${vision.artifactId}`);
|
|
51
|
+
parts.push("Visual content is attached for this model call only; call read_image again later if needed.");
|
|
52
|
+
return parts.join(" | ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toAnthropicImageBlock(vision = null) {
|
|
56
|
+
if (!vision) return null;
|
|
57
|
+
return {
|
|
58
|
+
type: "image",
|
|
59
|
+
source: {
|
|
60
|
+
type: "base64",
|
|
61
|
+
media_type: vision.mediaType,
|
|
62
|
+
data: vision.base64,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toOpenAiImagePart(vision = null) {
|
|
68
|
+
if (!vision) return null;
|
|
69
|
+
return {
|
|
70
|
+
type: "image_url",
|
|
71
|
+
image_url: {
|
|
72
|
+
url: `data:${vision.mediaType};base64,${vision.base64}`,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function degradeVisionContent(content) {
|
|
78
|
+
if (typeof content === "string") return content;
|
|
79
|
+
if (!Array.isArray(content)) return content;
|
|
80
|
+
const texts = [];
|
|
81
|
+
for (const block of content) {
|
|
82
|
+
if (!block || typeof block !== "object") continue;
|
|
83
|
+
const type = String(block.type || "").trim().toLowerCase();
|
|
84
|
+
if (type === "text" && block.text) {
|
|
85
|
+
texts.push(String(block.text));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (type === "image" || type === "image_url") {
|
|
89
|
+
const pathHint = block.path
|
|
90
|
+
|| (block.source && block.source.path)
|
|
91
|
+
|| "";
|
|
92
|
+
texts.push(pathHint ? `[image: ${pathHint}]` : "[image]");
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (type === "tool_result" && Array.isArray(block.content)) {
|
|
96
|
+
texts.push(degradeVisionContent(block.content));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return texts.filter(Boolean).join("\n") || "[multimodal content]";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
extractVisionPayload,
|
|
104
|
+
isVisionToolResult,
|
|
105
|
+
stripVisionBase64,
|
|
106
|
+
visionSummaryText,
|
|
107
|
+
toAnthropicImageBlock,
|
|
108
|
+
toOpenAiImagePart,
|
|
109
|
+
degradeVisionContent,
|
|
110
|
+
};
|