@lazyingart/agintiflow 0.20.195 → 0.20.196

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.195",
3
+ "version": "0.20.196",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a project-aware agent workspace for hybrid wet-dry R&D, hardware-aware intelligence, software automation, and industrial workflows.",
6
6
  "license": "Apache-2.0",
@@ -135,6 +135,7 @@
135
135
  "dependencies": {
136
136
  "@modelcontextprotocol/sdk": "^1.29.0",
137
137
  "express": "^5.1.0",
138
+ "fast-xml-parser": "^5.8.0",
138
139
  "openai": "^6.3.0",
139
140
  "playwright": "^1.55.0"
140
141
  }
@@ -73,6 +73,31 @@ try {
73
73
  await fs.access(path.join(workspace, "artifacts/images/dry-run/task_manifest.json"));
74
74
  const payloadText = await fs.readFile(path.join(workspace, "artifacts/images/dry-run/request_payload.redacted.json"), "utf8");
75
75
  assert(payloadText.includes("nano-banana-2"), "redacted image payload was not written");
76
+ const referencePng = Buffer.from(
77
+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAYAAACJ7f8GAAAADElEQVR42mP8z8AARAAAIf4BfQKxMQAAAABJRU5ErkJggg==",
78
+ "base64"
79
+ );
80
+ await fs.mkdir(path.join(workspace, "refs"), { recursive: true });
81
+ await fs.writeFile(path.join(workspace, "refs/reference.png"), referencePng);
82
+ const referenceDryRun = await generateImage(
83
+ {
84
+ prompt: "Use the reference image geometry for a diagnostic dry run.",
85
+ outputDir: "artifacts/images/reference-dry-run",
86
+ outputStem: "reference",
87
+ referenceImages: ["refs/reference.png"],
88
+ matchReferenceSize: true,
89
+ dryRun: true,
90
+ },
91
+ {
92
+ commandCwd: workspace,
93
+ allowFileTools: true,
94
+ }
95
+ );
96
+ assert(referenceDryRun.ok && /Reference-size matching/i.test(referenceDryRun.geometryNotice || ""), "reference dry run did not report geometry notice");
97
+ const referenceManifest = JSON.parse(await fs.readFile(path.join(workspace, "artifacts/images/reference-dry-run/task_manifest.json"), "utf8"));
98
+ assert(referenceManifest.referenceImages?.[0]?.dimensions?.width === 2, "reference manifest did not record source image width");
99
+ assert(referenceManifest.referenceImages?.[0]?.dimensions?.height === 3, "reference manifest did not record source image height");
100
+ assert(referenceManifest.matchReferenceSize === true, "reference manifest did not record matchReferenceSize");
76
101
  const veniceDryRun = await generateImage(
77
102
  {
78
103
  provider: "venice",
@@ -139,6 +164,12 @@ try {
139
164
  assert(cliImageResult.actualFormat === "png", "direct image CLI did not select PNG fallback");
140
165
  assert(/raster PNG/i.test(cliImageResult.formatNotice || ""), "direct image CLI did not explain SVG-to-PNG fallback");
141
166
  await fs.access(path.join(workspace, "artifacts/images/cli-svg-fallback/task_manifest.json"));
167
+ const cliImageHelp = await execFile(process.execPath, [path.join(repoRoot, "bin/aginti-cli.js"), "--no-auto-update", "image", "--help"], {
168
+ cwd: repoRoot,
169
+ env: { ...process.env, AGINTIFLOW_HOME: process.env.AGINTIFLOW_HOME },
170
+ });
171
+ assert(cliImageHelp.stdout.includes("--reference"), "aginti image --help did not show reference-image options");
172
+ assert(cliImageHelp.stdout.includes("--match-reference-size"), "aginti image --help did not show match-reference-size");
142
173
 
143
174
  const blocked = await generateImage(
144
175
  {
@@ -187,10 +218,12 @@ try {
187
218
  "image_skill_listed",
188
219
  "venice_image_skill_listed",
189
220
  "generate_image_dry_run",
221
+ "reference_image_manifest_dimensions",
190
222
  "auto_venice_when_grsai_missing",
191
223
  "venice_generate_image_dry_run",
192
224
  "svg_request_png_fallback",
193
225
  "direct_image_cli_svg_request_png_fallback",
226
+ "direct_image_cli_subcommand_help",
194
227
  "generate_image_guardrail",
195
228
  "mock_agent_image_tool",
196
229
  ],
@@ -786,6 +786,10 @@ try {
786
786
  if (!zhHelpResult.stdout.includes("命令:") || !zhHelpResult.stdout.includes("输入普通任务")) {
787
787
  throw new Error("interactive --language zh-Hans did not localize CLI help");
788
788
  }
789
+ const zhLeadingLanguageStatus = await runCli(["--language", "zh-Hans", "chat"], "/status\n/exit\n");
790
+ if (!zhLeadingLanguageStatus.stdout.includes("language=zh-Hans")) {
791
+ throw new Error("leading --language zh-Hans was stripped before interactive status");
792
+ }
789
793
  const skillsResult = await runChat("/skills website\n/exit\n");
790
794
  if (!skillsResult.stdout.includes("website-app") || !skillsResult.stdout.includes("Website And App Builder")) {
791
795
  throw new Error("interactive /skills did not show matching built-in skills");
@@ -956,6 +960,7 @@ try {
956
960
  "prompt-redraw-fast-path",
957
961
  "committed-user-no-cwd-footer",
958
962
  "cli-i18n",
963
+ "leading-language-option",
959
964
  "user-prompt-label",
960
965
  "escape-policy",
961
966
  "composer-history-recall",
@@ -179,6 +179,19 @@ try {
179
179
  assert(cdataSvgResult.ok === false, "CDATA-wrapped SVG write should be marked not ok");
180
180
  assert(cdataSvgResult.artifactValidation?.kind === "svg", "SVG validation result missing");
181
181
  assert(/CDATA/i.test(cdataSvgResult.artifactValidation.errors.join(" ")), "CDATA SVG validation did not explain the wrapper problem");
182
+ const invalidXmlSvgResult = await executeWorkspaceTool(
183
+ "write_file",
184
+ {
185
+ path: "figures/invalid-unescaped-text.svg",
186
+ content: '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40"><text x="4" y="24">latency < 50 ms</text></svg>',
187
+ },
188
+ workspaceToolConfig
189
+ );
190
+ assert(invalidXmlSvgResult.ok === false, "SVG write with unescaped text '<' should be marked not ok");
191
+ assert(
192
+ /XML parser rejected/i.test(invalidXmlSvgResult.artifactValidation?.errors?.join(" ") || ""),
193
+ "invalid SVG XML was not rejected by the XML parser"
194
+ );
182
195
  const validSvgResult = await executeWorkspaceTool(
183
196
  "write_file",
184
197
  {
@@ -1265,6 +1278,7 @@ try {
1265
1278
  "resume_runtime_time_context",
1266
1279
  "virtual_workspace_path",
1267
1280
  "svg_cdata_validation_failure",
1281
+ "svg_unescaped_text_xml_validation_failure",
1268
1282
  "svg_standalone_validation_pass",
1269
1283
  "apply_patch",
1270
1284
  "multi_file_patch",
@@ -24,6 +24,7 @@ import {
24
24
  import { buildScsEvidenceLedger, deriveScsTaskContract, evaluateScsEvidence } from "../src/scs-evidence.js";
25
25
  import { resolveRuntimeConfig } from "../src/config.js";
26
26
  import { classifyGoalIntent, isDirectAnswerIntent } from "../src/goal-intent.js";
27
+ import { languageWriterDefaults } from "../src/writing-specialist.js";
27
28
 
28
29
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
29
30
 
@@ -111,6 +112,10 @@ const envSnapshot = {
111
112
  OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
112
113
  LLM_BASE_URL: process.env.LLM_BASE_URL,
113
114
  AGINTI_MAIN_REASONING: process.env.AGINTI_MAIN_REASONING,
115
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY,
116
+ DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
117
+ AGINTI_WRITING_PROVIDER_ZH: process.env.AGINTI_WRITING_PROVIDER_ZH,
118
+ AGINTI_WRITING_MODEL_ZH: process.env.AGINTI_WRITING_MODEL_ZH,
114
119
  };
115
120
  process.env.OPENAI_BASE_URL = "https://openai-compatible.example/v1";
116
121
  process.env.LLM_BASE_URL = "https://generic-compatible.example/v1";
@@ -123,6 +128,18 @@ process.env.AGINTI_MAIN_REASONING = "none";
123
128
  assert(getModelRoleDefaults().main.reasoning === "", "none main reasoning should normalize to omitted reasoning");
124
129
  assert(normalizeReasoningEffort("min") === "minimal", "min reasoning alias should normalize to minimal");
125
130
  assert(normalizeReasoningEffort("extra-high") === "xhigh", "extra-high reasoning alias should normalize to xhigh");
131
+ process.env.DEEPSEEK_API_KEY = "test-deepseek-key";
132
+ process.env.OPENAI_API_KEY = "test-openai-key";
133
+ delete process.env.AGINTI_WRITING_PROVIDER_ZH;
134
+ delete process.env.AGINTI_WRITING_MODEL_ZH;
135
+ const zhWriterRoute = languageWriterDefaults({ language: "zh-Hans", writingBrief: "写一段小说。" }, { provider: "mock", model: "mock-agent" });
136
+ assert(zhWriterRoute.provider === "deepseek" && zhWriterRoute.model === "deepseek-v4-pro", "Chinese writing should default to DeepSeek Pro when available");
137
+ const enWriterRoute = languageWriterDefaults({ language: "en", writingBrief: "Write a scene." }, { provider: "mock", model: "mock-agent" });
138
+ assert(enWriterRoute.provider === "openai" && enWriterRoute.model, "English writing should default to OpenAI when available");
139
+ process.env.AGINTI_WRITING_PROVIDER_ZH = "qwen";
140
+ process.env.AGINTI_WRITING_MODEL_ZH = "qwen-test-writer";
141
+ const zhEnvWriterRoute = languageWriterDefaults({ language: "zh-Hans", writingBrief: "写一段小说。" }, { provider: "mock", model: "mock-agent" });
142
+ assert(zhEnvWriterRoute.provider === "qwen" && zhEnvWriterRoute.model === "qwen-test-writer", "language-specific writer env should override auto routing");
126
143
  for (const [key, value] of Object.entries(envSnapshot)) {
127
144
  if (value === undefined) delete process.env[key];
128
145
  else process.env[key] = value;
@@ -578,6 +595,10 @@ const jsonObjectContract = deriveScsTaskContract({
578
595
  const virtualFileContract = deriveScsTaskContract({
579
596
  goal: "Create file: /workspace/virtual-output.txt with virtual Docker path support.",
580
597
  });
598
+ const requiredWriterToolContract = deriveScsTaskContract({
599
+ goal: "Call writing_specialist again and create final/story.md.",
600
+ taskProfile: "writing",
601
+ });
581
602
  const outputListContract = deriveScsTaskContract({
582
603
  goal: [
583
604
  "Create:",
@@ -647,6 +668,10 @@ assert(
647
668
  !virtualFileContract.requiredEvidence.some((item) => item.category === "artifact"),
648
669
  "virtual output filename should require file evidence without treating output in the filename as an artifact"
649
670
  );
671
+ assert(
672
+ requiredWriterToolContract.requiredToolCalls.includes("writing_specialist"),
673
+ "SCS should infer explicitly required specialist tool calls"
674
+ );
650
675
  assert(
651
676
  outputListContract.exactOutputPaths.includes("work/demo/generate_items.py") &&
652
677
  outputListContract.exactOutputPaths.includes("work/demo/review_items.py") &&
@@ -691,6 +716,28 @@ const checkedCodeEval = evaluateScsEvidence(
691
716
  })
692
717
  );
693
718
  assert(checkedCodeEval.ok, "code task should finish when file and command evidence are both present");
719
+ const missingWriterToolEval = evaluateScsEvidence(
720
+ requiredWriterToolContract,
721
+ buildScsEvidenceLedger({
722
+ context: { events: [{ type: "file.changed", data: { path: "final/story.md" } }] },
723
+ })
724
+ );
725
+ assert(
726
+ !missingWriterToolEval.ok && missingWriterToolEval.missingToolCalls.includes("writing_specialist"),
727
+ "SCS should reject finish when required specialist call is missing"
728
+ );
729
+ const presentWriterToolEval = evaluateScsEvidence(
730
+ requiredWriterToolContract,
731
+ buildScsEvidenceLedger({
732
+ context: {
733
+ events: [
734
+ { type: "file.changed", data: { path: "final/story.md" } },
735
+ { type: "tool.completed", data: { toolName: "writing_specialist", ok: true, artifactPath: "artifacts/writer.json" } },
736
+ ],
737
+ },
738
+ })
739
+ );
740
+ assert(presentWriterToolEval.ok, "SCS should accept required specialist call when tool evidence is present");
694
741
 
695
742
  const blockedFileFinish = await reviewScsFinish(
696
743
  { mock: true },
@@ -740,6 +787,7 @@ console.log(
740
787
  "role-defaults",
741
788
  "openai-base-url",
742
789
  "provider-default-reasoning",
790
+ "writing-specialist-language-routing",
743
791
  "openai-chat-reasoning-payload",
744
792
  "goal-intent-direct-answer",
745
793
  "route-overrides",
@@ -754,6 +802,7 @@ console.log(
754
802
  "scs-student-validator-replan",
755
803
  "scs-evidence-stdout",
756
804
  "scs-contract-evidence-ledger",
805
+ "scs-required-tool-call-contract",
757
806
  "cli-models-command",
758
807
  "venice-shortcut",
759
808
  ],
@@ -130,6 +130,66 @@ function mimeFromPath(filePath) {
130
130
  return "image/png";
131
131
  }
132
132
 
133
+ function imageDimensionsFromBuffer(buffer) {
134
+ if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null;
135
+
136
+ if (buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504e47 && buffer.toString("ascii", 12, 16) === "IHDR") {
137
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20), format: "png" };
138
+ }
139
+
140
+ if (buffer.toString("ascii", 0, 3) === "GIF" && buffer.length >= 10) {
141
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8), format: "gif" };
142
+ }
143
+
144
+ if (buffer[0] === 0xff && buffer[1] === 0xd8) {
145
+ let offset = 2;
146
+ while (offset + 9 < buffer.length) {
147
+ while (buffer[offset] === 0xff) offset += 1;
148
+ const marker = buffer[offset];
149
+ offset += 1;
150
+ if ([0xd8, 0xd9, 0x01].includes(marker) || (marker >= 0xd0 && marker <= 0xd7)) continue;
151
+ if (offset + 2 > buffer.length) break;
152
+ const length = buffer.readUInt16BE(offset);
153
+ if (length < 2 || offset + length > buffer.length) break;
154
+ if (
155
+ [0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker) &&
156
+ offset + 7 < buffer.length
157
+ ) {
158
+ return { width: buffer.readUInt16BE(offset + 5), height: buffer.readUInt16BE(offset + 3), format: "jpeg" };
159
+ }
160
+ offset += length;
161
+ }
162
+ }
163
+
164
+ if (buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
165
+ let offset = 12;
166
+ while (offset + 8 <= buffer.length) {
167
+ const chunk = buffer.toString("ascii", offset, offset + 4);
168
+ const size = buffer.readUInt32LE(offset + 4);
169
+ const data = offset + 8;
170
+ if (chunk === "VP8X" && data + 10 <= buffer.length) {
171
+ const width = 1 + buffer.readUIntLE(data + 4, 3);
172
+ const height = 1 + buffer.readUIntLE(data + 7, 3);
173
+ return { width, height, format: "webp" };
174
+ }
175
+ if (chunk === "VP8 " && data + 10 <= buffer.length && buffer[data + 3] === 0x9d && buffer[data + 4] === 0x01 && buffer[data + 5] === 0x2a) {
176
+ return {
177
+ width: buffer.readUInt16LE(data + 6) & 0x3fff,
178
+ height: buffer.readUInt16LE(data + 8) & 0x3fff,
179
+ format: "webp",
180
+ };
181
+ }
182
+ offset += 8 + size + (size % 2);
183
+ }
184
+ }
185
+
186
+ return null;
187
+ }
188
+
189
+ function dimensionsEqual(a, b) {
190
+ return Boolean(a && b && Number(a.width) === Number(b.width) && Number(a.height) === Number(b.height));
191
+ }
192
+
133
193
  function parseJsonResponse(raw) {
134
194
  try {
135
195
  const parsed = JSON.parse(raw);
@@ -163,6 +223,33 @@ function redactPayload(payload) {
163
223
  return copy;
164
224
  }
165
225
 
226
+ function extractFailureReason(payload = {}) {
227
+ const seen = new Set();
228
+ const reasons = [];
229
+ const interesting = /^(?:failure_reason|failureReason|fail_reason|failReason|reason|error|error_message|errorMessage|message|msg|detail|details|code|status)$/i;
230
+ const walk = (value, depth = 0, key = "") => {
231
+ if (value === null || value === undefined || depth > 4 || reasons.length >= 12) return;
232
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
233
+ if (!interesting.test(key)) return;
234
+ const text = redactSensitiveText(String(value)).replace(/\s+/g, " ").trim();
235
+ if (text && !seen.has(`${key}:${text}`)) {
236
+ seen.add(`${key}:${text}`);
237
+ reasons.push(`${key}: ${text}`);
238
+ }
239
+ return;
240
+ }
241
+ if (Array.isArray(value)) {
242
+ value.slice(0, 8).forEach((item) => walk(item, depth + 1, key));
243
+ return;
244
+ }
245
+ if (typeof value === "object") {
246
+ for (const [childKey, childValue] of Object.entries(value)) walk(childValue, depth + 1, childKey);
247
+ }
248
+ };
249
+ walk(payload);
250
+ return reasons.join("; ").slice(0, 1200) || "";
251
+ }
252
+
166
253
  function grsaiKey() {
167
254
  return String(process.env.GRSAI || process.env.GRSAI_API_KEY || "").trim();
168
255
  }
@@ -181,8 +268,18 @@ export function listAuxiliarySkills() {
181
268
  async function referenceToUrl(item, config) {
182
269
  const value = String(item || "").trim();
183
270
  if (!value) return { url: "", redacted: "" };
184
- if (/^https?:\/\//i.test(value)) return { url: value, redacted: value };
185
- if (value.startsWith("data:")) return { url: value, redacted: `<data-uri length=${value.length}>` };
271
+ if (/^https?:\/\//i.test(value)) return { url: value, redacted: value, source: "url", dimensions: null };
272
+ if (value.startsWith("data:")) {
273
+ const match = value.match(/^data:([^;,]+);base64,([\s\S]+)$/i);
274
+ const buffer = match ? Buffer.from(match[2], "base64") : null;
275
+ return {
276
+ url: value,
277
+ redacted: `<data-uri length=${value.length}>`,
278
+ source: "data-uri",
279
+ bytes: buffer?.length || 0,
280
+ dimensions: buffer ? imageDimensionsFromBuffer(buffer) : null,
281
+ };
282
+ }
186
283
 
187
284
  const target = resolveWorkspacePath(config, value);
188
285
  const stat = await fs.stat(target.absolutePath);
@@ -192,6 +289,10 @@ async function referenceToUrl(item, config) {
192
289
  return {
193
290
  url: `data:${mimeFromPath(target.relativePath)};base64,${buffer.toString("base64")}`,
194
291
  redacted: `<${target.relativePath} data-uri bytes=${buffer.length}>`,
292
+ source: "workspace-file",
293
+ path: target.relativePath,
294
+ bytes: buffer.length,
295
+ dimensions: imageDimensionsFromBuffer(buffer),
195
296
  };
196
297
  }
197
298
 
@@ -302,6 +403,7 @@ async function downloadImage(url, destination, signal = null) {
302
403
  return {
303
404
  bytes: buffer.length,
304
405
  sha256: hashBuffer(buffer),
406
+ dimensions: imageDimensionsFromBuffer(buffer),
305
407
  };
306
408
  }
307
409
 
@@ -313,6 +415,7 @@ async function writeBase64Image(image, destination) {
313
415
  return {
314
416
  bytes: buffer.length,
315
417
  sha256: hashBuffer(buffer),
418
+ dimensions: imageDimensionsFromBuffer(buffer),
316
419
  };
317
420
  }
318
421
 
@@ -401,6 +504,12 @@ async function generateVeniceImages({ prompt, args, target, outputStem, manifest
401
504
  imagePaths.push(relativePath);
402
505
  downloads.push({ path: relativePath, ...info });
403
506
  }
507
+ const referenceDimensions = manifest.referenceDimensions || null;
508
+ const matchReferenceSize = Boolean(manifest.matchReferenceSize);
509
+ const geometryMismatch =
510
+ matchReferenceSize && referenceDimensions
511
+ ? downloads.find((item) => item.dimensions && !dimensionsEqual(item.dimensions, referenceDimensions))
512
+ : null;
404
513
 
405
514
  manifest.provider = "venice";
406
515
  manifest.host = base;
@@ -411,6 +520,9 @@ async function generateVeniceImages({ prompt, args, target, outputStem, manifest
411
520
  manifest.status = "succeeded";
412
521
  manifest.finishedAt = new Date().toISOString();
413
522
  manifest.downloadedFiles = downloads;
523
+ if (geometryMismatch) {
524
+ manifest.geometryNotice = `Reference size requested (${referenceDimensions.width}x${referenceDimensions.height}), but generated output ${geometryMismatch.path} is ${geometryMismatch.dimensions.width}x${geometryMismatch.dimensions.height}.`;
525
+ }
414
526
  if (resultPayload.id) manifest.taskId = String(resultPayload.id);
415
527
  await writeJson(manifestPath, manifest);
416
528
 
@@ -427,6 +539,7 @@ async function generateVeniceImages({ prompt, args, target, outputStem, manifest
427
539
  requestedFormat: formatInfo.requestedFormat,
428
540
  actualFormat: format,
429
541
  formatNotice: formatInfo.notice,
542
+ geometryNotice: manifest.geometryNotice || "",
430
543
  summary: `${imagePaths.length} image(s) generated through Venice${formatInfo.notice ? ` (${formatInfo.notice})` : ""}`,
431
544
  };
432
545
  }
@@ -456,6 +569,15 @@ export async function generateImage(args = {}, config = {}) {
456
569
  const converted = await referenceToUrl(item, config);
457
570
  if (converted.url) references.push(converted);
458
571
  }
572
+ const referenceMetadata = references.map((item) => ({
573
+ source: item.source || "",
574
+ path: item.path || "",
575
+ redacted: item.redacted || "",
576
+ bytes: item.bytes || 0,
577
+ dimensions: item.dimensions || null,
578
+ }));
579
+ const referenceDimensions = references.find((item) => item.dimensions)?.dimensions || null;
580
+ const matchReferenceSize = Boolean(args.matchReferenceSize || args.match_ref_size || args.matchRefSize);
459
581
 
460
582
  const provider = args.provider ? normalizeImageProvider(args.provider) : defaultImageProvider(config);
461
583
  const formatInfo = normalizeOutputFormat(args);
@@ -479,6 +601,7 @@ export async function generateImage(args = {}, config = {}) {
479
601
 
480
602
  const redactedPayload = redactPayload(payload);
481
603
  redactedPayload.urls = references.map((item) => item.redacted);
604
+ if (matchReferenceSize) redactedPayload.matchReferenceSize = true;
482
605
  const promptPath = path.join(target.absolutePath, "prompt.txt");
483
606
  const requestPath = path.join(target.absolutePath, "request_payload.redacted.json");
484
607
  const manifestPath = path.join(target.absolutePath, "task_manifest.json");
@@ -496,9 +619,18 @@ export async function generateImage(args = {}, config = {}) {
496
619
  outputDir: target.relativePath,
497
620
  promptFile: path.posix.join(target.relativePath, "prompt.txt"),
498
621
  requestPayloadRedacted: path.posix.join(target.relativePath, "request_payload.redacted.json"),
622
+ referenceImages: referenceMetadata,
623
+ referenceDimensions,
624
+ matchReferenceSize,
499
625
  status: args.dryRun ? "prepared" : "started",
500
626
  createdAt: new Date().toISOString(),
501
627
  };
628
+ if (matchReferenceSize && !referenceDimensions) {
629
+ manifest.geometryNotice = "Reference-size matching was requested, but no local reference image dimensions could be detected.";
630
+ } else if (matchReferenceSize) {
631
+ manifest.geometryNotice =
632
+ "Reference-size matching was requested. AgInTiFlow records and checks output dimensions, but the current image backends may not preserve exact source geometry.";
633
+ }
502
634
  await writeJson(manifestPath, manifest);
503
635
 
504
636
  if (args.dryRun) {
@@ -515,6 +647,7 @@ export async function generateImage(args = {}, config = {}) {
515
647
  requestedFormat: formatInfo.requestedFormat,
516
648
  actualFormat: formatInfo.actualFormat,
517
649
  formatNotice: formatInfo.notice,
650
+ geometryNotice: manifest.geometryNotice || "",
518
651
  summary: `Prepared redacted image-generation payload without calling the provider.${formatInfo.notice ? ` ${formatInfo.notice}` : ""}`,
519
652
  };
520
653
  }
@@ -568,10 +701,13 @@ export async function generateImage(args = {}, config = {}) {
568
701
 
569
702
  const status = resultPayload.status || resultPayload?.data?.status;
570
703
  if (status !== "succeeded") {
704
+ const failureReason = extractFailureReason(resultPayload) || `status: ${status || "unknown"}`;
571
705
  manifest.status = status || "failed";
706
+ manifest.failureReason = failureReason;
707
+ manifest.resultResponse = path.posix.join(target.relativePath, "result_response.json");
572
708
  manifest.finishedAt = new Date().toISOString();
573
709
  await writeJson(manifestPath, manifest);
574
- throw new Error(`Image generation failed with status ${status || "unknown"}. See ${manifestPath}.`);
710
+ throw new Error(`Image generation failed with status ${status || "unknown"}: ${failureReason}. See ${manifestPath}.`);
575
711
  }
576
712
 
577
713
  const urls = resultUrls(resultPayload);
@@ -588,6 +724,10 @@ export async function generateImage(args = {}, config = {}) {
588
724
  imagePaths.push(relativePath);
589
725
  downloads.push({ path: relativePath, ...info });
590
726
  }
727
+ const geometryMismatch =
728
+ matchReferenceSize && referenceDimensions
729
+ ? downloads.find((item) => item.dimensions && !dimensionsEqual(item.dimensions, referenceDimensions))
730
+ : null;
591
731
 
592
732
  manifest.status = "succeeded";
593
733
  manifest.requestedFormat = formatInfo.requestedFormat;
@@ -595,6 +735,9 @@ export async function generateImage(args = {}, config = {}) {
595
735
  if (formatInfo.notice) manifest.formatNotice = formatInfo.notice;
596
736
  manifest.finishedAt = new Date().toISOString();
597
737
  manifest.downloadedFiles = downloads;
738
+ if (geometryMismatch) {
739
+ manifest.geometryNotice = `Reference size requested (${referenceDimensions.width}x${referenceDimensions.height}), but generated output ${geometryMismatch.path} is ${geometryMismatch.dimensions.width}x${geometryMismatch.dimensions.height}.`;
740
+ }
598
741
  await writeJson(manifestPath, manifest);
599
742
 
600
743
  return {
@@ -609,6 +752,7 @@ export async function generateImage(args = {}, config = {}) {
609
752
  requestedFormat: formatInfo.requestedFormat,
610
753
  actualFormat: manifest.actualFormat,
611
754
  formatNotice: formatInfo.notice,
755
+ geometryNotice: manifest.geometryNotice || "",
612
756
  summary: `${imagePaths.length} image(s) generated${formatInfo.notice ? ` (${formatInfo.notice})` : ""}`,
613
757
  };
614
758
  }
package/src/cli.js CHANGED
@@ -825,6 +825,7 @@ function parseImageCommandArgs(argv = []) {
825
825
  imageSize: "",
826
826
  host: "",
827
827
  referenceImages: [],
828
+ matchReferenceSize: false,
828
829
  commandCwd: "",
829
830
  requestTimeoutMs: "",
830
831
  pollTimeoutMs: "",
@@ -861,6 +862,11 @@ function parseImageCommandArgs(argv = []) {
861
862
  index += 1;
862
863
  continue;
863
864
  }
865
+ if (arg === "--match-reference-size" || arg === "--match-ref-size") {
866
+ result.matchReferenceSize = true;
867
+ index += 1;
868
+ continue;
869
+ }
864
870
  if (arg === "--stdin") {
865
871
  result.stdin = true;
866
872
  index += 1;
@@ -919,9 +925,10 @@ function parseImageCommandArgs(argv = []) {
919
925
 
920
926
  function printImageCommandUsage() {
921
927
  console.log(
922
- 'Usage: aginti image [generate] [--json] [--dry-run] [--provider grsai|venice] [--model MODEL] [--format png|webp|svg] [--output-dir DIR] [--output-stem STEM] [--aspect-ratio 1:1] [--image-size 1K|2K|4K|1024x1024] [--reference path-or-url] "prompt"'
928
+ 'Usage: aginti image [generate] [--json] [--dry-run] [--provider grsai|venice] [--model MODEL] [--format png|webp|svg] [--output-dir DIR] [--output-stem STEM] [--aspect-ratio 1:1] [--image-size 1K|2K|4K|1024x1024] [--reference path-or-url] [--match-reference-size] "prompt"'
923
929
  );
924
930
  console.log("Direct image CLI calls the same generate_image tool as the web API. SVG/vector requests return PNG with requestedFormat/actualFormat/formatNotice.");
931
+ console.log("--reference accepts local paths, URLs, or data URIs. Diagnostics record reference/output dimensions when available.");
925
932
  console.log('Agent-mediated image work is still available as: aginti --image "draw a poster"');
926
933
  }
927
934
 
@@ -946,11 +953,13 @@ function printImageCommandResult(result = {}) {
946
953
  console.log(`format: ${requested}${requested === actual ? "" : ` -> ${actual}`}`);
947
954
  }
948
955
  if (result.formatNotice) console.log(`notice: ${result.formatNotice}`);
956
+ if (result.geometryNotice) console.log(`geometry: ${result.geometryNotice}`);
949
957
  if (result.path) console.log(`path: ${result.path}`);
950
958
  if (result.imagePaths?.length) console.log(`images: ${result.imagePaths.join(", ")}`);
951
959
  if (result.manifestPath) console.log(`manifest: ${result.manifestPath}`);
952
960
  if (result.promptPath) console.log(`prompt: ${result.promptPath}`);
953
961
  if (result.requestPayloadPath) console.log(`request: ${result.requestPayloadPath}`);
962
+ if (result.failureReason) console.log(`failure: ${result.failureReason}`);
954
963
  if (result.reason) console.log(`reason: ${result.reason}`);
955
964
  }
956
965
 
@@ -994,6 +1003,7 @@ async function handleImageCommand(argv, { commandCwd = process.cwd() } = {}) {
994
1003
  imageSize: parsed.imageSize || undefined,
995
1004
  host: parsed.host || undefined,
996
1005
  referenceImages: parsed.referenceImages,
1006
+ matchReferenceSize: parsed.matchReferenceSize,
997
1007
  requestTimeoutMs: parsed.requestTimeoutMs || undefined,
998
1008
  pollTimeoutMs: parsed.pollTimeoutMs || undefined,
999
1009
  pollIntervalMs: parsed.pollIntervalMs || undefined,
@@ -1107,6 +1117,7 @@ function stripLeadingGlobalOptions(argv = []) {
1107
1117
  let index = 0;
1108
1118
  const options = {
1109
1119
  commandCwd: "",
1120
+ language: "",
1110
1121
  };
1111
1122
  while (index < argv.length) {
1112
1123
  const arg = argv[index];
@@ -1115,7 +1126,15 @@ function stripLeadingGlobalOptions(argv = []) {
1115
1126
  continue;
1116
1127
  }
1117
1128
  if (arg === "--language" || arg === "--lang" || arg === "-L") {
1118
- index += readOption(argv, index) ? 2 : 1;
1129
+ const first = readOption(argv, index);
1130
+ const second = argv[index + 2] && !String(argv[index + 2]).startsWith("--") ? argv[index + 2] : "";
1131
+ if (["cn", "zh"].includes(String(first || "").toLowerCase()) && ["s", "t"].includes(String(second || "").toLowerCase())) {
1132
+ options.language = resolveLanguage(`${first}-${second}`);
1133
+ index += 3;
1134
+ continue;
1135
+ }
1136
+ options.language = resolveLanguage(first);
1137
+ index += first ? 2 : 1;
1119
1138
  continue;
1120
1139
  }
1121
1140
  if (arg === "--cwd") {
@@ -1992,7 +2011,7 @@ async function handleStorageCommand(argv) {
1992
2011
  }
1993
2012
 
1994
2013
  export async function main(argv = process.argv.slice(2)) {
1995
- if (argv[0] === "help" || argv.includes("--help") || argv.includes("-h")) {
2014
+ if (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
1996
2015
  printUsage();
1997
2016
  return;
1998
2017
  }
@@ -2268,7 +2287,7 @@ export async function main(argv = process.argv.slice(2)) {
2268
2287
  const parsedResumeOptions = parseArgs(resumeOptions.optionArgv);
2269
2288
  exitOnUnknownOptions(parsedResumeOptions);
2270
2289
  const webLaunch = await maybeEnsureDefaultWebApp(parsedResumeOptions, { commandCwd });
2271
- await startInteractiveCli(agentDefaults({ ...parsedResumeOptions, resume: sessionId, commandCwd: parsedResumeOptions.commandCwd || commandCwd }), {
2290
+ await startInteractiveCli(agentDefaults({ ...parsedResumeOptions, language: parsedResumeOptions.language || stripped.options.language, resume: sessionId, commandCwd: parsedResumeOptions.commandCwd || commandCwd }), {
2272
2291
  packageDir,
2273
2292
  packageVersion: packageJson.version,
2274
2293
  webAppUrl: webLaunch.ok ? webLaunch.url : "",
@@ -2278,7 +2297,7 @@ export async function main(argv = process.argv.slice(2)) {
2278
2297
  }
2279
2298
  const parsedResumeArgs = parseArgs([...resumeOptions.optionArgv, prompt]);
2280
2299
  exitOnUnknownOptions(parsedResumeArgs);
2281
- const resumeArgs = agentDefaults({ ...parsedResumeArgs, resume: sessionId, goal: prompt, commandCwd: parsedResumeArgs.commandCwd || commandCwd });
2300
+ const resumeArgs = agentDefaults({ ...parsedResumeArgs, language: parsedResumeArgs.language || stripped.options.language, resume: sessionId, goal: prompt, commandCwd: parsedResumeArgs.commandCwd || commandCwd });
2282
2301
  if (!(await ensureDeepSeekKeyForOneShot(resumeArgs))) process.exit(1);
2283
2302
  await maybeEnsureDefaultWebApp(resumeArgs, { commandCwd });
2284
2303
  const config = loadConfig(resumeArgs, { packageDir });
@@ -2287,7 +2306,7 @@ export async function main(argv = process.argv.slice(2)) {
2287
2306
  }
2288
2307
 
2289
2308
  const parsedArgs = parseArgs(commandArgv);
2290
- const args = { ...parsedArgs, commandCwd: parsedArgs.commandCwd || commandCwd };
2309
+ const args = { ...parsedArgs, language: parsedArgs.language || stripped.options.language, commandCwd: parsedArgs.commandCwd || commandCwd };
2291
2310
  exitOnUnknownOptions(args);
2292
2311
 
2293
2312
  if (args.webapp) {
package/src/project.js CHANGED
@@ -52,6 +52,16 @@ const LOCAL_ENV_KEYS = new Set([
52
52
  "AGINTI_WRAPPER_REASONING",
53
53
  "AGINTI_AUX_PROVIDER",
54
54
  "AGINTI_AUX_MODEL",
55
+ "AGINTI_WRITING_PROVIDER",
56
+ "AGINTI_WRITING_MODEL",
57
+ "AGINTI_WRITING_PROVIDER_ZH",
58
+ "AGINTI_WRITING_MODEL_ZH",
59
+ "AGINTI_WRITING_PROVIDER_EN",
60
+ "AGINTI_WRITING_MODEL_EN",
61
+ "AGINTI_WRITING_PROVIDER_JA",
62
+ "AGINTI_WRITING_MODEL_JA",
63
+ "AGINTI_WRITING_PROVIDER_KO",
64
+ "AGINTI_WRITING_MODEL_KO",
55
65
  "QWEN_API_KEY",
56
66
  "QWEN_DEFAULT_MODEL",
57
67
  "QWEN_BASE_URL",
@@ -211,6 +211,7 @@ function fallbackHardContractPlan(goal = "", contract = {}, studentReason = "")
211
211
  const exactInputPaths = normalizeStringList(contract.exactInputPaths, []);
212
212
  const requiredTextTerms = normalizeStringList(contract.requiredTextTerms, []);
213
213
  const forbiddenTextTerms = normalizeStringList(contract.forbiddenTextTerms, []);
214
+ const requiredToolCalls = normalizeStringList(contract.requiredToolCalls, []);
214
215
  return [
215
216
  "1. Execute the user's target work under the deterministic hard-contract fallback plan.",
216
217
  exactOutputPaths.length
@@ -219,9 +220,10 @@ function fallbackHardContractPlan(goal = "", contract = {}, studentReason = "")
219
220
  exactInputPaths.length ? `3. Use these exact user-specified input/reference path(s): ${exactInputPaths.join(", ")}.` : "",
220
221
  requiredTextTerms.length ? `4. Ensure the output contains these required term(s): ${requiredTextTerms.join(", ")}.` : "",
221
222
  forbiddenTextTerms.length ? `5. Ensure the output does not contain these forbidden term(s): ${forbiddenTextTerms.join(", ")}.` : "",
222
- "6. Run concrete validation commands or inspections for file existence and content before finish.",
223
- studentReason ? `7. Preserve the validator concern while executing: ${compact(studentReason, 180)}` : "",
224
- goal ? `8. Original goal remains authoritative: ${compact(goal, 220)}` : "",
223
+ requiredToolCalls.length ? `6. Call these explicitly required tool(s) before finish: ${requiredToolCalls.join(", ")}.` : "",
224
+ "7. Run concrete validation commands or inspections for file existence and content before finish.",
225
+ studentReason ? `8. Preserve the validator concern while executing: ${compact(studentReason, 180)}` : "",
226
+ goal ? `9. Original goal remains authoritative: ${compact(goal, 220)}` : "",
225
227
  ]
226
228
  .filter(Boolean)
227
229
  .join("\n");
@@ -457,6 +457,33 @@ function inferForbiddenActions(goal = "") {
457
457
  return unique(forbidden).slice(0, 8);
458
458
  }
459
459
 
460
+ function inferRequiredToolCalls(goal = "") {
461
+ const source = stripForbiddenLanguage(String(goal || ""));
462
+ const knownTools = [
463
+ "writing_specialist",
464
+ "json_specialist",
465
+ "read_image",
466
+ "generate_image",
467
+ "send_to_canvas",
468
+ "start_long_job",
469
+ "long_job_status",
470
+ ];
471
+ const required = [];
472
+ for (const toolName of knownTools) {
473
+ const index = source.indexOf(toolName);
474
+ if (index < 0) continue;
475
+ const window = source.slice(Math.max(0, index - 90), Math.min(source.length, index + toolName.length + 90));
476
+ const strongToolInstruction =
477
+ /\b(?:must|explicitly|again|call|invoke|require(?:d|s)?\s+(?:tool|call|use))\b/i.test(window) ||
478
+ /必须(?:调用|使用)|必須(?:調用|使用)|明确(?:调用|使用)|明確(?:調用|使用)|再次(?:调用|使用)|调用|調用/.test(window);
479
+ const specialistUseInstruction = /_specialist$/.test(toolName) && (/\buse\b/i.test(window) || /使用/.test(window));
480
+ if (strongToolInstruction || specialistUseInstruction) {
481
+ required.push(toolName);
482
+ }
483
+ }
484
+ return unique(required).slice(0, 8);
485
+ }
486
+
460
487
  function stripForbiddenLanguage(goal = "") {
461
488
  return String(goal || "")
462
489
  .replace(/\b(do not|don't|dont|never|no need to|without)\s+([^.\n;]+)/gi, "")
@@ -466,7 +493,8 @@ function stripForbiddenLanguage(goal = "") {
466
493
 
467
494
  export function deriveScsTaskContract({ goal = "", taskProfile = "", acceptanceCriteria = [] } = {}) {
468
495
  const requirementCategories = inferRequirementCategories(goal, taskProfile, acceptanceCriteria);
469
- const requiresExternalEvidence = requirementCategories.length > 0 || goalRequiresEvidence(goal, taskProfile);
496
+ const requiredToolCalls = inferRequiredToolCalls(goal);
497
+ const requiresExternalEvidence = requirementCategories.length > 0 || requiredToolCalls.length > 0 || goalRequiresEvidence(goal, taskProfile);
470
498
  const requiredEvidence = requirementCategories.map((category) => ({
471
499
  id: category,
472
500
  category,
@@ -483,6 +511,7 @@ export function deriveScsTaskContract({ goal = "", taskProfile = "", acceptanceC
483
511
  forbiddenActions: inferForbiddenActions(goal),
484
512
  exactOutputPaths,
485
513
  exactInputPaths,
514
+ requiredToolCalls,
486
515
  requiredTextTerms: inferRequiredTextTerms(goal),
487
516
  forbiddenTextTerms: inferForbiddenTextTerms(goal),
488
517
  successCriteria: unique(acceptanceCriteria).slice(0, 10),
@@ -679,6 +708,7 @@ export function buildScsEvidenceLedger({ state = {}, context = {} } = {}) {
679
708
  const messageEvidence = messages.flatMap(messageToEvidence);
680
709
  const items = [...eventEvidence, ...messageEvidence].slice(-80);
681
710
  const categories = unique(items.map((item) => item.category));
711
+ const toolNames = unique(items.map((item) => item.toolName).filter(Boolean));
682
712
  const blockers = [...events.map(eventToBlocker), ...messages.map(messageToBlocker)]
683
713
  .filter(Boolean)
684
714
  .slice(-20)
@@ -690,6 +720,7 @@ export function buildScsEvidenceLedger({ state = {}, context = {} } = {}) {
690
720
  version: 1,
691
721
  itemCount: items.length,
692
722
  categories,
723
+ toolNames,
693
724
  blockerCount: blockers.length,
694
725
  blockers,
695
726
  items: items.map((item, index) => ({
@@ -702,6 +733,8 @@ export function buildScsEvidenceLedger({ state = {}, context = {} } = {}) {
702
733
  export function evaluateScsEvidence(contract = {}, ledger = {}) {
703
734
  const required = Array.isArray(contract.requiredEvidence) ? contract.requiredEvidence : [];
704
735
  const ledgerCategories = new Set(Array.isArray(ledger.categories) ? ledger.categories : []);
736
+ const requiredToolCalls = Array.isArray(contract.requiredToolCalls) ? contract.requiredToolCalls : [];
737
+ const ledgerToolNames = new Set(Array.isArray(ledger.toolNames) ? ledger.toolNames : []);
705
738
  const satisfied = [];
706
739
  const missing = [];
707
740
  for (const requirement of required) {
@@ -711,17 +744,23 @@ export function evaluateScsEvidence(contract = {}, ledger = {}) {
711
744
  missing.push(requirement);
712
745
  }
713
746
  }
747
+ const missingToolCalls = requiredToolCalls.filter((toolName) => !ledgerToolNames.has(toolName));
714
748
  const hasAnyEvidence = Number(ledger.itemCount || 0) > 0;
715
- const ok = !contract.requiresExternalEvidence || (missing.length === 0 && hasAnyEvidence);
749
+ const evidenceOk = !contract.requiresExternalEvidence || (missing.length === 0 && hasAnyEvidence);
750
+ const ok = evidenceOk && missingToolCalls.length === 0;
716
751
  return {
717
752
  ok,
718
753
  requiresExternalEvidence: Boolean(contract.requiresExternalEvidence),
719
754
  hasAnyEvidence,
720
755
  satisfied,
721
756
  missing,
757
+ requiredToolCalls,
758
+ missingToolCalls,
722
759
  reason: ok
723
760
  ? "Evidence satisfies the deterministic task contract."
724
- : missing.length
761
+ : missingToolCalls.length
762
+ ? `Missing required tool calls: ${missingToolCalls.join(", ")}.`
763
+ : missing.length
725
764
  ? `Missing evidence categories: ${missing.map((item) => item.category).join(", ")}.`
726
765
  : "Task requires external evidence but the ledger is empty.",
727
766
  };
@@ -741,6 +780,7 @@ export function summarizeScsContractEvidence({ contract = {}, ledger = {}, evalu
741
780
  forbiddenActions: contract.forbiddenActions || [],
742
781
  exactOutputPaths: contract.exactOutputPaths || [],
743
782
  exactInputPaths: contract.exactInputPaths || [],
783
+ requiredToolCalls: contract.requiredToolCalls || [],
744
784
  requiredTextTerms: contract.requiredTextTerms || [],
745
785
  forbiddenTextTerms: contract.forbiddenTextTerms || [],
746
786
  successCriteria: contract.successCriteria || [],
@@ -748,6 +788,7 @@ export function summarizeScsContractEvidence({ contract = {}, ledger = {}, evalu
748
788
  evidenceLedger: {
749
789
  itemCount: ledger.itemCount || 0,
750
790
  categories: ledger.categories || [],
791
+ toolNames: ledger.toolNames || [],
751
792
  recentItems: (ledger.items || []).slice(-12).map((item) => ({
752
793
  id: item.id,
753
794
  category: item.category,
@@ -770,6 +811,7 @@ export function summarizeScsContractEvidence({ contract = {}, ledger = {}, evalu
770
811
  ok: Boolean(evaluation.ok),
771
812
  reason: evaluation.reason || "",
772
813
  missing: (evaluation.missing || []).map((item) => item.category),
814
+ missingToolCalls: evaluation.missingToolCalls || [],
773
815
  satisfied: (evaluation.satisfied || []).map((item) => item.category),
774
816
  },
775
817
  };
@@ -794,6 +836,7 @@ export function deterministicFinishBlocker(contract = {}, ledger = {}, evaluatio
794
836
  evidence: [
795
837
  `Required: ${(contract.requiredEvidence || []).map((item) => item.category).join(", ") || "external evidence"}`,
796
838
  `Present: ${(ledger.categories || []).join(", ") || "none"}`,
839
+ ...(evaluation.missingToolCalls?.length ? [`Required tool calls missing: ${evaluation.missingToolCalls.join(", ")}`] : []),
797
840
  ],
798
841
  nextRequiredAction:
799
842
  "Collect the missing concrete evidence, verify the requested state or artifact, then ask SCS to finish again; if impossible, report a real blocker with proof.",
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { XMLValidator } from "fast-xml-parser";
4
5
  import { hasSensitiveText, redactSensitiveText } from "./redaction.js";
5
6
 
6
7
  export const WORKSPACE_TOOL_NAMES = ["inspect_project", "list_files", "read_file", "search_files", "write_file", "apply_patch"];
@@ -174,6 +175,13 @@ function validateSvgArtifact(relativePath, content) {
174
175
  const cdataOpen = (trimmed.match(/<!\[CDATA\[/gi) || []).length;
175
176
  const cdataClose = (trimmed.match(/\]\]>/g) || []).length;
176
177
  if (cdataOpen !== cdataClose) errors.push("CDATA open/close markers are unbalanced.");
178
+ const xmlValidation = trimmed ? XMLValidator.validate(trimmed, { allowBooleanAttributes: false }) : true;
179
+ if (xmlValidation !== true) {
180
+ const err = xmlValidation?.err || {};
181
+ errors.push(
182
+ `XML parser rejected SVG${err.line ? ` at line ${err.line}${err.col ? `:${err.col}` : ""}` : ""}: ${err.msg || "invalid XML syntax"}`
183
+ );
184
+ }
177
185
  errors.push(...validateXmlTagStack(trimmed));
178
186
  if (/<text\b/i.test(trimmed) && !/(viewBox|width|height)=/i.test(trimmed)) {
179
187
  warnings.push("SVG contains text but no obvious sizing/viewBox metadata; render-preview before claiming visual fit.");
@@ -181,7 +189,7 @@ function validateSvgArtifact(relativePath, content) {
181
189
  return {
182
190
  kind: "svg",
183
191
  ok: errors.length === 0,
184
- parser: "builtin-svg-xml-guard",
192
+ parser: "fast-xml-parser+xml-stack-guard",
185
193
  root: root || "",
186
194
  errors,
187
195
  warnings,
@@ -87,8 +87,62 @@ function normalizeWritingRequest(args = {}) {
87
87
  length: normalizeText(args.length || args.targetLength || ""),
88
88
  formatIntent: normalizeText(args.formatIntent || args.outputFormat || ""),
89
89
  temperature: normalizeTemperature(args.temperature, kind),
90
- provider: String(args.provider || process.env.AGINTI_WRITING_PROVIDER || "").trim(),
91
- model: String(args.model || process.env.AGINTI_WRITING_MODEL || "").trim(),
90
+ provider: String(args.provider || "").trim(),
91
+ model: String(args.model || "").trim(),
92
+ };
93
+ }
94
+
95
+ function likelyLanguageFromText(request = {}) {
96
+ const explicit = String(request.language || "").trim();
97
+ if (explicit) return explicit;
98
+ const text = [request.writingBrief, request.target, request.audience, request.canon, request.styleGuide, request.priorDraft, request.constraints]
99
+ .join("\n")
100
+ .slice(0, 8000);
101
+ if (/[\u3040-\u30ff]/.test(text)) return "ja";
102
+ if (/[\uac00-\ud7af]/.test(text)) return "ko";
103
+ if (/[\u4e00-\u9fff]/.test(text)) return "zh-Hans";
104
+ return "en";
105
+ }
106
+
107
+ function languageEnvSuffix(language = "") {
108
+ const normalized = String(language || "").toLowerCase();
109
+ if (normalized.startsWith("zh")) return "ZH";
110
+ if (normalized.startsWith("ja")) return "JA";
111
+ if (normalized.startsWith("ko")) return "KO";
112
+ if (normalized.startsWith("en")) return "EN";
113
+ return "";
114
+ }
115
+
116
+ function hasProviderKey(provider = "") {
117
+ const normalized = String(provider || "").toLowerCase();
118
+ if (normalized === "openai") return Boolean(process.env.OPENAI_API_KEY || process.env.LLM_API_KEY);
119
+ if (normalized === "deepseek") return Boolean(process.env.DEEPSEEK_API_KEY || process.env.LLM_API_KEY);
120
+ if (normalized === "openrouter") return Boolean(process.env.OPENROUTER_API_KEY);
121
+ if (normalized === "qwen") return Boolean(process.env.QWEN_API_KEY);
122
+ if (normalized === "venice") return Boolean(process.env.VENICE_API_KEY);
123
+ if (normalized === "mock") return true;
124
+ return false;
125
+ }
126
+
127
+ export function languageWriterDefaults(request = {}, config = {}) {
128
+ const language = likelyLanguageFromText(request);
129
+ const suffix = languageEnvSuffix(language);
130
+ const envProvider = String((suffix && process.env[`AGINTI_WRITING_PROVIDER_${suffix}`]) || process.env.AGINTI_WRITING_PROVIDER || "").trim();
131
+ const envModel = String((suffix && process.env[`AGINTI_WRITING_MODEL_${suffix}`]) || process.env.AGINTI_WRITING_MODEL || "").trim();
132
+ if (envProvider || envModel) {
133
+ return { provider: envProvider, model: envModel, language, reason: "writer-env" };
134
+ }
135
+ if (String(language || "").toLowerCase().startsWith("zh") && hasProviderKey("deepseek")) {
136
+ return { provider: "deepseek", model: process.env.DEEPSEEK_PRO_MODEL || "deepseek-v4-pro", language, reason: "zh-deepseek-default" };
137
+ }
138
+ if (String(language || "").toLowerCase().startsWith("en") && hasProviderKey("openai")) {
139
+ return { provider: "openai", model: process.env.OPENAI_DEFAULT_MODEL || process.env.LLM_MODEL || "gpt-5.4", language, reason: "en-openai-default" };
140
+ }
141
+ return {
142
+ provider: config.provider || "",
143
+ model: config.model || "",
144
+ language,
145
+ reason: "session-default",
92
146
  };
93
147
  }
94
148
 
@@ -193,8 +247,9 @@ export async function runWritingSpecialist(args = {}, config = {}, store = null)
193
247
  .update(JSON.stringify(redactValue(request)))
194
248
  .digest("hex");
195
249
  let result;
196
- let model = request.model || config.model || "";
197
- let provider = request.provider || config.provider || "";
250
+ const writerDefaults = languageWriterDefaults(request, config);
251
+ let model = request.model || writerDefaults.model || config.model || "";
252
+ let provider = request.provider || writerDefaults.provider || config.provider || "";
198
253
  let rawContent = "";
199
254
 
200
255
  try {
@@ -204,11 +259,13 @@ export async function runWritingSpecialist(args = {}, config = {}, store = null)
204
259
  result = mockWritingResult(request);
205
260
  } else {
206
261
  const providerDefaults = request.provider ? getProviderDefaults(request.provider) : {};
262
+ const selectedProviderDefaults = provider ? getProviderDefaults(provider) : {};
207
263
  const writingConfig = {
208
264
  ...config,
265
+ ...selectedProviderDefaults,
209
266
  ...providerDefaults,
210
267
  provider: provider || config.provider,
211
- model: model || providerDefaults.model || config.model,
268
+ model: model || providerDefaults.model || selectedProviderDefaults.model || config.model,
212
269
  };
213
270
  model = writingConfig.model;
214
271
  provider = writingConfig.provider;
@@ -254,9 +311,11 @@ export async function runWritingSpecialist(args = {}, config = {}, store = null)
254
311
  task: request.task,
255
312
  kind: request.kind,
256
313
  language: request.language,
314
+ detectedLanguage: writerDefaults.language,
257
315
  target: request.target,
258
316
  length: request.length,
259
317
  provider: request.provider,
318
+ routeReason: writerDefaults.reason,
260
319
  formatIntent: request.formatIntent,
261
320
  requestFingerprint,
262
321
  },
@@ -273,8 +332,10 @@ export async function runWritingSpecialist(args = {}, config = {}, store = null)
273
332
  task: request.task,
274
333
  kind: request.kind,
275
334
  language: request.language,
335
+ detectedLanguage: writerDefaults.language,
276
336
  target: request.target,
277
337
  provider: request.provider,
338
+ routeReason: writerDefaults.reason,
278
339
  requestFingerprint,
279
340
  },
280
341
  error: redactSensitiveText(error instanceof Error ? error.message : String(error)),