teapot-coding-agent 0.15.2 → 0.15.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/dist/agent/llm.js +68 -10
- package/dist/server/api.js +77 -1
- package/package.json +1 -1
- package/public/assets/{index-GM3KalCv.js → index-BtzQpvLr.js} +8 -8
- package/public/assets/index-BtzQpvLr.js.map +1 -0
- package/public/assets/{index-BQv6XAjp.css → index-eI4YBc_o.css} +1 -1
- package/public/assets/{shiki-iG0tvr7B.js → shiki-B7A2MCj6.js} +2 -2
- package/public/assets/{shiki-iG0tvr7B.js.map → shiki-B7A2MCj6.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-GM3KalCv.js.map +0 -1
package/dist/agent/llm.js
CHANGED
|
@@ -47,6 +47,37 @@ function client(cfg) {
|
|
|
47
47
|
* empty text content in user/assistant messages makes many of them 400.
|
|
48
48
|
* Fill blanks with a harmless placeholder before sending.
|
|
49
49
|
*/
|
|
50
|
+
/**
|
|
51
|
+
* Providers occasionally glue MULTIPLE tool calls into one entry, or leak
|
|
52
|
+
* their internal framing into the name field (seen live: a name of
|
|
53
|
+
* `get_goal\uFFFD\uFFFDlist_dir` whose raw bytes were
|
|
54
|
+
* `get_goal</tool_call><…><tool_call><…>`). Detect the tag boundaries and
|
|
55
|
+
* split the blob back into individual calls so the round stays usable
|
|
56
|
+
* instead of failing with "unknown tool".
|
|
57
|
+
*/
|
|
58
|
+
function repairMangledToolName(raw) {
|
|
59
|
+
const calls = [];
|
|
60
|
+
// Form 1 — framing tags survived: <name></tool_call>[<id>…]<tool_call><name>
|
|
61
|
+
const tagRe = /([a-zA-Z_][\w.]*)<\/tool_call>|<tool_call>\s*<?([a-zA-Z_][\w.]*)?/g;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = tagRe.exec(raw))) {
|
|
64
|
+
const name = m[1] ?? m[2];
|
|
65
|
+
if (name && !calls.some((c) => c.name === name))
|
|
66
|
+
calls.push({ name, arguments: "{}" });
|
|
67
|
+
}
|
|
68
|
+
if (calls.length)
|
|
69
|
+
return calls;
|
|
70
|
+
// Form 2 — tags were already destroyed into U+FFFD replacement chars:
|
|
71
|
+
// "get_goal\uFFFD\uFFFDlist_dir" → split on the replacement runs
|
|
72
|
+
const parts = raw.split(/[\ufffd]+/).filter((p) => /^[a-zA-Z_][\w.]*$/.test(p));
|
|
73
|
+
for (const p of parts)
|
|
74
|
+
calls.push({ name: p, arguments: "{}" });
|
|
75
|
+
return calls;
|
|
76
|
+
}
|
|
77
|
+
/** true when a tool name can't possibly be one of ours (framing leaked in) */
|
|
78
|
+
function looksMangled(name) {
|
|
79
|
+
return !name || /[\ufffd<>]/.test(name) || /<tool_call>|<\/tool/.test(name);
|
|
80
|
+
}
|
|
50
81
|
function sanitize(messages) {
|
|
51
82
|
return messages.map((m) => {
|
|
52
83
|
if ((m.role === "user" || m.role === "assistant") && !m.content) {
|
|
@@ -78,11 +109,26 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
|
78
109
|
const reasoning = typeof rm.reasoning === "string" && rm.reasoning ? rm.reasoning : undefined;
|
|
79
110
|
const calls = rm.tool_calls;
|
|
80
111
|
if (calls?.length) {
|
|
81
|
-
message.tool_calls = calls.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
message.tool_calls = calls.flatMap((c, i) => {
|
|
113
|
+
const name = c.function?.name ?? "";
|
|
114
|
+
// provider glued several calls together / leaked framing into the
|
|
115
|
+
// name — split the blob back into real calls so the round survives
|
|
116
|
+
if (looksMangled(name)) {
|
|
117
|
+
const repaired = repairMangledToolName(name);
|
|
118
|
+
if (repaired.length)
|
|
119
|
+
return repaired.map((r, k) => ({
|
|
120
|
+
id: `${c.id ?? `call_${i}`}_${k}`,
|
|
121
|
+
type: "function",
|
|
122
|
+
function: { name: r.name, arguments: r.arguments },
|
|
123
|
+
}));
|
|
124
|
+
return []; // unsalvageable — drop rather than feed "unknown tool"
|
|
125
|
+
}
|
|
126
|
+
return [{
|
|
127
|
+
id: c.id ?? `call_${i}`,
|
|
128
|
+
type: "function",
|
|
129
|
+
function: { name, arguments: c.function?.arguments ?? "{}" },
|
|
130
|
+
}];
|
|
131
|
+
});
|
|
86
132
|
}
|
|
87
133
|
// some gateways answer 200 with finish_reason "error"; those tool calls /
|
|
88
134
|
// text are often truncated garbage — discard the whole completion so the
|
|
@@ -177,11 +223,23 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
177
223
|
.sort((a, b) => a - b)
|
|
178
224
|
.map((i) => calls[i]);
|
|
179
225
|
if (list.length)
|
|
180
|
-
message.tool_calls = list.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
226
|
+
message.tool_calls = list.flatMap((c, i) => {
|
|
227
|
+
if (looksMangled(c.name)) {
|
|
228
|
+
const repaired = repairMangledToolName(c.name);
|
|
229
|
+
if (repaired.length)
|
|
230
|
+
return repaired.map((r, k) => ({
|
|
231
|
+
id: `${c.id || `call_${i}`}_${k}`,
|
|
232
|
+
type: "function",
|
|
233
|
+
function: { name: r.name, arguments: r.arguments },
|
|
234
|
+
}));
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
return [{
|
|
238
|
+
id: c.id || `call_${i}`,
|
|
239
|
+
type: "function",
|
|
240
|
+
function: { name: c.name, arguments: c.args || "{}" },
|
|
241
|
+
}];
|
|
242
|
+
});
|
|
185
243
|
if (!message.content && !message.tool_calls)
|
|
186
244
|
throw new Error("LLM API error: empty completion");
|
|
187
245
|
return { message, reasoning: reasoning || undefined, usage };
|
package/dist/server/api.js
CHANGED
|
@@ -45,6 +45,43 @@ async function listModels(baseUrl, apiKey) {
|
|
|
45
45
|
import path from "node:path";
|
|
46
46
|
import { bus } from "../bus.js";
|
|
47
47
|
import { readEvents } from "../log/events.js";
|
|
48
|
+
/** media categories the file tree can preview inline */
|
|
49
|
+
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"]);
|
|
50
|
+
const VIDEO_EXT = new Set(["mp4", "webm", "mov", "mkv", "avi", "m4v"]);
|
|
51
|
+
const AUDIO_EXT = new Set(["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"]);
|
|
52
|
+
function mediaKind(p) {
|
|
53
|
+
const ext = p.split(".").pop()?.toLowerCase() ?? "";
|
|
54
|
+
if (IMAGE_EXT.has(ext))
|
|
55
|
+
return "image";
|
|
56
|
+
if (VIDEO_EXT.has(ext))
|
|
57
|
+
return "video";
|
|
58
|
+
if (AUDIO_EXT.has(ext))
|
|
59
|
+
return "audio";
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/** extension → MIME (used for the media preview endpoint) */
|
|
63
|
+
export const MEDIA_MIME = (() => {
|
|
64
|
+
const m = {};
|
|
65
|
+
for (const e of IMAGE_EXT)
|
|
66
|
+
m[e] = `image/${e === "jpg" ? "jpeg" : e}`;
|
|
67
|
+
Object.assign(m, { svg: "image/svg+xml", ico: "image/x-icon", avif: "image/avif" });
|
|
68
|
+
for (const e of VIDEO_EXT)
|
|
69
|
+
m[e] = `video/${e === "mov" ? "quicktime" : e === "mkv" ? "x-matroska" : e}`;
|
|
70
|
+
for (const e of AUDIO_EXT)
|
|
71
|
+
m[e] = `audio/${e}`;
|
|
72
|
+
Object.assign(m, { m4a: "audio/mp4", mp3: "audio/mpeg" });
|
|
73
|
+
return m;
|
|
74
|
+
})();
|
|
75
|
+
/** category → generic fallback when no specific extension match exists */
|
|
76
|
+
const KIND_MIME = {
|
|
77
|
+
image: "application/octet-stream",
|
|
78
|
+
video: "application/octet-stream",
|
|
79
|
+
audio: "application/octet-stream",
|
|
80
|
+
};
|
|
81
|
+
function mimeFor(rel, kind) {
|
|
82
|
+
const ext = rel.split(".").pop()?.toLowerCase() ?? "";
|
|
83
|
+
return MEDIA_MIME[ext] ?? KIND_MIME[kind];
|
|
84
|
+
}
|
|
48
85
|
export function buildApp(master) {
|
|
49
86
|
const app = new Hono();
|
|
50
87
|
// Optional bearer auth for LAN exposure — TEAPOT_API_TOKEN env wins, else
|
|
@@ -328,6 +365,44 @@ export function buildApp(master) {
|
|
|
328
365
|
path: rel,
|
|
329
366
|
truncated: buf.length > 100_000,
|
|
330
367
|
content: buf.subarray(0, 100_000).toString("utf8"),
|
|
368
|
+
media: mediaKind(rel),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
return c.json({ error: err.message }, 400);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
// raw bytes for media previews (images / video / audio) — auth-gated like
|
|
376
|
+
// every other agent route, workspace-confined, served with the right MIME
|
|
377
|
+
app.get("/api/agents/:id/raw", async (c) => {
|
|
378
|
+
const a = master.agents.get(c.req.param("id"));
|
|
379
|
+
if (!a)
|
|
380
|
+
return c.json({ error: "not found" }, 404);
|
|
381
|
+
const rel = c.req.query("path") ?? "";
|
|
382
|
+
if (!rel.trim())
|
|
383
|
+
return c.json({ error: "path required" }, 400);
|
|
384
|
+
let abs;
|
|
385
|
+
try {
|
|
386
|
+
abs = safeJoin(a.workspace, rel);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
return c.json({ error: err.message }, 400);
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const buf = await fs.readFile(abs);
|
|
393
|
+
const kind = mediaKind(rel);
|
|
394
|
+
if (!kind)
|
|
395
|
+
return c.json({ error: "not a previewable media file" }, 400);
|
|
396
|
+
if (buf.length > 200 * 1024 * 1024)
|
|
397
|
+
return c.json({ error: "file too large to preview (>200MB)" }, 413);
|
|
398
|
+
// hono-native body API — a raw `new Response(...)` returned here lost
|
|
399
|
+
// its headers somewhere in the middleware chain (content-type arrived
|
|
400
|
+
// as undefined), while c.body() passes them through correctly
|
|
401
|
+
return c.body(new Uint8Array(buf), 200, {
|
|
402
|
+
"content-type": mimeFor(rel, kind),
|
|
403
|
+
// previews only; the browser shouldn't cache stale workspace files
|
|
404
|
+
"cache-control": "no-store",
|
|
405
|
+
"content-disposition": `inline; filename="${encodeURIComponent(path.basename(rel))}"`,
|
|
331
406
|
});
|
|
332
407
|
}
|
|
333
408
|
catch (err) {
|
|
@@ -781,7 +856,8 @@ export function buildApp(master) {
|
|
|
781
856
|
const a = master.agents.get(c.req.param("id"));
|
|
782
857
|
if (!a)
|
|
783
858
|
return c.json({ error: "not found" }, 404);
|
|
784
|
-
|
|
859
|
+
// cached like /events — this used to bypass it and re-read the whole log
|
|
860
|
+
const events = await readEventsCached(a.log.filePath);
|
|
785
861
|
const branches = new Map();
|
|
786
862
|
for (const e of events) {
|
|
787
863
|
const b = branches.get(e.branch) ?? { branch: e.branch, events: 0 };
|