privateer-agent 0.15.3 → 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/extensions/privateer-gate.ts +61 -2
- package/package.json +1 -1
- package/src/config/whatsNew.json +3 -3
- package/src/permissions/classify.ts +11 -4
- package/src/tools/media.ts +291 -49
|
@@ -63,6 +63,41 @@ function piBusy(): boolean {
|
|
|
63
63
|
try { return ctxRef?.isIdle?.() === false; } catch { return false; }
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Messages the app sent mid-turn that Pi has queued as steers but the model has not
|
|
67
|
+
// read yet. Pi only reads a steer between steps, and a turn that ends on an error or
|
|
68
|
+
// a Stop leaves it in the queue — the app showed "Added to the running turn" and then
|
|
69
|
+
// nothing, so a driver had no way to tell a read message from a lost one. Each entry
|
|
70
|
+
// is cleared (and the app told) when Pi puts it in the transcript.
|
|
71
|
+
let unreadSteers: string[] = [];
|
|
72
|
+
let stopRequested = false;
|
|
73
|
+
const userText = (m: any): string =>
|
|
74
|
+
typeof m?.content === "string"
|
|
75
|
+
? m.content
|
|
76
|
+
: Array.isArray(m?.content) ? m.content.filter((p: any) => p?.type === "text").map((p: any) => p.text).join("\n") : "";
|
|
77
|
+
|
|
78
|
+
// After a run ends, wait for Pi to settle (it may start another run to drain the
|
|
79
|
+
// queue itself), then say so if an app message is still sitting unread.
|
|
80
|
+
function reportStrandedSteers(): void {
|
|
81
|
+
const stopped = stopRequested;
|
|
82
|
+
stopRequested = false;
|
|
83
|
+
if (!unreadSteers.length) return;
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
const check = () => {
|
|
86
|
+
if (!unreadSteers.length) return;
|
|
87
|
+
if (piBusy()) {
|
|
88
|
+
if (Date.now() - startedAt < 10_000) setTimeout(check, 250).unref?.();
|
|
89
|
+
return; // still running: the next step or agent_end reads/reports it
|
|
90
|
+
}
|
|
91
|
+
const n = unreadSteers.length;
|
|
92
|
+
const what = n === 1 ? "Your last message" : `Your last ${n} messages`;
|
|
93
|
+
relay?.sendNotice(
|
|
94
|
+
`${what} arrived after the agent's final step${stopped ? " (you stopped the turn)" : ""}, so it wasn't read. ` +
|
|
95
|
+
"It will go to the agent with your next prompt.",
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
setTimeout(check, 250).unref?.();
|
|
99
|
+
}
|
|
100
|
+
|
|
66
101
|
let piRef: any = null;
|
|
67
102
|
let relay: any = null;
|
|
68
103
|
// Pi-extension manager for the app's extensions screen. Built lazily on first use
|
|
@@ -381,7 +416,10 @@ const bridge = new RemoteBridge({
|
|
|
381
416
|
relay?.sendNotice(`Couldn't attach: ${mentions.skipped.join(", ")} (must be a file inside ${cwd})`);
|
|
382
417
|
}
|
|
383
418
|
const body = mentions.text + note;
|
|
384
|
-
if (piBusy())
|
|
419
|
+
if (piBusy()) {
|
|
420
|
+
unreadSteers.push(body);
|
|
421
|
+
relay?.sendNotice("Added to the running turn — the agent reads it before its next step.");
|
|
422
|
+
}
|
|
385
423
|
// A message sent mid-turn STEERS it, as in the REPL and the desktop session.
|
|
386
424
|
// This used to be refused with "busy — a turn is already running" behind a flag
|
|
387
425
|
// that only agent_end cleared — but the extension API's sendUserMessage is
|
|
@@ -402,7 +440,17 @@ const bridge = new RemoteBridge({
|
|
|
402
440
|
}
|
|
403
441
|
});
|
|
404
442
|
},
|
|
405
|
-
|
|
443
|
+
// The app's Stop. This was a no-op, so Stop only closed the turn in the app while the
|
|
444
|
+
// agent kept working — and the next message from the app landed as a steer on a turn
|
|
445
|
+
// the driver believed was over.
|
|
446
|
+
onInterrupt: () => {
|
|
447
|
+
try {
|
|
448
|
+
if (ctxRef?.isIdle?.() === false) {
|
|
449
|
+
stopRequested = true;
|
|
450
|
+
ctxRef.abort();
|
|
451
|
+
}
|
|
452
|
+
} catch { /* a replaced session's context throws — nothing to stop */ }
|
|
453
|
+
},
|
|
406
454
|
// The app asked to end remote access from its side — stop the relay locally too so
|
|
407
455
|
// the terminal doesn't keep reconnecting, and clear the green indicator.
|
|
408
456
|
onTerminate: () => disableRemote(),
|
|
@@ -667,6 +715,8 @@ export default function privateerControl(pi: any): void {
|
|
|
667
715
|
// remote access was already on when the session (re)started.
|
|
668
716
|
if (ctx?.ui) uiRef = ctx.ui;
|
|
669
717
|
ctxRef = ctx;
|
|
718
|
+
unreadSteers = []; // a new session has a new queue
|
|
719
|
+
stopRequested = false;
|
|
670
720
|
// Capture the model registry + launch model so the app's /model picker has this
|
|
671
721
|
// machine's real catalog and the banner shows the current spec from the start.
|
|
672
722
|
if (ctx?.modelRegistry) modelReg = ctx.modelRegistry;
|
|
@@ -796,6 +846,14 @@ export default function privateerControl(pi: any): void {
|
|
|
796
846
|
});
|
|
797
847
|
pi.on("message_start", (ev: any) => {
|
|
798
848
|
if (headlessRun && ev?.message?.role === "assistant") watchdog.arm();
|
|
849
|
+
// A queued app message entering the transcript — the model reads it this step.
|
|
850
|
+
// Match by text; an input handler (the privacy filter) may have rewritten it, in
|
|
851
|
+
// which case the oldest unread one is the one Pi just delivered (FIFO).
|
|
852
|
+
if (ev?.message?.role === "user" && unreadSteers.length) {
|
|
853
|
+
const i = unreadSteers.indexOf(userText(ev.message));
|
|
854
|
+
unreadSteers.splice(i >= 0 ? i : 0, 1);
|
|
855
|
+
relay?.sendNotice("The agent read your message.");
|
|
856
|
+
}
|
|
799
857
|
});
|
|
800
858
|
pi.on("message_update", () => {
|
|
801
859
|
if (headlessRun) watchdog.arm();
|
|
@@ -840,6 +898,7 @@ export default function privateerControl(pi: any): void {
|
|
|
840
898
|
pi.on("agent_end", (ev: any) => {
|
|
841
899
|
fwd(ev);
|
|
842
900
|
bridge.settleTurn();
|
|
901
|
+
reportStrandedSteers();
|
|
843
902
|
});
|
|
844
903
|
|
|
845
904
|
pi.registerCommand?.("mode", {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
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",
|
package/src/config/whatsNew.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
|
|
3
|
-
"reviewedFor": "0.15.
|
|
3
|
+
"reviewedFor": "0.15.4",
|
|
4
4
|
"items": [
|
|
5
|
+
{ "text": "Slow images, speech and music run as background jobs — a dropped connection no longer loses a paid result", "since": "0.15.4" },
|
|
5
6
|
{ "text": "Driving from the app: a message sent mid-turn steers it, and questions survive a dropped link", "since": "0.15.1" },
|
|
6
7
|
{ "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" },
|
|
7
|
-
{ "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" }
|
|
8
|
-
{ "text": "API keys can expire and carry their own credit limit — set them in the app", "since": "0.14.6" }
|
|
8
|
+
{ "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" }
|
|
9
9
|
]
|
|
10
10
|
}
|
|
@@ -234,6 +234,14 @@ const MEDIA_TITLES: Record<string, string> = {
|
|
|
234
234
|
// rather than their names (see `resuming` below).
|
|
235
235
|
const RESUME_VIDEO_TITLE = "Save a video already generated (nothing further is billed)";
|
|
236
236
|
const RESUME_SPRITE_TITLE = "Save a sprite animation already generated (nothing further is billed)";
|
|
237
|
+
const RESUME_TITLES: Record<string, string> = {
|
|
238
|
+
generate_video: RESUME_VIDEO_TITLE,
|
|
239
|
+
generate_sprite: RESUME_SPRITE_TITLE,
|
|
240
|
+
generate_image: "Save an image already generated (nothing further is billed)",
|
|
241
|
+
generate_speech: "Save speech already generated (nothing further is billed)",
|
|
242
|
+
generate_music: "Save music already generated (nothing further is billed)",
|
|
243
|
+
generate_sfx: "Save a sound effect already generated (nothing further is billed)",
|
|
244
|
+
};
|
|
237
245
|
|
|
238
246
|
// Pi's MCP resource tools (core/mcp-servers.js). They list and read resources from
|
|
239
247
|
// every connected server — network reads from connectors the user configured.
|
|
@@ -533,11 +541,10 @@ export function classifyToolCall(
|
|
|
533
541
|
// cheaper path than re-generating. Getting that backwards is what teaches a model
|
|
534
542
|
// to pay twice — and it costs the most on a sprite, where the alternative to a
|
|
535
543
|
// resume is a whole second fan-out of up to five video generations.
|
|
536
|
-
|
|
544
|
+
// Image, speech, music and sfx became deferred jobs too, so the same holds for them.
|
|
545
|
+
const resumable = RESUME_TITLES[name] !== undefined;
|
|
537
546
|
const resuming = resumable && typeof obj.resumeJobId === "string" && !!obj.resumeJobId.trim();
|
|
538
|
-
const mediaTitle = resuming
|
|
539
|
-
? (name === "generate_sprite" ? RESUME_SPRITE_TITLE : RESUME_VIDEO_TITLE)
|
|
540
|
-
: MEDIA_TITLES[name];
|
|
547
|
+
const mediaTitle = resuming ? RESUME_TITLES[name] : MEDIA_TITLES[name];
|
|
541
548
|
const inputs = [
|
|
542
549
|
...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
|
|
543
550
|
str(obj.input),
|
package/src/tools/media.ts
CHANGED
|
@@ -38,11 +38,14 @@
|
|
|
38
38
|
// stitch → score → send.
|
|
39
39
|
|
|
40
40
|
import { Type } from "typebox";
|
|
41
|
-
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSync } from "node:fs";
|
|
41
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
42
|
+
import { createDecipheriv, randomBytes } from "node:crypto";
|
|
42
43
|
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
43
44
|
import { tmpdir } from "node:os";
|
|
45
|
+
import { Agent } from "undici";
|
|
44
46
|
import { apiRequest } from "../auth/privateer.ts";
|
|
45
47
|
import { effectiveMediaModel } from "../config/mediaModels.ts";
|
|
48
|
+
import { globalDir } from "../config/paths.ts";
|
|
46
49
|
|
|
47
50
|
/** Tool names these definitions register, for allow-list construction. */
|
|
48
51
|
export const MEDIA_TOOL_NAMES = [
|
|
@@ -167,6 +170,51 @@ interface AccountFailure {
|
|
|
167
170
|
status?: number;
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
// Pi installs a process-wide undici dispatcher whose headersTimeout/bodyTimeout is
|
|
174
|
+
// its HTTP idle timeout — 5 minutes by default. That is right for a model stream and
|
|
175
|
+
// wrong here: an image, speech or music POST answers only once the provider has
|
|
176
|
+
// finished, and a slow model (gpt-image with input images, a long track) can take
|
|
177
|
+
// longer than that to send its first byte. undici then aborts with a HeadersTimeout
|
|
178
|
+
// that fetch flattens to the bare "fetch failed", and an agent driving Privateer
|
|
179
|
+
// (Claude Code, Hermes, OpenClaw…) saw a 5-6 minute hang followed by a message that
|
|
180
|
+
// gave it nothing to act on — while the generation it had paid for finished anyway.
|
|
181
|
+
// Media calls get their own dispatcher with a ceiling sized to the slowest
|
|
182
|
+
// synchronous generation; the tool's AbortSignal still cancels it sooner.
|
|
183
|
+
export const MEDIA_HTTP_TIMEOUT_MS = 20 * 60_000;
|
|
184
|
+
let _mediaDispatcher: Agent | null = null;
|
|
185
|
+
function mediaDispatcher(): Agent {
|
|
186
|
+
return (_mediaDispatcher ??= new Agent({
|
|
187
|
+
headersTimeout: MEDIA_HTTP_TIMEOUT_MS,
|
|
188
|
+
bodyTimeout: MEDIA_HTTP_TIMEOUT_MS,
|
|
189
|
+
connect: { autoSelectFamilyAttemptTimeout: 2_000 },
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Say WHY a request got no HTTP response. fetch reports every transport failure as
|
|
195
|
+
* "fetch failed" and keeps the real reason on `cause` (an undici error with a `code`,
|
|
196
|
+
* or a Node errno); printing only the message threw that away, so a caller could not
|
|
197
|
+
* tell a timeout from a dead network from DNS. The timeout case also warns about
|
|
198
|
+
* billing: the server keeps working after we stop listening, so blindly re-sending
|
|
199
|
+
* the same prompt can pay for the same generation twice.
|
|
200
|
+
*/
|
|
201
|
+
export function describeTransportFailure(e: unknown): string {
|
|
202
|
+
if (e instanceof Error && e.name === "AbortError") return "the request was cancelled";
|
|
203
|
+
const top = e instanceof Error ? e.message : String(e);
|
|
204
|
+
const cause = (e as { cause?: unknown })?.cause;
|
|
205
|
+
const code = String((cause as { code?: unknown })?.code ?? (e as { code?: unknown })?.code ?? "");
|
|
206
|
+
const causeMsg = cause instanceof Error ? cause.message : "";
|
|
207
|
+
if (/^UND_ERR_(HEADERS|BODY)_TIMEOUT$/.test(code)) {
|
|
208
|
+
return (
|
|
209
|
+
`Privateer did not answer within ${Math.round(MEDIA_HTTP_TIMEOUT_MS / 60_000)} minutes (${code}). ` +
|
|
210
|
+
"The generation may still have finished and been billed on the server, so check the output path " +
|
|
211
|
+
"before retrying, and retry once rather than in a loop"
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const detail = [code, causeMsg].filter(Boolean).join(": ");
|
|
215
|
+
return `could not reach Privateer: ${top}${detail && !top.includes(detail) ? ` (${detail})` : ""}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
170
218
|
/**
|
|
171
219
|
* Call the account API and return the parsed payload, or a message written for the
|
|
172
220
|
* model to read. Errors are surfaced rather than swallowed — an agent that quietly
|
|
@@ -175,11 +223,14 @@ interface AccountFailure {
|
|
|
175
223
|
*/
|
|
176
224
|
async function callAccount<T>(
|
|
177
225
|
path: string,
|
|
178
|
-
init: { method: "GET" | "POST"; body?: unknown; signal?: AbortSignal },
|
|
226
|
+
init: { method: "GET" | "POST" | "DELETE"; body?: unknown; signal?: AbortSignal },
|
|
179
227
|
): Promise<({ ok: true } & { data: T }) | AccountFailure> {
|
|
180
228
|
let res: Response;
|
|
181
229
|
try {
|
|
182
230
|
res = await apiRequest(path, {
|
|
231
|
+
// npm undici's Agent vs the undici-types Dispatcher in RequestInit: same runtime
|
|
232
|
+
// contract (verified against both Node's bundled fetch and Pi's undici 8).
|
|
233
|
+
...({ dispatcher: mediaDispatcher() } as object),
|
|
183
234
|
method: init.method,
|
|
184
235
|
...(init.body === undefined
|
|
185
236
|
? {}
|
|
@@ -187,9 +238,18 @@ async function callAccount<T>(
|
|
|
187
238
|
...(init.signal ? { signal: init.signal } : {}),
|
|
188
239
|
});
|
|
189
240
|
} catch (e) {
|
|
190
|
-
return { ok: false, message:
|
|
241
|
+
return { ok: false, message: describeTransportFailure(e) };
|
|
191
242
|
}
|
|
243
|
+
return interpretAccountResponse<T>(res);
|
|
244
|
+
}
|
|
192
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Turn an account-API response into the payload or a message for the model. Split
|
|
248
|
+
* out of callAccount because a deferred job's result arrives SEALED inside a poll,
|
|
249
|
+
* and once opened it must be judged exactly as the same response would have been
|
|
250
|
+
* had it come back directly — same ZDR wording, same 402, same 503 advice.
|
|
251
|
+
*/
|
|
252
|
+
async function interpretAccountResponse<T>(res: Response): Promise<({ ok: true } & { data: T }) | AccountFailure> {
|
|
193
253
|
if (res.ok || res.status === 202) {
|
|
194
254
|
try {
|
|
195
255
|
return { ok: true, data: (await res.json()) as T };
|
|
@@ -261,6 +321,191 @@ async function callAccount<T>(
|
|
|
261
321
|
return { ok: false, status: res.status, message: serverMessage || `media generation failed (HTTP ${res.status}${code ? ` ${code}` : ""})` };
|
|
262
322
|
}
|
|
263
323
|
|
|
324
|
+
// ── Deferred jobs (image, speech, music, sfx) ────────────────────────────────
|
|
325
|
+
//
|
|
326
|
+
// These four used to be ONE blocking POST that answered only when the provider had
|
|
327
|
+
// finished. A slow generation outlived the idle timeouts between us and the server,
|
|
328
|
+
// the connection died, and the result — already billed — was gone. Now each is
|
|
329
|
+
// submit → job id → poll, like video: POST /api/agent/media/jobs runs the original
|
|
330
|
+
// route server-side and parks the response SEALED under a key only this machine
|
|
331
|
+
// holds (`sealKey`, sent with the submit and dropped by the server). The key is
|
|
332
|
+
// kept on disk beside the job id, so a later process — the same agent after a
|
|
333
|
+
// timeout, or another session entirely — can collect it with `resumeJobId`.
|
|
334
|
+
//
|
|
335
|
+
// A server older than the jobs route answers 404, and the call falls back to the
|
|
336
|
+
// old blocking request, so an agent released before the server deploys still works.
|
|
337
|
+
|
|
338
|
+
export type DeferredKind = "image" | "speech" | "music" | "sfx";
|
|
339
|
+
const DEFERRED_SYNC_PATH: Record<DeferredKind, string> = {
|
|
340
|
+
image: "/api/agent/media/images",
|
|
341
|
+
speech: "/api/audio/speech",
|
|
342
|
+
music: "/api/audio/music",
|
|
343
|
+
sfx: "/api/audio/sfx",
|
|
344
|
+
};
|
|
345
|
+
const DEFERRED_TOOL: Record<DeferredKind, string> = {
|
|
346
|
+
image: "generate_image",
|
|
347
|
+
speech: "generate_speech",
|
|
348
|
+
music: "generate_music",
|
|
349
|
+
sfx: "generate_sfx",
|
|
350
|
+
};
|
|
351
|
+
const DEFERRED_POLL_MS = 3_000;
|
|
352
|
+
// The server holds a result for an hour; a key older than that opens nothing.
|
|
353
|
+
const DEFERRED_KEY_TTL_MS = 2 * 60 * 60_000;
|
|
354
|
+
|
|
355
|
+
function deferredKeyDir(): string {
|
|
356
|
+
return join(globalDir(), "media-jobs");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function saveDeferredKey(jobId: string, kind: DeferredKind, key: Buffer): void {
|
|
360
|
+
const dir = deferredKeyDir();
|
|
361
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
362
|
+
// Prune expired keys while we are here — nothing else ever reads them.
|
|
363
|
+
try {
|
|
364
|
+
for (const f of readdirSync(dir)) {
|
|
365
|
+
const p = join(dir, f);
|
|
366
|
+
try {
|
|
367
|
+
const { createdAt } = JSON.parse(readFileSync(p, "utf8")) as { createdAt?: number };
|
|
368
|
+
if (!createdAt || Date.now() - createdAt > DEFERRED_KEY_TTL_MS) unlinkSync(p);
|
|
369
|
+
} catch { /* unreadable — leave it */ }
|
|
370
|
+
}
|
|
371
|
+
} catch { /* best effort */ }
|
|
372
|
+
writeFileSync(join(dir, `${jobId}.json`), JSON.stringify({ kind, key: key.toString("base64"), createdAt: Date.now() }), { mode: 0o600 });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function loadDeferredKey(jobId: string): { kind: DeferredKind; key: Buffer } | null {
|
|
376
|
+
if (!/^amj_[0-9a-f]+$/.test(jobId)) return null;
|
|
377
|
+
try {
|
|
378
|
+
const raw = JSON.parse(readFileSync(join(deferredKeyDir(), `${jobId}.json`), "utf8")) as { kind: DeferredKind; key: string };
|
|
379
|
+
return { kind: raw.kind, key: Buffer.from(raw.key, "base64") };
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function forgetDeferredKey(jobId: string): void {
|
|
386
|
+
try { unlinkSync(join(deferredKeyDir(), `${jobId}.json`)); } catch { /* already gone */ }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Open the server's iv(12) | tag(16) | ciphertext. Throws on a wrong key or tampering. */
|
|
390
|
+
export function openSealedResult(key: Buffer, sealedB64: string): { status: number; body: string } {
|
|
391
|
+
const buf = Buffer.from(sealedB64, "base64");
|
|
392
|
+
const d = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12));
|
|
393
|
+
d.setAuthTag(buf.subarray(12, 28));
|
|
394
|
+
return JSON.parse(Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString("utf8"));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function deferredResumeHint(kind: DeferredKind, jobId: string): string {
|
|
398
|
+
return `Collect it with ${DEFERRED_TOOL[kind]} { resumeJobId: "${jobId}", path: <same path> } — that waits on this same job and bills nothing further.`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
interface DeferredStatus {
|
|
402
|
+
status?: string;
|
|
403
|
+
kind?: string;
|
|
404
|
+
httpStatus?: number;
|
|
405
|
+
sealed?: string;
|
|
406
|
+
message?: string;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
type DeferredResult<T> = ({ ok: true; data: T }) | AccountFailure | { ok: "started"; jobId: string };
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Run a generation as a deferred job and return what the blocking call would have.
|
|
413
|
+
* `resumeJobId` skips the submit and only waits; `wait: false` returns as soon as
|
|
414
|
+
* the job is accepted, so a caller can start several and collect them later.
|
|
415
|
+
*/
|
|
416
|
+
async function callDeferred<T>(
|
|
417
|
+
kind: DeferredKind,
|
|
418
|
+
body: unknown,
|
|
419
|
+
opts: { signal?: AbortSignal; resumeJobId?: string; wait?: boolean },
|
|
420
|
+
): Promise<DeferredResult<T>> {
|
|
421
|
+
let jobId = String(opts.resumeJobId ?? "").trim();
|
|
422
|
+
let key: Buffer;
|
|
423
|
+
if (jobId) {
|
|
424
|
+
const held = loadDeferredKey(jobId);
|
|
425
|
+
if (!held) {
|
|
426
|
+
return { ok: false, message: `no key for job ${jobId} on this machine — it was started elsewhere, already collected, or is over two hours old. Its result cannot be opened here.` };
|
|
427
|
+
}
|
|
428
|
+
if (held.kind !== kind) {
|
|
429
|
+
return { ok: false, message: `job ${jobId} is a ${held.kind} job; collect it with ${DEFERRED_TOOL[held.kind]}.` };
|
|
430
|
+
}
|
|
431
|
+
key = held.key;
|
|
432
|
+
} else {
|
|
433
|
+
key = randomBytes(32);
|
|
434
|
+
const submitted = await callAccount<{ jobId?: string }>("/api/agent/media/jobs", {
|
|
435
|
+
method: "POST",
|
|
436
|
+
signal: opts.signal,
|
|
437
|
+
body: { kind, sealKey: key.toString("base64"), request: body },
|
|
438
|
+
});
|
|
439
|
+
if (!submitted.ok) {
|
|
440
|
+
// A server from before the jobs route: do it the old, blocking way.
|
|
441
|
+
if (submitted.status === 404 || submitted.status === 405) {
|
|
442
|
+
return callAccount<T>(DEFERRED_SYNC_PATH[kind], { method: "POST", body, signal: opts.signal });
|
|
443
|
+
}
|
|
444
|
+
return submitted;
|
|
445
|
+
}
|
|
446
|
+
jobId = String(submitted.data.jobId ?? "");
|
|
447
|
+
if (!jobId) return { ok: false, message: "Privateer accepted the job but returned no job id" };
|
|
448
|
+
saveDeferredKey(jobId, kind, key);
|
|
449
|
+
if (opts.wait === false) return { ok: "started", jobId };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const hint = deferredResumeHint(kind, jobId);
|
|
453
|
+
const deadline = Date.now() + MEDIA_HTTP_TIMEOUT_MS;
|
|
454
|
+
let failures = 0;
|
|
455
|
+
while (Date.now() < deadline) {
|
|
456
|
+
if (opts.signal?.aborted) {
|
|
457
|
+
return { ok: false, message: `stopped waiting — job ${jobId} keeps running on the server. ${hint}` };
|
|
458
|
+
}
|
|
459
|
+
const poll = await callAccount<DeferredStatus>(`/api/agent/media/jobs/${encodeURIComponent(jobId)}`, { method: "GET", signal: opts.signal });
|
|
460
|
+
if (!poll.ok) {
|
|
461
|
+
if (spritePollWorthRetrying(poll.status) && ++failures < SPRITE_POLL_FAILURE_BUDGET) {
|
|
462
|
+
await sleep(DEFERRED_POLL_MS * 2, opts.signal);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (poll.status === 404) forgetDeferredKey(jobId);
|
|
466
|
+
return { ok: false, status: poll.status, message: `job ${jobId} could not be collected: ${poll.message}${poll.status === 404 ? "" : `. ${hint}`}` };
|
|
467
|
+
}
|
|
468
|
+
failures = 0;
|
|
469
|
+
const status = String(poll.data.status ?? "");
|
|
470
|
+
if (status === "failed") {
|
|
471
|
+
forgetDeferredKey(jobId);
|
|
472
|
+
return { ok: false, message: poll.data.message ?? `job ${jobId} failed on the server` };
|
|
473
|
+
}
|
|
474
|
+
if (status === "done" && poll.data.sealed) {
|
|
475
|
+
let opened: { status: number; body: string };
|
|
476
|
+
try {
|
|
477
|
+
opened = openSealedResult(key, poll.data.sealed);
|
|
478
|
+
} catch {
|
|
479
|
+
return { ok: false, message: `job ${jobId}'s result could not be opened with this machine's key` };
|
|
480
|
+
}
|
|
481
|
+
forgetDeferredKey(jobId);
|
|
482
|
+
// Ack so the server drops the ciphertext now rather than at the TTL.
|
|
483
|
+
void callAccount(`/api/agent/media/jobs/${encodeURIComponent(jobId)}`, { method: "DELETE" }).catch(() => {});
|
|
484
|
+
return interpretAccountResponse<T>(new Response(opened.body, { status: opened.status, headers: { "Content-Type": "application/json" } }));
|
|
485
|
+
}
|
|
486
|
+
await sleep(DEFERRED_POLL_MS, opts.signal);
|
|
487
|
+
}
|
|
488
|
+
return { ok: false, message: `job ${jobId} is still running after ${Math.round(MEDIA_HTTP_TIMEOUT_MS / 60_000)} minutes. ${hint}` };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Shared parameters every deferred tool takes. */
|
|
492
|
+
const deferredParams = {
|
|
493
|
+
resumeJobId: Type.Optional(Type.String({
|
|
494
|
+
description:
|
|
495
|
+
"Collect a job started earlier (with wait:false, or one that timed out) instead of starting a new one. " +
|
|
496
|
+
"Nothing is generated and nothing is billed: it only waits and saves. Other generation fields are ignored; `path` still says where to save.",
|
|
497
|
+
})),
|
|
498
|
+
wait: Type.Optional(Type.Boolean({
|
|
499
|
+
description:
|
|
500
|
+
"Default true. Set false to start the generation and return its job id immediately, so you can start " +
|
|
501
|
+
"several at once and collect each later with resumeJobId. The job runs on Privateer's servers either way.",
|
|
502
|
+
})),
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
function startedText(kind: DeferredKind, jobId: string): string {
|
|
506
|
+
return `Started ${kind} job ${jobId}; it is generating on Privateer's servers (results are held for up to an hour, sealed to this machine). ${deferredResumeHint(kind, jobId)}`;
|
|
507
|
+
}
|
|
508
|
+
|
|
264
509
|
// ── Images ───────────────────────────────────────────────────────────────────
|
|
265
510
|
|
|
266
511
|
interface ImageResponse {
|
|
@@ -276,7 +521,7 @@ export const generateImageToolDefinition = {
|
|
|
276
521
|
"images already on disk — to EDIT or COMPOSE instead: one input means 'change this picture per the " +
|
|
277
522
|
"prompt', several means 'combine these into one'. Billed to the user's Privateer account and " +
|
|
278
523
|
"subject to its privacy settings; the prompt and any input images pass through Privateer's servers " +
|
|
279
|
-
"in plaintext,
|
|
524
|
+
"in plaintext. The finished result is held on the server for up to an hour, encrypted to a key only this machine holds, until it is collected — the only readable copy is the file you name. Use the resulting " +
|
|
280
525
|
"path as the first frame of generate_video, or as slideshow material for video_compose.",
|
|
281
526
|
parameters: Type.Object({
|
|
282
527
|
prompt: Type.String({ description: "What the image should show. Be specific about subject, style, lighting and framing." }),
|
|
@@ -292,40 +537,39 @@ export const generateImageToolDefinition = {
|
|
|
292
537
|
aspectRatio: Type.Optional(Type.String({ description: "Aspect ratio, e.g. '16:9', '9:16', '1:1'. Defaults to the model's own." })),
|
|
293
538
|
size: Type.Optional(Type.String({ description: "Explicit pixel size if the model supports one, e.g. '1024x1024'." })),
|
|
294
539
|
model: Type.Optional(Type.String({ description: "Override the image model (e.g. 'google/gemini-3.1-flash-image'). Leave unset to use the one the user chose with /image-model, or the account default." })),
|
|
540
|
+
...deferredParams,
|
|
295
541
|
}),
|
|
296
542
|
async execute(
|
|
297
543
|
_toolCallId: string,
|
|
298
|
-
params: { prompt: string; path: string; images?: string[]; count?: number; aspectRatio?: string; size?: string; model?: string },
|
|
544
|
+
params: { prompt: string; path: string; images?: string[]; count?: number; aspectRatio?: string; size?: string; model?: string; resumeJobId?: string; wait?: boolean },
|
|
299
545
|
signal?: AbortSignal,
|
|
300
546
|
_onUpdate?: unknown,
|
|
301
547
|
ctx?: { cwd?: string },
|
|
302
548
|
) {
|
|
303
549
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
304
550
|
const prompt = String(params.prompt ?? "").trim();
|
|
305
|
-
|
|
551
|
+
const resuming = Boolean(String(params.resumeJobId ?? "").trim());
|
|
552
|
+
if (!prompt && !resuming) return text("Error: prompt is required.");
|
|
306
553
|
if (!params.path) return text("Error: path is required — say where to save the image.");
|
|
307
554
|
|
|
308
555
|
let inputs: { data: string; mimeType: string }[];
|
|
309
556
|
try {
|
|
310
|
-
inputs = (params.images ?? []).map((p) => readInputImage(cwd, p));
|
|
557
|
+
inputs = resuming ? [] : (params.images ?? []).map((p) => readInputImage(cwd, p));
|
|
311
558
|
} catch (e) {
|
|
312
559
|
return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
|
|
313
560
|
}
|
|
314
561
|
|
|
315
562
|
const count = Math.min(Math.max(1, Math.round(params.count ?? 1)), 4);
|
|
316
563
|
const model = effectiveMediaModel("image", params.model);
|
|
317
|
-
const r = await
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
...(model ? { model } : {}),
|
|
327
|
-
},
|
|
328
|
-
});
|
|
564
|
+
const r = await callDeferred<ImageResponse>("image", {
|
|
565
|
+
prompt,
|
|
566
|
+
n: count,
|
|
567
|
+
...(inputs.length ? { images: inputs } : {}),
|
|
568
|
+
...(params.aspectRatio ? { aspectRatio: params.aspectRatio } : {}),
|
|
569
|
+
...(params.size ? { imageSize: params.size } : {}),
|
|
570
|
+
...(model ? { model } : {}),
|
|
571
|
+
}, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
|
|
572
|
+
if (r.ok === "started") return text(startedText("image", r.jobId));
|
|
329
573
|
if (!r.ok) return text(`Image generation failed: ${r.message}`);
|
|
330
574
|
|
|
331
575
|
const images = r.data.images ?? [];
|
|
@@ -344,7 +588,7 @@ export const generateImageToolDefinition = {
|
|
|
344
588
|
}
|
|
345
589
|
if (written.length === 0) return text("Image generation returned no usable image data.");
|
|
346
590
|
|
|
347
|
-
const verb = inputs.length ? (inputs.length === 1 ? "Edited" : "Composed") : "Generated";
|
|
591
|
+
const verb = resuming ? "Collected" : inputs.length ? (inputs.length === 1 ? "Edited" : "Composed") : "Generated";
|
|
348
592
|
return text(`${verb} ${written.length} image${written.length === 1 ? "" : "s"} with ${r.data.model ?? "the account image model"}:\n${written.map((w) => ` ${w}`).join("\n")}`);
|
|
349
593
|
},
|
|
350
594
|
};
|
|
@@ -782,28 +1026,26 @@ export const generateSpeechToolDefinition = {
|
|
|
782
1026
|
"refused with VOICE_UNSUPPORTED rather than silently served on a different voice.",
|
|
783
1027
|
})),
|
|
784
1028
|
model: Type.Optional(Type.String({ description: "Override the account's text-to-speech model." })),
|
|
1029
|
+
...deferredParams,
|
|
785
1030
|
}),
|
|
786
1031
|
async execute(
|
|
787
1032
|
_toolCallId: string,
|
|
788
|
-
params: { text: string; path: string; voice?: string; model?: string },
|
|
1033
|
+
params: { text: string; path: string; voice?: string; model?: string; resumeJobId?: string; wait?: boolean },
|
|
789
1034
|
signal?: AbortSignal,
|
|
790
1035
|
_onUpdate?: unknown,
|
|
791
1036
|
ctx?: { cwd?: string },
|
|
792
1037
|
) {
|
|
793
1038
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
794
1039
|
const body = String(params.text ?? "").trim();
|
|
795
|
-
if (!body) return text("Error: text is required.");
|
|
1040
|
+
if (!body && !String(params.resumeJobId ?? "").trim()) return text("Error: text is required.");
|
|
796
1041
|
if (!params.path) return text("Error: path is required — say where to save the audio.");
|
|
797
1042
|
|
|
798
|
-
const r = await
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
...(params.model ? { ttsModelId: params.model } : {}),
|
|
805
|
-
},
|
|
806
|
-
});
|
|
1043
|
+
const r = await callDeferred<AudioResponse>("speech", {
|
|
1044
|
+
text: body,
|
|
1045
|
+
...(params.voice ? { voice: params.voice } : {}),
|
|
1046
|
+
...(params.model ? { ttsModelId: params.model } : {}),
|
|
1047
|
+
}, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
|
|
1048
|
+
if (r.ok === "started") return text(startedText("speech", r.jobId));
|
|
807
1049
|
if (!r.ok) return text(`Speech generation failed: ${r.message}`);
|
|
808
1050
|
if (!r.data.audioBase64) return text("Speech generation returned no audio.");
|
|
809
1051
|
|
|
@@ -834,24 +1076,26 @@ export const generateMusicToolDefinition = {
|
|
|
834
1076
|
prompt: Type.String({ description: "The music to generate: genre, mood, instrumentation, tempo. Keep it about the music, not about the user." }),
|
|
835
1077
|
path: Type.String({ description: "Where to write the audio, relative to cwd or absolute (e.g. 'audio/score.mp3')." }),
|
|
836
1078
|
model: Type.Optional(Type.String({ description: "Override the account's music model." })),
|
|
1079
|
+
...deferredParams,
|
|
837
1080
|
}),
|
|
838
1081
|
async execute(
|
|
839
1082
|
_toolCallId: string,
|
|
840
|
-
params: { prompt: string; path: string; model?: string },
|
|
1083
|
+
params: { prompt: string; path: string; model?: string; resumeJobId?: string; wait?: boolean },
|
|
841
1084
|
signal?: AbortSignal,
|
|
842
1085
|
_onUpdate?: unknown,
|
|
843
1086
|
ctx?: { cwd?: string },
|
|
844
1087
|
) {
|
|
845
1088
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
846
1089
|
const prompt = String(params.prompt ?? "").trim();
|
|
847
|
-
if (!prompt) return text("Error: prompt is required.");
|
|
1090
|
+
if (!prompt && !String(params.resumeJobId ?? "").trim()) return text("Error: prompt is required.");
|
|
848
1091
|
if (!params.path) return text("Error: path is required — say where to save the audio.");
|
|
849
1092
|
|
|
850
|
-
const r = await
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1093
|
+
const r = await callDeferred<AudioResponse>(
|
|
1094
|
+
"music",
|
|
1095
|
+
{ prompt, ...(params.model ? { musicModelId: params.model } : {}) },
|
|
1096
|
+
{ signal, resumeJobId: params.resumeJobId, wait: params.wait },
|
|
1097
|
+
);
|
|
1098
|
+
if (r.ok === "started") return text(startedText("music", r.jobId));
|
|
855
1099
|
if (!r.ok) return text(`Music generation failed: ${r.message}`);
|
|
856
1100
|
if (!r.data.audioBase64) return text("Music generation returned no audio.");
|
|
857
1101
|
|
|
@@ -907,17 +1151,18 @@ export const generateSfxToolDefinition = {
|
|
|
907
1151
|
"foley in the catalog; the default is a cheaper model tuned for single sounds.",
|
|
908
1152
|
}),
|
|
909
1153
|
),
|
|
1154
|
+
...deferredParams,
|
|
910
1155
|
}),
|
|
911
1156
|
async execute(
|
|
912
1157
|
_toolCallId: string,
|
|
913
|
-
params: { prompt: string; path: string; seconds?: number; model?: string },
|
|
1158
|
+
params: { prompt: string; path: string; seconds?: number; model?: string; resumeJobId?: string; wait?: boolean },
|
|
914
1159
|
signal?: AbortSignal,
|
|
915
1160
|
_onUpdate?: unknown,
|
|
916
1161
|
ctx?: { cwd?: string },
|
|
917
1162
|
) {
|
|
918
1163
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
919
1164
|
const prompt = String(params.prompt ?? "").trim();
|
|
920
|
-
if (!prompt) return text("Error: prompt is required.");
|
|
1165
|
+
if (!prompt && !String(params.resumeJobId ?? "").trim()) return text("Error: prompt is required.");
|
|
921
1166
|
if (!params.path) return text("Error: path is required — say where to save the audio.");
|
|
922
1167
|
|
|
923
1168
|
let seconds: number | undefined;
|
|
@@ -935,15 +1180,12 @@ export const generateSfxToolDefinition = {
|
|
|
935
1180
|
seconds = Math.round(seconds);
|
|
936
1181
|
}
|
|
937
1182
|
|
|
938
|
-
const r = await
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
...(params.model ? { sfxModelId: params.model } : {}),
|
|
945
|
-
},
|
|
946
|
-
});
|
|
1183
|
+
const r = await callDeferred<AudioResponse>("sfx", {
|
|
1184
|
+
prompt,
|
|
1185
|
+
...(seconds != null ? { duration: seconds } : {}),
|
|
1186
|
+
...(params.model ? { sfxModelId: params.model } : {}),
|
|
1187
|
+
}, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
|
|
1188
|
+
if (r.ok === "started") return text(startedText("sfx", r.jobId));
|
|
947
1189
|
if (!r.ok) return text(`Sound-effect generation failed: ${r.message}`);
|
|
948
1190
|
if (!r.data.audioBase64) return text("Sound-effect generation returned no audio.");
|
|
949
1191
|
|