privateer-agent 0.12.37 → 0.12.39
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/extensions/privateer-gate.ts +52 -12
- package/package.json +1 -1
- package/src/permissions/classify.ts +13 -4
- package/src/tools/media.ts +117 -42
|
@@ -25,6 +25,7 @@ import { makeSaveCargoTool } from "../src/tools/cargo.ts";
|
|
|
25
25
|
import { makeChartTools } from "../src/tools/charts.ts";
|
|
26
26
|
import { makeSaveAttachmentTool } from "../src/tools/saveAttachment.ts";
|
|
27
27
|
import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentStore.ts";
|
|
28
|
+
import { resolveMentions, searchFiles } from "../src/util/fileMentions.ts";
|
|
28
29
|
import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
|
|
29
30
|
import { makeSkillsControl } from "../src/remote/skillsControl.ts";
|
|
30
31
|
import { agentDir } from "../src/config/paths.ts";
|
|
@@ -48,7 +49,9 @@ const allowedOutsideRoots: string[] = [];
|
|
|
48
49
|
// SECOND prompt arriving while Pi is still processing — which throws "Agent is already
|
|
49
50
|
// processing" and wedges the session. This happens in normal use when the app drops
|
|
50
51
|
// (backgrounded → socket suspended) and re-sends its prompt on reconnect. Mirrors the
|
|
51
|
-
// REPL's `turnActive` guard.
|
|
52
|
+
// REPL's `turnActive` guard. Claimed as soon as a prompt is accepted — ahead of the
|
|
53
|
+
// mention expansion, which reads files and so opens a window a second prompt could
|
|
54
|
+
// slip through — released again if the send fails, cleared on agent_end.
|
|
52
55
|
let remoteTurnActive = false;
|
|
53
56
|
|
|
54
57
|
let piRef: any = null;
|
|
@@ -149,7 +152,7 @@ async function switchModelRemote(spec: string): Promise<void> {
|
|
|
149
152
|
const ok = await piRef?.setModel?.(model);
|
|
150
153
|
if (ok === false) { relay?.sendNotice(`No API key for ${p} — can't switch to ${sp}.`); return; }
|
|
151
154
|
currentSpec = sp;
|
|
152
|
-
relay?.sendContext({ model: currentSpec, version: agentVersion() }); // banner follows
|
|
155
|
+
relay?.sendContext({ model: currentSpec, cwd: process.cwd(), version: agentVersion() }); // banner follows
|
|
153
156
|
relay?.sendNotice(`model → ${sp}`);
|
|
154
157
|
} catch (e) {
|
|
155
158
|
relay?.sendNotice(`Couldn't switch model: ${(e as Error).message}`);
|
|
@@ -344,6 +347,12 @@ const bridge = new RemoteBridge({
|
|
|
344
347
|
relay?.sendNotice("busy — a turn is already running; wait for it to finish.");
|
|
345
348
|
return;
|
|
346
349
|
}
|
|
350
|
+
// Claim the turn BEFORE the awaits below, not after the send. Expanding mentions
|
|
351
|
+
// reads files, so the send is no longer synchronous with this callback — a second
|
|
352
|
+
// prompt arriving in that window would pass the guard above and land Pi with two
|
|
353
|
+
// turns. Released again on any failure path, so a refused send can't wedge the
|
|
354
|
+
// bridge; the success path leaves it set until agent_end.
|
|
355
|
+
remoteTurnActive = true;
|
|
347
356
|
// Fold any files the app sent since the last prompt into a reference note so the
|
|
348
357
|
// model knows they exist and can save_attachment them.
|
|
349
358
|
const atts = sinceLastPrompt;
|
|
@@ -352,14 +361,32 @@ const bridge = new RemoteBridge({
|
|
|
352
361
|
? `\n\n[Files attached from the app: ${atts.map((a) => `#${a.n} ${a.name} (${a.mediaType})`).join(", ")}. ` +
|
|
353
362
|
`Use the save_attachment tool with the ref number to write one to disk.]`
|
|
354
363
|
: "";
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
364
|
+
void (async () => {
|
|
365
|
+
try {
|
|
366
|
+
// Expand any `@path` mentions into appended <file> blocks + image attachments,
|
|
367
|
+
// resolved against this terminal's cwd (constrained to the cwd subtree). The
|
|
368
|
+
// REPL (src/cli/chat.ts) and the desktop session (agentSession.ts) both do this;
|
|
369
|
+
// the shipped TUI did not, so the one surface with no Tab key — a phone driving
|
|
370
|
+
// this terminal — was the only one where `@file` did nothing at all.
|
|
371
|
+
const cwd = process.cwd();
|
|
372
|
+
const mentions = await resolveMentions(text, cwd);
|
|
373
|
+
if (mentions.skipped.length) {
|
|
374
|
+
relay?.sendNotice(`Couldn't attach: ${mentions.skipped.join(", ")} (must be a file inside ${cwd})`);
|
|
375
|
+
}
|
|
376
|
+
const body = mentions.text + note;
|
|
377
|
+
// Images ride as content parts — pi's sendUserMessage takes the same
|
|
378
|
+
// {type:"image",data,mimeType} shape resolveMentions already emits, so a
|
|
379
|
+
// mentioned screenshot reaches the model as a real attachment, not a path.
|
|
380
|
+
piRef?.sendUserMessage?.(
|
|
381
|
+
mentions.images.length ? [{ type: "text", text: body }, ...mentions.images] : body,
|
|
382
|
+
); // drive a turn in Pi's TUI
|
|
383
|
+
} catch (e) {
|
|
384
|
+
// An "already processing" (or any send/expansion failure) must not wedge the
|
|
385
|
+
// bridge — surface it and stay idle so the next prompt still works.
|
|
386
|
+
remoteTurnActive = false;
|
|
387
|
+
relay?.sendNotice(`couldn't start turn: ${(e as Error).message}`);
|
|
388
|
+
}
|
|
389
|
+
})();
|
|
363
390
|
},
|
|
364
391
|
onInterrupt: () => {}, // Pi owns interrupt; best-effort no-op
|
|
365
392
|
// The app asked to end remote access from its side — stop the relay locally too so
|
|
@@ -383,10 +410,23 @@ const bridge = new RemoteBridge({
|
|
|
383
410
|
// terminal, and advertise the slash commands for the composer's autocomplete.
|
|
384
411
|
setRemoteState("connected");
|
|
385
412
|
relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]);
|
|
386
|
-
|
|
413
|
+
// cwd rides along here (home-collapsed on the way out, see RelayClient.sendContext).
|
|
414
|
+
// Without it the app's composer shows no working-directory strip at all — and that
|
|
415
|
+
// strip is the only place a driver can see which folder the prompts they type are
|
|
416
|
+
// reading, writing and `@`-mentioning against.
|
|
417
|
+
relay?.sendContext({ model: currentSpec, cwd: process.cwd(), version: agentVersion() });
|
|
387
418
|
relay?.sendCommands(advertiseCommands());
|
|
388
419
|
},
|
|
389
420
|
onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
|
|
421
|
+
// The app composer is autocompleting an `@file` mention — list the cwd entries
|
|
422
|
+
// matching the query and reply. Read-only + cwd-constrained (searchFiles never
|
|
423
|
+
// escapes the subtree); resolution of the picked path happens in onPrompt above.
|
|
424
|
+
// Unanswered, the app's searchFiles() times out to [] after 4s and the palette
|
|
425
|
+
// reads "no files" — indistinguishable from an empty project.
|
|
426
|
+
onFilesSearch: (id, query) => void (async () => {
|
|
427
|
+
try { bridge.sendFileMatches(id, await searchFiles(query, process.cwd())); }
|
|
428
|
+
catch { bridge.sendFileMatches(id, []); }
|
|
429
|
+
})(),
|
|
390
430
|
// Drive the indicator from the relay's own status stream: "connected" → green;
|
|
391
431
|
// its reconnect/retry notices → yellow "connecting…". Ignored once we're off.
|
|
392
432
|
onStatus: (text) => {
|
|
@@ -528,7 +568,7 @@ export default function privateerControl(pi: any): void {
|
|
|
528
568
|
pi.on("model_select", (ev: any) => {
|
|
529
569
|
if (ev?.model) {
|
|
530
570
|
currentSpec = modelSpec(ev.model);
|
|
531
|
-
relay?.sendContext({ model: currentSpec, version: agentVersion() });
|
|
571
|
+
relay?.sendContext({ model: currentSpec, cwd: process.cwd(), version: agentVersion() });
|
|
532
572
|
}
|
|
533
573
|
});
|
|
534
574
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.39",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -225,6 +225,9 @@ const MEDIA_TITLES: Record<string, string> = {
|
|
|
225
225
|
video_compose: "Compose video/audio locally",
|
|
226
226
|
media_capabilities: "Read media capabilities",
|
|
227
227
|
};
|
|
228
|
+
// Not in MEDIA_TITLES: it is the same tool, told apart by its arguments rather
|
|
229
|
+
// than its name (see `resuming` below).
|
|
230
|
+
const RESUME_VIDEO_TITLE = "Save a video already generated (nothing further is billed)";
|
|
228
231
|
|
|
229
232
|
export function classifyToolCall(
|
|
230
233
|
toolName: string,
|
|
@@ -478,6 +481,14 @@ export function classifyToolCall(
|
|
|
478
481
|
// even when the output lands neatly in cwd.
|
|
479
482
|
if (MEDIA_TOOLS.has(name)) {
|
|
480
483
|
const compose = name === "video_compose";
|
|
484
|
+
// A generate_video RESUME submits nothing and bills nothing — it goes back to
|
|
485
|
+
// waiting on a job the account has already paid for and writes the file. So it
|
|
486
|
+
// is an ordinary write, not a billed one: the title must not claim a charge
|
|
487
|
+
// that isn't happening, and `alwaysAsk` must not make re-prompting the cheaper
|
|
488
|
+
// path than re-generating. Getting that backwards is what teaches a model to
|
|
489
|
+
// pay twice.
|
|
490
|
+
const resuming = name === "generate_video" && typeof obj.resumeJobId === "string" && !!obj.resumeJobId.trim();
|
|
491
|
+
const mediaTitle = resuming ? RESUME_VIDEO_TITLE : MEDIA_TITLES[name];
|
|
481
492
|
const inputs = [
|
|
482
493
|
...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
|
|
483
494
|
str(obj.input),
|
|
@@ -549,13 +560,11 @@ export function classifyToolCall(
|
|
|
549
560
|
return {
|
|
550
561
|
tool: toolName,
|
|
551
562
|
kind: "write",
|
|
552
|
-
title: outside
|
|
553
|
-
? `${MEDIA_TITLES[name]} outside working directory`
|
|
554
|
-
: MEDIA_TITLES[name],
|
|
563
|
+
title: outside ? `${mediaTitle} outside working directory` : mediaTitle,
|
|
555
564
|
detail: `${outputOutside ? absOut : outPath}${inputNote}`,
|
|
556
565
|
protected: isProtectedPath(absOut) || protectedInputs.length > 0,
|
|
557
566
|
outside,
|
|
558
|
-
alwaysAsk: BILLED_MEDIA_TOOLS.has(name),
|
|
567
|
+
alwaysAsk: BILLED_MEDIA_TOOLS.has(name) && !resuming,
|
|
559
568
|
path: absOut,
|
|
560
569
|
};
|
|
561
570
|
}
|
package/src/tools/media.ts
CHANGED
|
@@ -56,8 +56,23 @@ export const MEDIA_TOOL_NAMES = [
|
|
|
56
56
|
|
|
57
57
|
// A video job can legitimately take minutes. Bound the wait so a wedged provider
|
|
58
58
|
// doesn't pin an unattended run forever; the job id is reported on timeout so the
|
|
59
|
-
// caller can resume the poll rather than pay for another generation.
|
|
60
|
-
|
|
59
|
+
// caller can resume the poll (`resumeJobId`) rather than pay for another generation.
|
|
60
|
+
//
|
|
61
|
+
// TWENTY-FIVE, AND THE RESUME PARAM, ARE ONE FIX. At twelve this was the only
|
|
62
|
+
// surface that gave up on a job at all — the app's own poller (ChatScreen,
|
|
63
|
+
// libraryService) runs on a bare setInterval with no deadline, so the identical
|
|
64
|
+
// job that lands fine in a chat was abandoned in a terminal — and there was
|
|
65
|
+
// nothing to resume WITH: `generate_video` took no job id, so the sentence above
|
|
66
|
+
// described a recovery the tool could not perform. The model's only move was to
|
|
67
|
+
// call generate_video again, which bills a second generation and starts a second
|
|
68
|
+
// wait, which is what "video generation hangs" looks like from the outside.
|
|
69
|
+
//
|
|
70
|
+
// The ceiling stays because an unattended run must not be pinned forever, and 25
|
|
71
|
+
// is not arbitrary: the desktop's turn supervisor abandons a turn whose open tool
|
|
72
|
+
// has been silent for 30 minutes (desktop/src/main/turnSupervisor.ts toolStallMs),
|
|
73
|
+
// and a tool that outlives its own supervisor is abandoned mid-poll with the job
|
|
74
|
+
// uncollected — exactly the failure this is fixing. Keep it under that budget.
|
|
75
|
+
const VIDEO_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_VIDEO_TIMEOUT_MS) || 25 * 60_000;
|
|
61
76
|
const VIDEO_POLL_INTERVAL_MS = 5_000;
|
|
62
77
|
// A mesh job runs about a minute at the provider's stated typical time, and
|
|
63
78
|
// several for a large face count. Same bounded-wait contract as video: the job
|
|
@@ -334,10 +349,18 @@ export const generateVideoToolDefinition = {
|
|
|
334
349
|
"extracted with video_compose, then start the next from it. Generation takes minutes and this tool " +
|
|
335
350
|
"waits for it. Expensive (roughly $0.10-$1 a clip) and billed to the user's Privateer account, so " +
|
|
336
351
|
"plan the shot before calling. Clip lengths and aspect ratios are model-specific — check " +
|
|
337
|
-
"media_capabilities first if unsure. Stitch the finished clips with video_compose
|
|
352
|
+
"media_capabilities first if unsure. Stitch the finished clips with video_compose.\n" +
|
|
353
|
+
"If a call comes back saying the job is still running, DO NOT call this again with the same " +
|
|
354
|
+
"prompt — that bills a second generation. Call it with `resumeJobId` set to the job id it " +
|
|
355
|
+
"reported (and the same `path`) to keep waiting on the clip the account has already paid for.",
|
|
338
356
|
parameters: Type.Object({
|
|
339
357
|
prompt: Type.String({ description: "What happens in the shot: subject, action, camera move, style." }),
|
|
340
358
|
path: Type.String({ description: "Where to write the video, relative to cwd or absolute (e.g. 'clips/01-opening.mp4')." }),
|
|
359
|
+
resumeJobId: Type.Optional(Type.String({
|
|
360
|
+
description:
|
|
361
|
+
"Resume waiting on a job already submitted (from a previous call that timed out). " +
|
|
362
|
+
"Nothing is generated and nothing is billed: it only polls and saves. `prompt` is ignored.",
|
|
363
|
+
})),
|
|
341
364
|
firstFrame: Type.Optional(Type.String({ description: "Path to an image to use as the opening frame (image-to-video)." })),
|
|
342
365
|
lastFrame: Type.Optional(Type.String({ description: "Path to an image to use as the closing frame. Requires firstFrame." })),
|
|
343
366
|
seconds: Type.Optional(Type.Number({ description: "Clip length in seconds. Only certain values are legal per model — see media_capabilities." })),
|
|
@@ -349,7 +372,7 @@ export const generateVideoToolDefinition = {
|
|
|
349
372
|
async execute(
|
|
350
373
|
_toolCallId: string,
|
|
351
374
|
params: {
|
|
352
|
-
prompt: string; path: string; firstFrame?: string; lastFrame?: string;
|
|
375
|
+
prompt: string; path: string; resumeJobId?: string; firstFrame?: string; lastFrame?: string;
|
|
353
376
|
seconds?: number; aspectRatio?: string; resolution?: string; audio?: boolean; model?: string;
|
|
354
377
|
},
|
|
355
378
|
signal?: AbortSignal,
|
|
@@ -357,9 +380,18 @@ export const generateVideoToolDefinition = {
|
|
|
357
380
|
ctx?: { cwd?: string },
|
|
358
381
|
) {
|
|
359
382
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
383
|
+
if (!params.path) return text("Error: path is required — say where to save the video.");
|
|
384
|
+
|
|
385
|
+
// RESUME. Submit nothing, bill nothing — just go back to waiting on a job the
|
|
386
|
+
// account has already paid for. Everything below the poll is identical, which
|
|
387
|
+
// is why the loop lives in awaitVideoJob() rather than being duplicated here.
|
|
388
|
+
const resumeJobId = String(params.resumeJobId ?? "").trim();
|
|
389
|
+
if (resumeJobId) {
|
|
390
|
+
return awaitVideoJob(resumeJobId, cwd, params.path, null, signal);
|
|
391
|
+
}
|
|
392
|
+
|
|
360
393
|
const prompt = String(params.prompt ?? "").trim();
|
|
361
394
|
if (!prompt) return text("Error: prompt is required.");
|
|
362
|
-
if (!params.path) return text("Error: path is required — say where to save the video.");
|
|
363
395
|
if (params.lastFrame && !params.firstFrame) return text("Error: lastFrame needs firstFrame alongside it.");
|
|
364
396
|
|
|
365
397
|
let firstFrame: { data: string; mimeType: string } | undefined;
|
|
@@ -389,45 +421,72 @@ export const generateVideoToolDefinition = {
|
|
|
389
421
|
const jobId = submitted.data.jobId;
|
|
390
422
|
if (!jobId) return text("Video generation failed: Privateer did not return a job id.");
|
|
391
423
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const cancelled = () =>
|
|
396
|
-
text(`Video job ${jobId} was submitted but the wait was cancelled. It is still running and will still be billed.`);
|
|
397
|
-
for (;;) {
|
|
398
|
-
if (signal?.aborted) return cancelled();
|
|
399
|
-
await sleep(VIDEO_POLL_INTERVAL_MS, signal);
|
|
400
|
-
// sleep() resolves early on abort, so re-check before spending a request on a
|
|
401
|
-
// signal that is already dead — otherwise the cancel surfaces as a network error.
|
|
402
|
-
if (signal?.aborted) return cancelled();
|
|
403
|
-
const poll = await callAccount<VideoStatusResponse>(`/api/agent/media/videos/${encodeURIComponent(jobId)}`, {
|
|
404
|
-
method: "GET",
|
|
405
|
-
signal,
|
|
406
|
-
});
|
|
407
|
-
if (!poll.ok) return text(`Video job ${jobId} could not be polled: ${poll.message}`);
|
|
424
|
+
return awaitVideoJob(jobId, cwd, params.path, submitted.data.model ?? null, signal);
|
|
425
|
+
},
|
|
426
|
+
};
|
|
408
427
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
428
|
+
/**
|
|
429
|
+
* Wait on a submitted video job and write its bytes to `path`.
|
|
430
|
+
*
|
|
431
|
+
* Split out of execute() so the RESUME path is the same code rather than a second
|
|
432
|
+
* copy of it: the bytes are delivered exactly once and are stored nowhere, so a
|
|
433
|
+
* resume that polled differently from the original wait would be the one place a
|
|
434
|
+
* paid clip could be dropped.
|
|
435
|
+
*
|
|
436
|
+
* `submittedModel` is null on a resume — we did not submit, so we have no model
|
|
437
|
+
* name of our own. The poll reports one anyway; the fallback only covers a server
|
|
438
|
+
* that returns neither.
|
|
439
|
+
*/
|
|
440
|
+
async function awaitVideoJob(
|
|
441
|
+
jobId: string,
|
|
442
|
+
cwd: string,
|
|
443
|
+
path: string,
|
|
444
|
+
submittedModel: string | null,
|
|
445
|
+
signal?: AbortSignal,
|
|
446
|
+
) {
|
|
447
|
+
// The account is charged when the provider delivers, so an abandoned poll still
|
|
448
|
+
// costs money — every exit below names the job id, and `resumeJobId` is what
|
|
449
|
+
// turns that id back into the file.
|
|
450
|
+
const deadline = Date.now() + VIDEO_POLL_TIMEOUT_MS;
|
|
451
|
+
const resumeHint = `Resume it with generate_video { resumeJobId: "${jobId}", path: "${path}" } — that waits on this same clip and bills nothing further.`;
|
|
452
|
+
const cancelled = () =>
|
|
453
|
+
text(`Video job ${jobId} is still running and will still be billed; the wait was cancelled. ${resumeHint}`);
|
|
454
|
+
for (;;) {
|
|
455
|
+
if (signal?.aborted) return cancelled();
|
|
456
|
+
await sleep(VIDEO_POLL_INTERVAL_MS, signal);
|
|
457
|
+
// sleep() resolves early on abort, so re-check before spending a request on a
|
|
458
|
+
// signal that is already dead — otherwise the cancel surfaces as a network error.
|
|
459
|
+
if (signal?.aborted) return cancelled();
|
|
460
|
+
const poll = await callAccount<VideoStatusResponse>(`/api/agent/media/videos/${encodeURIComponent(jobId)}`, {
|
|
461
|
+
method: "GET",
|
|
462
|
+
signal,
|
|
463
|
+
});
|
|
464
|
+
// A poll that fails is NOT the job failing — the clip is still coming and is
|
|
465
|
+
// still billed, so this has to point at the resume too. Without that the model
|
|
466
|
+
// reads a dropped request as a dead job and generates the whole thing again.
|
|
467
|
+
if (!poll.ok) return text(`Video job ${jobId} could not be polled: ${poll.message}. ${resumeHint}`);
|
|
468
|
+
|
|
469
|
+
const status = String(poll.data.status ?? "").toLowerCase();
|
|
470
|
+
if (status === "failed") return text(`Video generation failed: ${poll.data.message ?? "the provider reported a failure"}.`);
|
|
471
|
+
if (status === "completed") {
|
|
472
|
+
if (!poll.data.data) {
|
|
473
|
+
// The bytes were handed out on an earlier poll and are not stored anywhere.
|
|
474
|
+
return text(`Video job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the file is missing.`);
|
|
427
475
|
}
|
|
476
|
+
const target = abs(cwd, path);
|
|
477
|
+
const ext = extname(target) || extForMime(poll.data.mimeType ?? "", ".mp4");
|
|
478
|
+
const out = `${target.slice(0, target.length - extname(target).length)}${ext}`;
|
|
479
|
+
const summary = writeOut(out, Buffer.from(poll.data.data, "base64"));
|
|
480
|
+
return text(`Generated video with ${poll.data.model ?? submittedModel ?? "the account video model"}: ${summary}`);
|
|
428
481
|
}
|
|
429
|
-
|
|
430
|
-
|
|
482
|
+
if (Date.now() > deadline) {
|
|
483
|
+
return text(
|
|
484
|
+
`Video job ${jobId} is still ${status || "running"} after ${Math.round(VIDEO_POLL_TIMEOUT_MS / 60000)} minutes. ` +
|
|
485
|
+
`It will still complete and is already billed — do NOT generate it again. ${resumeHint}`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
431
490
|
|
|
432
491
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
433
492
|
return new Promise((resolve_) => {
|
|
@@ -1143,6 +1202,9 @@ export const generateSpriteToolDefinition = {
|
|
|
1143
1202
|
"right-facing ones rather than rendered, which is why eight animations cost five clips and not " +
|
|
1144
1203
|
"eight. Each clip is charged at the account's video rate, so an eight-way set is genuinely " +
|
|
1145
1204
|
"expensive; say the total to the user before batching characters.\n" +
|
|
1205
|
+
"TWO models are involved, which matters when one of them is down: an IMAGE model turns the " +
|
|
1206
|
+
"picture to face each direction (only for `directions` 'four' and 'eight'), then a VIDEO model " +
|
|
1207
|
+
"renders the motion per facing. `image_model` and `model` override them separately.\n" +
|
|
1146
1208
|
"It takes several minutes (the clips render sequentially) and this tool waits. Frame count, cell " +
|
|
1147
1209
|
"size and frame rate are chosen here and cost nothing extra. AVAILABILITY: this needs a video " +
|
|
1148
1210
|
"decoder on the Privateer API and some deployments do not have one — call media_capabilities and " +
|
|
@@ -1218,13 +1280,25 @@ export const generateSpriteToolDefinition = {
|
|
|
1218
1280
|
model: Type.Optional(
|
|
1219
1281
|
Type.String({ description: "Video model id to render the motion with. Omit for the account default." }),
|
|
1220
1282
|
),
|
|
1283
|
+
image_model: Type.Optional(
|
|
1284
|
+
Type.String({
|
|
1285
|
+
description:
|
|
1286
|
+
"Image model id used to TURN the picture to face each direction, e.g. " +
|
|
1287
|
+
"'google/gemini-3.1-flash-image'. Omit for the account default. Only used when " +
|
|
1288
|
+
"`directions` is 'four' or 'eight' — 'one' renders no turns and never touches an image " +
|
|
1289
|
+
"model. Worth setting when a run fails with SPRITE_IMAGE_MODEL_UNAVAILABLE: that is the " +
|
|
1290
|
+
"image provider being down, not the request being wrong, and naming another model here " +
|
|
1291
|
+
"retries the whole run for nothing (the turns are drawn before any clip is billed). " +
|
|
1292
|
+
"media_capabilities lists the account's current image model.",
|
|
1293
|
+
}),
|
|
1294
|
+
),
|
|
1221
1295
|
}),
|
|
1222
1296
|
async execute(
|
|
1223
1297
|
_toolCallId: string,
|
|
1224
1298
|
params: {
|
|
1225
1299
|
image: string; prompt: string; dir: string; action?: string; directions?: string;
|
|
1226
1300
|
frames?: number; frame_size?: number; fps?: number; loop?: boolean;
|
|
1227
|
-
name?: string; res_path?: string; model?: string;
|
|
1301
|
+
name?: string; res_path?: string; model?: string; image_model?: string;
|
|
1228
1302
|
},
|
|
1229
1303
|
signal?: AbortSignal,
|
|
1230
1304
|
_onUpdate?: unknown,
|
|
@@ -1264,6 +1338,7 @@ export const generateSpriteToolDefinition = {
|
|
|
1264
1338
|
return guessed ? { res_path: guessed } : {};
|
|
1265
1339
|
})()),
|
|
1266
1340
|
...(params.model ? { model: params.model } : {}),
|
|
1341
|
+
...(params.image_model ? { image_model: params.image_model } : {}),
|
|
1267
1342
|
},
|
|
1268
1343
|
});
|
|
1269
1344
|
if (!submitted.ok) return text(`Sprite generation failed: ${submitted.message}`);
|