pi-agnes-tools 0.1.0 → 0.3.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.
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * pi-agnes-tools
3
3
  *
4
- * Exposes Agnes AI image/video generation as callable tools, so you don't
5
- * have to switch models just to generate media.
4
+ * The single Agnes AI plugin for pi: full model catalog (text, image, video)
5
+ * for both endpoints PLUS image/video generation as callable tools.
6
6
  *
7
- * • agnes_image — POST /v1/images/generations (agnes-image-2.1-flash etc.)
8
- * • agnes_video — POST /v1/videos + poll until done (agnes-video-2.5-flash etc.)
7
+ * Providers (`agnes` = international, `agnes-cn` = China): seed catalog +
8
+ * live /v1/models discovery + stream routing, so every model is selectable
9
+ * via /model and --model.
10
+ * Tools: `agnes_image` (default agnes-image-2.5-flash) and `agnes_video`
11
+ * (default agnes-video-2.5-flash) — no model switch needed.
9
12
  *
10
- * Auth: reuses AGNES_API_KEY / AGNES_CN_API_KEY (same env vars as pi-agnes).
13
+ * Auth: AGNES_API_KEY / AGNES_CN_API_KEY env, else /login-stored key.
11
14
  * Saves: .pi/generated-images/ and .pi/generated-videos/ (project-relative).
12
15
  */
13
16
  import { mkdir, writeFile } from "node:fs/promises";
@@ -16,22 +19,51 @@ import { join } from "node:path";
16
19
  import { homedir } from "node:os";
17
20
  import { pathToFileURL } from "node:url";
18
21
 
19
- // typebox: prefer the pi-bundled copy when available (compiled binary / SEA),
20
- // fall back to a bare import when running as plain ESM (dev / jiti).
22
+ // typebox is provided to extensions by pi itself (bundled/virtual module).
21
23
  import * as _typebox from "typebox";
22
24
  const Type = _typebox.Type;
23
25
 
26
+ // pi-ai stream helpers (same import pi-agnes uses; resolved via pi's loader).
27
+ import {
28
+ createAssistantMessageEventStream,
29
+ openAICompletionsApi,
30
+ } from "@earendil-works/pi-ai";
31
+
24
32
  // ---------------------------------------------------------------------------
25
33
  // Shared helpers
26
34
  // ---------------------------------------------------------------------------
27
35
 
28
36
  const ENDPOINTS = {
29
- agnes: { baseUrl: "https://apihub.agnes-ai.com/v1", apiKeyEnv: "AGNES_API_KEY", authKey: "agnes" },
30
- "agnes-cn": { baseUrl: "https://api.agnes-ai.cn/v1", apiKeyEnv: "AGNES_CN_API_KEY", authKey: "agnes-cn" },
37
+ agnes: { baseUrl: "https://apihub.agnes-ai.com/v1", apiKeyEnv: "AGNES_API_KEY" },
38
+ "agnes-cn": { baseUrl: "https://api.agnes-ai.cn/v1", apiKeyEnv: "AGNES_CN_API_KEY" },
31
39
  };
32
40
 
33
- const IMAGE_MODELS = new Set(["agnes-image-2.0-flash", "agnes-image-2.1-flash"]);
41
+ const IMAGE_MODELS = new Set(["agnes-image-2.0-flash", "agnes-image-2.1-flash", "agnes-image-2.5-flash"]);
34
42
  const VIDEO_MODELS = new Set(["agnes-video-v2.0", "agnes-video-2.5", "agnes-video-2.5-flash"]);
43
+ const DEFAULT_IMAGE_MODEL = "agnes-image-2.5-flash";
44
+ const DEFAULT_VIDEO_MODEL = "agnes-video-2.5-flash";
45
+
46
+ // Text/LLM models only — image/video generation goes through the tools +
47
+ // skill, not the /model selector.
48
+ const AGNES_SEED = [
49
+ "agnes-2.5-flash",
50
+ "agnes-2.5-pro",
51
+ "agnes-2.5-pro-alpha",
52
+ "agnes-2.0-flash",
53
+ "agnes-3.0-flash",
54
+ ];
55
+
56
+ function isTextModel(id) {
57
+ return !id.startsWith("agnes-image-") && !id.startsWith("agnes-video-");
58
+ }
59
+
60
+ function isImageModel(id) {
61
+ return IMAGE_MODELS.has(id) || id.startsWith("agnes-image-");
62
+ }
63
+
64
+ function isVideoModel(id) {
65
+ return VIDEO_MODELS.has(id) || id.startsWith("agnes-video-");
66
+ }
35
67
 
36
68
  function fileLink(p, label = p) {
37
69
  return "[" + label + "](" + pathToFileURL(p).href + ")";
@@ -42,19 +74,17 @@ function resolveApiKey(endpoint) {
42
74
  // 1) Environment variable (fastest, always works)
43
75
  const envKey = process.env[cfg.apiKeyEnv];
44
76
  if (envKey) return envKey;
45
- // 2) /login-stored key in pi's auth store
77
+ // 2) /login-stored key in pi's auth store (same key pi-agnes uses)
46
78
  try {
47
79
  const authPath = join(homedir(), ".pi", "agent", "auth.json");
48
80
  const auth = JSON.parse(readFileSync(authPath, "utf8"));
49
- // Primary key: 'agnes' for both endpoints (pi-agnes registers one key per
50
- // provider id, and the CN endpoint uses the same account/key in most setups).
51
- const entry = auth[cfg.authKey] || auth["agnes"];
81
+ const entry = auth[endpoint] || auth["agnes"];
52
82
  if (entry && entry.key) return entry.key;
53
83
  } catch {
54
84
  // ignore read/parse failures; fall through
55
85
  }
56
86
  throw new Error(
57
- "No API key found for " + cfg.apiKeyEnv + ". Set the " + cfg.apiKeyEnv + " environment variable, or run /login with the pi-agnes provider."
87
+ "No API key found for " + cfg.apiKeyEnv + ". Set the " + cfg.apiKeyEnv + " environment variable, or run /login with the Agnes provider."
58
88
  );
59
89
  }
60
90
 
@@ -65,77 +95,28 @@ function getEndpoint(endpoint) {
65
95
  }
66
96
 
67
97
  function checkImageModel(model) {
68
- if (!IMAGE_MODELS.has(model) && !model.startsWith("agnes-image-")) {
98
+ if (!isImageModel(model)) {
69
99
  throw new Error("Unknown Agnes image model: " + model + ". Known models: " + [...IMAGE_MODELS].join(", "));
70
100
  }
71
101
  }
72
102
 
73
103
  function checkVideoModel(model) {
74
- if (!VIDEO_MODELS.has(model) && !model.startsWith("agnes-video-")) {
104
+ if (!isVideoModel(model)) {
75
105
  throw new Error("Unknown Agnes video model: " + model + ". Known models: " + [...VIDEO_MODELS].join(", "));
76
106
  }
77
107
  }
78
108
 
79
109
  // ---------------------------------------------------------------------------
80
- // agnes_image tool
110
+ // Shared REST cores (used by both the tools and the standalone stream router)
81
111
  // ---------------------------------------------------------------------------
82
112
 
83
- const imageParams = Type.Object({
84
- prompt: { type: "string", description: "Text prompt describing the image to generate." },
85
- model: {
86
- type: "string",
87
- description: "Agnes image model id. One of: " + [...IMAGE_MODELS].join(", ") + ". Default: agnes-image-2.1-flash.",
88
- },
89
- endpoint: {
90
- type: "string",
91
- enum: ["agnes", "agnes-cn"],
92
- description: "Which Agnes endpoint to use. Default: agnes (apihub.agnes-ai.com).",
93
- },
94
- images: {
95
- type: "array",
96
- items: { type: "string" },
97
- description:
98
- "Optional list of base64-encoded image data URIs (data:<mime>;base64,<data>) to use as reference/conditioning images.",
99
- },
100
- response_format: {
101
- type: "string",
102
- description: "Response image format. Default: png.",
103
- },
104
- });
105
-
106
- async function executeImage(_toolCallId, params) {
107
- const prompt = params.prompt;
108
- const rawModel = params.model || "agnes-image-2.1-flash";
109
- const endpointId = params.endpoint || "agnes";
110
- const images = params.images || [];
111
- const response_format = params.response_format || "png";
112
-
113
- checkImageModel(rawModel);
114
- const { baseUrl, headers } = getEndpoint(endpointId);
115
-
116
- const body = { model: rawModel, prompt, response_format };
117
- if (images.length > 0) {
118
- body.image = images;
119
- }
120
-
121
- const response = await fetch(baseUrl + "/images/generations", {
122
- method: "POST",
123
- headers,
124
- body: JSON.stringify(body),
125
- });
126
- const payload = await response.json().catch(() => null);
127
- if (!response.ok) {
128
- throw new Error((payload && payload.error && payload.error.message) || "Agnes image API HTTP " + response.status);
129
- }
130
- const image = payload && payload.data && payload.data[0];
131
- if (!image) throw new Error("Agnes image API returned no image data");
132
-
113
+ async function saveImagePayload(image, modelId) {
133
114
  const directory = join(process.cwd(), ".pi", "generated-images");
134
115
  await mkdir(directory, { recursive: true });
135
116
  const mime = image.mime_type || "image/png";
136
117
  const ext =
137
118
  mime.includes("png") ? "png" : mime.includes("jpeg") ? "jpg" : mime.includes("webp") ? "webp" : mime.includes("gif") ? "gif" : "png";
138
- const filePath = join(directory, rawModel + "-" + Date.now() + "." + ext);
119
+ const filePath = join(directory, modelId + "-" + Date.now() + "." + ext);
139
120
  if (image.b64_json) {
140
121
  await writeFile(filePath, Buffer.from(image.b64_json, "base64"));
141
122
  } else if (image.url) {
@@ -145,40 +126,27 @@ async function executeImage(_toolCallId, params) {
145
126
  } else {
146
127
  throw new Error("Agnes image API returned no url or b64_json");
147
128
  }
148
-
149
- const text = image.url
150
- ? "![](" + image.url + ")\n\nSaved local copy: " + fileLink(filePath) + "\n\nImage URL may expire according to Agnes retention policy."
151
- : "Generated image saved to: " + fileLink(filePath);
152
-
153
- return {
154
- content: [{ type: "text", text }],
155
- details: { filePath, model: rawModel, endpoint: endpointId, remoteUrl: image.url || null },
156
- };
129
+ return { filePath, mimeType: mime, remoteUrl: image.url || null };
157
130
  }
158
131
 
159
- // ---------------------------------------------------------------------------
160
- // agnes_video tool
161
- // ---------------------------------------------------------------------------
162
-
163
- const videoParams = Type.Object({
164
- prompt: { type: "string", description: "Text prompt describing the video to generate." },
165
- model: {
166
- type: "string",
167
- description: "Agnes video model id. One of: " + [...VIDEO_MODELS].join(", ") + ". Default: agnes-video-2.5-flash.",
168
- },
169
- endpoint: {
170
- type: "string",
171
- enum: ["agnes", "agnes-cn"],
172
- description: "Which Agnes endpoint to use. Default: agnes (apihub.agnes-ai.com).",
173
- },
174
- images: {
175
- type: "array",
176
- items: { type: "string" },
177
- description: "Optional reference image(s) as base64 data URIs. 1 image = image-to-video; >1 = keyframes mode.",
178
- },
179
- num_frames: { type: "integer", description: "Number of frames. Default: 121." },
180
- frame_rate: { type: "integer", description: "Frames per second. Default: 24." },
181
- });
132
+ async function requestImage(baseUrl, apiKey, opts) {
133
+ const body = { model: opts.model, prompt: opts.prompt };
134
+ if (opts.response_format) body.response_format = opts.response_format;
135
+ if (opts.images && opts.images.length > 0) body.image = opts.images;
136
+ const response = await fetch(baseUrl + "/images/generations", {
137
+ method: "POST",
138
+ headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json" },
139
+ body: JSON.stringify(body),
140
+ signal: opts.signal,
141
+ });
142
+ const payload = await response.json().catch(() => null);
143
+ if (!response.ok) {
144
+ throw new Error((payload && payload.error && payload.error.message) || "Agnes image API HTTP " + response.status);
145
+ }
146
+ const image = payload && payload.data && payload.data[0];
147
+ if (!image) throw new Error("Agnes image API returned no image data");
148
+ return saveImagePayload(image, opts.model);
149
+ }
182
150
 
183
151
  async function pollVideo(baseUrl, videoId, apiKey, signal) {
184
152
  const deadline = Date.now() + 30 * 60 * 1000;
@@ -214,26 +182,23 @@ async function pollVideo(baseUrl, videoId, apiKey, signal) {
214
182
  throw new Error("Agnes video generation timed out after 30 minutes");
215
183
  }
216
184
 
217
- async function executeVideo(_toolCallId, params, signal) {
218
- const prompt = params.prompt;
219
- const rawModel = params.model || "agnes-video-2.5-flash";
220
- const endpointId = params.endpoint || "agnes";
221
- const images = params.images || [];
222
- const num_frames = params.num_frames || 121;
223
- const frame_rate = params.frame_rate || 24;
224
-
225
- checkVideoModel(rawModel);
226
- const { baseUrl, apiKey, headers } = getEndpoint(endpointId);
227
-
228
- const body = { model: rawModel, prompt, num_frames, frame_rate };
185
+ async function requestVideo(baseUrl, apiKey, opts) {
186
+ // NOTE: the API accepts different fields per model. The flash/2.5 routes
187
+ // reject `num_frames` / `frame_rate` as request fields; v2.0 accepts the
188
+ // request with defaults. So we only send them when the caller explicitly
189
+ // provides them — never as hard-coded defaults.
190
+ const body = { model: opts.model, prompt: opts.prompt };
191
+ if (opts.num_frames) body.num_frames = opts.num_frames;
192
+ if (opts.frame_rate) body.frame_rate = opts.frame_rate;
193
+ const images = opts.images || [];
229
194
  if (images.length === 1) body.image = images[0];
230
195
  if (images.length > 1) body.extra_body = { image: images, mode: "keyframes" };
231
196
 
232
197
  const response = await fetch(baseUrl + "/videos", {
233
198
  method: "POST",
234
- headers,
199
+ headers: { Authorization: "Bearer " + apiKey, "Content-Type": "application/json" },
235
200
  body: JSON.stringify(body),
236
- signal,
201
+ signal: opts.signal,
237
202
  });
238
203
  const task = await response.json().catch(() => null);
239
204
  if (!response.ok) {
@@ -242,36 +207,325 @@ async function executeVideo(_toolCallId, params, signal) {
242
207
  const videoId = (task && (task.video_id || task.id || task.task_id)) || null;
243
208
  if (!videoId) throw new Error("Agnes video API returned no video_id");
244
209
 
245
- const result = task.status === "completed" ? task : await pollVideo(baseUrl, videoId, apiKey, signal);
246
- const url = result && result.metadata && result.metadata.url;
247
- if (!url) throw new Error("Agnes video API returned no metadata.url");
210
+ const result = task.status === "completed" ? task : await pollVideo(baseUrl, videoId, apiKey, opts.signal);
211
+ // The completed payload carries the video URL at top-level `url` (v2.0 and
212
+ // friends). Some routes nest it under `metadata.url`, so check both.
213
+ const url = (result && (result.url || (result.metadata && result.metadata.url))) || null;
214
+ if (!url) throw new Error("Agnes video API returned no video url");
248
215
 
249
216
  const directory = join(process.cwd(), ".pi", "generated-videos");
250
217
  await mkdir(directory, { recursive: true });
251
- const filePath = join(directory, rawModel + "-" + Date.now() + ".mp4");
218
+ const filePath = join(directory, opts.model + "-" + Date.now() + ".mp4");
252
219
  const videoRes = await fetch(url);
253
220
  if (!videoRes.ok) throw new Error("Unable to download generated video: HTTP " + videoRes.status);
254
221
  await writeFile(filePath, Buffer.from(await videoRes.arrayBuffer()));
222
+ return { filePath, remoteUrl: url };
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // agnes_image / agnes_video tools (always registered — no provider conflict)
227
+ // ---------------------------------------------------------------------------
255
228
 
256
- const text = "Generated video saved to: " + fileLink(filePath) + "\n\nVideo URL: " + url;
229
+ const imageParams = Type.Object({
230
+ prompt: { type: "string", description: "Text prompt describing the image to generate." },
231
+ model: {
232
+ type: "string",
233
+ description: "Agnes image model id. One of: " + [...IMAGE_MODELS].join(", ") + ". Default: " + DEFAULT_IMAGE_MODEL + ".",
234
+ },
235
+ endpoint: {
236
+ type: "string",
237
+ enum: ["agnes", "agnes-cn"],
238
+ description: "Which Agnes endpoint to use: agnes (international, apihub.agnes-ai.com) or agnes-cn (China, api.agnes-ai.cn). Default: agnes.",
239
+ },
240
+ images: {
241
+ type: "array",
242
+ items: { type: "string" },
243
+ description:
244
+ "Optional list of base64-encoded image data URIs (data:<mime>;base64,<data>) to use as reference/conditioning images.",
245
+ },
246
+ response_format: {
247
+ type: "string",
248
+ description: "Response image format. Default: png.",
249
+ },
250
+ });
251
+
252
+ async function executeImage(_toolCallId, params) {
253
+ const prompt = params.prompt;
254
+ const rawModel = params.model || DEFAULT_IMAGE_MODEL;
255
+ const endpointId = params.endpoint || "agnes";
256
+ checkImageModel(rawModel);
257
+ const { baseUrl, apiKey } = getEndpoint(endpointId);
258
+
259
+ const saved = await requestImage(baseUrl, apiKey, {
260
+ model: rawModel,
261
+ prompt,
262
+ images: params.images || [],
263
+ response_format: params.response_format || "png",
264
+ });
265
+
266
+ const text = saved.remoteUrl
267
+ ? "![](" + saved.remoteUrl + ")\n\nSaved local copy: " + fileLink(saved.filePath) + "\n\nImage URL may expire according to Agnes retention policy."
268
+ : "Generated image saved to: " + fileLink(saved.filePath);
269
+
270
+ return {
271
+ content: [{ type: "text", text }],
272
+ details: { filePath: saved.filePath, model: rawModel, endpoint: endpointId, remoteUrl: saved.remoteUrl },
273
+ };
274
+ }
275
+
276
+ const videoParams = Type.Object({
277
+ prompt: { type: "string", description: "Text prompt describing the video to generate." },
278
+ model: {
279
+ type: "string",
280
+ description: "Agnes video model id. One of: " + [...VIDEO_MODELS].join(", ") + ". Default: " + DEFAULT_VIDEO_MODEL + ".",
281
+ },
282
+ endpoint: {
283
+ type: "string",
284
+ enum: ["agnes", "agnes-cn"],
285
+ description: "Which Agnes endpoint to use: agnes (international, apihub.agnes-ai.com) or agnes-cn (China, api.agnes-ai.cn). Default: agnes.",
286
+ },
287
+ images: {
288
+ type: "array",
289
+ items: { type: "string" },
290
+ description: "Optional reference image(s) as base64 data URIs. 1 image = image-to-video; >1 = keyframes mode.",
291
+ },
292
+ num_frames: { type: "integer", description: "Number of frames. Default: 121." },
293
+ frame_rate: { type: "integer", description: "Frames per second. Default: 24." },
294
+ });
295
+
296
+ async function executeVideo(_toolCallId, params, signal) {
297
+ const prompt = params.prompt;
298
+ const rawModel = params.model || DEFAULT_VIDEO_MODEL;
299
+ const endpointId = params.endpoint || "agnes";
300
+ checkVideoModel(rawModel);
301
+ const { baseUrl, apiKey } = getEndpoint(endpointId);
302
+
303
+ const saved = await requestVideo(baseUrl, apiKey, {
304
+ model: rawModel,
305
+ prompt,
306
+ images: params.images || [],
307
+ // Only forward when explicitly provided — some Agnes video routes reject
308
+ // num_frames/frame_rate as request fields and use their own defaults.
309
+ num_frames: params.num_frames,
310
+ frame_rate: params.frame_rate,
311
+ signal,
312
+ });
313
+
314
+ const text = "Generated video saved to: " + fileLink(saved.filePath) + "\n\nVideo URL: " + saved.remoteUrl;
257
315
  return {
258
316
  content: [{ type: "text", text }],
259
- details: { filePath, model: rawModel, endpoint: endpointId, remoteUrl: url },
317
+ details: { filePath: saved.filePath, model: rawModel, endpoint: endpointId, remoteUrl: saved.remoteUrl },
260
318
  };
261
319
  }
262
320
 
321
+ // ---------------------------------------------------------------------------
322
+ // Standalone provider fallback (only when pi-agnes is NOT installed).
323
+ // Mirrors pi-agnes: seed catalog + /v1/models discovery + stream routing,
324
+ // so /model selection works out of the box without pi-agnes.
325
+ // ---------------------------------------------------------------------------
326
+
327
+ function toModelConfig(id) {
328
+ const limits =
329
+ id.startsWith("agnes-2.5") || id.startsWith("agnes-3")
330
+ ? { contextWindow: 1048576, maxTokens: 65536 }
331
+ : id.startsWith("agnes-2.0")
332
+ ? { contextWindow: 1048576, maxTokens: 32768 }
333
+ : { contextWindow: 131072, maxTokens: 32768 };
334
+ return {
335
+ id,
336
+ name: id,
337
+ reasoning: false,
338
+ input: ["text", "image"],
339
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
340
+ ...limits,
341
+ };
342
+ }
343
+
344
+ async function fetchStandaloneModels(baseUrl, apiKey, signal) {
345
+ const headers = {};
346
+ if (apiKey) headers["Authorization"] = "Bearer " + apiKey;
347
+ const res = await fetch(baseUrl + "/models", { headers, redirect: "follow", signal });
348
+ if (!res.ok) throw new Error("HTTP " + res.status + " " + res.statusText);
349
+ const payload = await res.json().catch(() => null);
350
+ const data = payload && Array.isArray(payload.data) ? payload.data : Array.isArray(payload) ? payload : [];
351
+ return data
352
+ .filter((m) => m && m.id && isTextModel(m.id))
353
+ .map((m) => toModelConfig(m.id));
354
+ }
355
+
356
+ function makeRefreshModels(baseUrl, apiKeyEnv, providerId) {
357
+ return async ({ signal, stored, publish, allowNetwork, credential }) => {
358
+ const cached = stored && Array.isArray(stored.models) ? stored.models : undefined;
359
+ if (!allowNetwork || (signal && signal.aborted)) return cached;
360
+ const apiKey = credential && credential.type === "api_key" ? credential.key : process.env[apiKeyEnv];
361
+ let models;
362
+ try {
363
+ models = await fetchStandaloneModels(baseUrl, apiKey, signal);
364
+ } catch (error) {
365
+ if (cached) return cached;
366
+ throw error;
367
+ }
368
+ if (models.length > 0) {
369
+ await publish({ persist: { provider: providerId, models } });
370
+ return models;
371
+ }
372
+ return cached;
373
+ };
374
+ }
375
+
376
+ function latestUserMessage(context) {
377
+ const messages = context && Array.isArray(context.messages) ? context.messages : [];
378
+ return [...messages].reverse().find((m) => m && m.role === "user");
379
+ }
380
+
381
+ function extractPrompt(context) {
382
+ const user = latestUserMessage(context);
383
+ if (!user) return { prompt: "", images: [] };
384
+ if (typeof user.content === "string") return { prompt: user.content, images: [] };
385
+ return {
386
+ prompt: user.content
387
+ .filter((part) => part && part.type === "text")
388
+ .map((part) => part.text || "")
389
+ .join("\n"),
390
+ images: user.content.filter((part) => part && part.type === "image"),
391
+ };
392
+ }
393
+
394
+ function pushDone(stream, output, text) {
395
+ output.content.push({ type: "text", text });
396
+ stream.push({ type: "text_start", contentIndex: 0, partial: output });
397
+ stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial: output });
398
+ stream.push({ type: "text_end", contentIndex: 0, content: text, partial: output });
399
+ output.stopReason = "stop";
400
+ stream.push({ type: "done", reason: "stop", message: output });
401
+ stream.end();
402
+ }
403
+
404
+ function pushError(stream, output, error, signal) {
405
+ output.stopReason = signal && signal.aborted ? "aborted" : "error";
406
+ output.errorMessage = error instanceof Error ? error.message : String(error);
407
+ stream.push({ type: "error", reason: output.stopReason, error: output });
408
+ stream.end();
409
+ }
410
+
411
+ function newOutput(model) {
412
+ return {
413
+ role: "assistant",
414
+ content: [],
415
+ api: model.api,
416
+ provider: model.provider,
417
+ model: model.id,
418
+ usage: {
419
+ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
420
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
421
+ },
422
+ stopReason: "pending",
423
+ timestamp: Date.now(),
424
+ };
425
+ }
426
+
427
+ function streamStandaloneImage(model, context, options) {
428
+ const stream = createAssistantMessageEventStream();
429
+ const output = newOutput(model);
430
+ (async () => {
431
+ try {
432
+ stream.push({ type: "start", partial: output });
433
+ const extracted = extractPrompt(context);
434
+ if (!extracted.prompt) throw new Error("Image generation requires a text prompt");
435
+ const endpointId = model.provider === "agnes-cn" ? "agnes-cn" : "agnes";
436
+ const { baseUrl, apiKey } = getEndpoint(endpointId);
437
+ const saved = await requestImage(baseUrl, apiKey, {
438
+ model: model.id,
439
+ prompt: extracted.prompt,
440
+ images: extracted.images.map((img) => "data:" + img.mimeType + ";base64," + img.data),
441
+ response_format: "png",
442
+ signal: options && options.signal,
443
+ });
444
+ const text = saved.remoteUrl
445
+ ? "![](" + saved.remoteUrl + ")\n\nSaved local copy: " + fileLink(saved.filePath) + "\n\nImage URL may expire according to Agnes retention policy."
446
+ : "Generated image saved to: " + fileLink(saved.filePath);
447
+ pushDone(stream, output, text);
448
+ } catch (error) {
449
+ pushError(stream, output, error, options && options.signal);
450
+ }
451
+ })();
452
+ return stream;
453
+ }
454
+
455
+ function streamStandaloneVideo(model, context, options) {
456
+ const stream = createAssistantMessageEventStream();
457
+ const output = newOutput(model);
458
+ (async () => {
459
+ try {
460
+ stream.push({ type: "start", partial: output });
461
+ const extracted = extractPrompt(context);
462
+ if (!extracted.prompt) throw new Error("Video generation requires a text prompt");
463
+ const endpointId = model.provider === "agnes-cn" ? "agnes-cn" : "agnes";
464
+ const { baseUrl, apiKey } = getEndpoint(endpointId);
465
+ const saved = await requestVideo(baseUrl, apiKey, {
466
+ model: model.id,
467
+ prompt: extracted.prompt,
468
+ images: extracted.images.map((img) => "data:" + img.mimeType + ";base64," + img.data),
469
+ num_frames: 121,
470
+ frame_rate: 24,
471
+ signal: options && options.signal,
472
+ });
473
+ pushDone(stream, output, "Generated video saved to: " + fileLink(saved.filePath) + "\n\nVideo URL: " + saved.remoteUrl);
474
+ pushDone(stream, output, "Generated video saved to: " + fileLink(saved.filePath) + "\n\nVideo URL: " + saved.remoteUrl);
475
+ } catch (error) {
476
+ pushError(stream, output, error, options && options.signal);
477
+ }
478
+ })();
479
+ return stream;
480
+ }
481
+
482
+ function streamStandalone(model, context, options) {
483
+ if (isVideoModel(model.id)) return streamStandaloneVideo(model, context, options);
484
+ if (isImageModel(model.id)) return streamStandaloneImage(model, context, options);
485
+ return openAICompletionsApi().streamSimple(model, context, options);
486
+ }
487
+
488
+ function debugLog(msg) {
489
+ if (process.env.PI_AGNES_TOOLS_DEBUG) {
490
+ try { console.error("[pi-agnes-tools] " + msg); } catch { /* ignore */ }
491
+ }
492
+ }
493
+
494
+ function registerAgnesProviders(pi) {
495
+ const defs = [
496
+ { id: "agnes", name: "Agnes AI", baseUrl: ENDPOINTS.agnes.baseUrl, apiKeyEnv: "AGNES_API_KEY" },
497
+ { id: "agnes-cn", name: "Agnes AI (CN)", baseUrl: ENDPOINTS["agnes-cn"].baseUrl, apiKeyEnv: "AGNES_CN_API_KEY" },
498
+ ];
499
+ for (const def of defs) {
500
+ // Omit apiKey when the env var is absent so /login can supply the key —
501
+ // same convention pi-agnes uses.
502
+ const apiKeyRef = process.env[def.apiKeyEnv] ? "$" + def.apiKeyEnv : undefined;
503
+ pi.registerProvider(def.id, {
504
+ name: def.name,
505
+ baseUrl: def.baseUrl,
506
+ ...(apiKeyRef ? { apiKey: apiKeyRef } : {}),
507
+ api: "openai-completions",
508
+ streamSimple: streamStandalone,
509
+ models: AGNES_SEED.map((id) => toModelConfig(id)),
510
+ refreshModels: makeRefreshModels(def.baseUrl, def.apiKeyEnv, def.id),
511
+ });
512
+ }
513
+ }
514
+
263
515
  // ---------------------------------------------------------------------------
264
516
  // Extension entry
265
517
  // ---------------------------------------------------------------------------
266
518
 
267
519
  export default function (pi) {
520
+ // Tools are always safe: unique names, no provider overlap.
268
521
  pi.registerTool({
269
522
  name: "agnes_image",
270
523
  label: "Agnes Image",
271
524
  description:
272
525
  "Generate an image via the Agnes AI API (no model switch needed). " +
273
526
  "Saves a local copy under .pi/generated-images/ and returns the saved path plus the (possibly expiring) remote URL. " +
274
- "Auth: AGNES_API_KEY (default endpoint) or AGNES_CN_API_KEY (endpoint=agnes-cn).",
527
+ "endpoint: agnes = international (apihub.agnes-ai.com, default), agnes-cn = China (api.agnes-ai.cn). " +
528
+ "Auth: AGNES_API_KEY or AGNES_CN_API_KEY env, else the /login-stored Agnes key.",
275
529
  parameters: imageParams,
276
530
  executionMode: "parallel",
277
531
  execute: executeImage,
@@ -284,9 +538,67 @@ export default function (pi) {
284
538
  "Generate a video via the Agnes AI API (no model switch needed). " +
285
539
  "Async: creates a task, polls every 5s until done (up to 30 min), then downloads the .mp4 to .pi/generated-videos/. " +
286
540
  "Supports image-to-video (1 reference image) and keyframes mode (>1 image). " +
287
- "Auth: AGNES_API_KEY (default endpoint) or AGNES_CN_API_KEY (endpoint=agnes-cn).",
541
+ "endpoint: agnes = international (default), agnes-cn = China. " +
542
+ "Auth: AGNES_API_KEY or AGNES_CN_API_KEY env, else the /login-stored Agnes key.",
288
543
  parameters: videoParams,
289
544
  executionMode: "sequential",
290
545
  execute: executeVideo,
291
546
  });
547
+
548
+ // Patch around a pi-ai quirk that bites this stack: when a streaming tool-call
549
+ // delta arrives without an `id`, the tool-call block is recorded with
550
+ // `id: ""` (openai-completions.js line ~326). On the next turn pi replays
551
+ // history to OpenAI Chat Completions, which rejects `tool_call_id: ""`
552
+ // with: "missing field `tool_call_id` at line 1 column N".
553
+ // We strip assistant tool-call blocks with empty ids and the matching
554
+ // orphan tool-result messages before each request to any agnes provider,
555
+ // so the upstream never sees an empty id. This is harmless when no
556
+ // empty-id blocks exist (the common case).
557
+ pi.on("before_provider_request", (event) => {
558
+ try {
559
+ const payload = event.payload;
560
+ if (!payload || typeof payload !== "object") return;
561
+ const messages = Array.isArray(payload.messages) ? payload.messages : null;
562
+ if (!messages) return;
563
+ // Pass 1: strip empty-id tool-call blocks from assistant messages.
564
+ let strippedAny = false;
565
+ for (const msg of messages) {
566
+ if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
567
+ const filtered = msg.content.filter((block) => {
568
+ if (block && block.type === "toolCall" && (!block.id || typeof block.id !== "string" || block.id.length === 0)) {
569
+ strippedAny = true;
570
+ return false;
571
+ }
572
+ return true;
573
+ });
574
+ if (filtered.length !== msg.content.length) msg.content = filtered;
575
+ }
576
+ // Pass 2: drop orphan tool-result messages with empty toolCallId
577
+ // (covers both internal camelCase and wire-format snake_case fields).
578
+ for (let i = messages.length - 1; i >= 0; i--) {
579
+ const m = messages[i];
580
+ if (!m) continue;
581
+ const isEmptyCamel = m.role === "toolResult" && (typeof m.toolCallId !== "string" || m.toolCallId.length === 0);
582
+ const isEmptySnake = m.role === "tool" && (typeof m.tool_call_id !== "string" || m.tool_call_id.length === 0);
583
+ if (isEmptyCamel || isEmptySnake) {
584
+ messages.splice(i, 1);
585
+ strippedAny = true;
586
+ }
587
+ }
588
+ if (strippedAny) {
589
+ debugLog("stripped empty-id tool-call/tool-result blocks before provider request");
590
+ }
591
+ } catch (error) {
592
+ debugLog("before_provider_request sanitizer failed: " + (error instanceof Error ? error.message : String(error)));
593
+ }
594
+ });
595
+
596
+ // Providers: this plugin owns `agnes` (international) and `agnes-cn`
597
+ // (China) outright — seed catalog + live discovery + stream routing — so
598
+ // every text, image and video model is selectable via /model and --model.
599
+ try {
600
+ registerAgnesProviders(pi);
601
+ } catch (error) {
602
+ debugLog("provider registration failed: " + (error instanceof Error ? error.message : String(error)));
603
+ }
292
604
  }