@dtlabs/ai-toolkit 0.17.3 → 0.17.5

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/CHANGELOG.md CHANGED
@@ -1,4 +1,21 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.17.5](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.4...v0.17.5) (2026-10-13)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **pm-phase1:** detect truncated Requirements/Tech-Spec instead of silently accepting them ([0783fe1](https://github.com/dtlab-tech/ai-toolkit/commit/0783fe1639258ea9701bffc647b9d0a54a85d3cd))
8
+ * **workflow-control, pm-phase1:** retry transient API errors; catch truncated Gate 1 docs ([47d4f2c](https://github.com/dtlab-tech/ai-toolkit/commit/47d4f2c47f79ac62b5247b44aac13dd2c80a33ad))
9
+ * **workflow-control:** retry worker dispatch on transient CLI/SDK API errors ([d75d02b](https://github.com/dtlab-tech/ai-toolkit/commit/d75d02b866a51ffde51bb276fdf21a60bd38c578))
10
+
11
+ ## [0.17.4](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.3...v0.17.4) (2026-10-13)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **workflow-control:** repair unescaped control chars in recovered JSON ([ebeddb5](https://github.com/dtlab-tech/ai-toolkit/commit/ebeddb5465ac988f94bd28d20b7f9dfdd967c2c9))
17
+ * **workflow-control:** repair unescaped control chars in recovered JSON ([0ce2466](https://github.com/dtlab-tech/ai-toolkit/commit/0ce24666b6d4d09b25ca71b1ed3b9f0ba7c06d7a))
18
+
2
19
  ## [0.17.3](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.2...v0.17.3) (2026-10-12)
3
20
 
4
21
 
@@ -169,28 +169,56 @@ function createControl(options) {
169
169
  });
170
170
  (options.log || (() => {}))('Worker evidence: ' + evidence.file);
171
171
  try {
172
- const result = await (options.dispatch || spawnClaudeAgent)({
173
- claudePath: options.claudePath, cwd: root, args: cliArgs, prompt,
174
- taskTimeoutMs: options.taskTimeoutMs || 1800000,
175
- });
172
+ // Retry a bounded number of times, but ONLY for a recognizable transient
173
+ // infrastructure failure — a CLI/SDK-level "API Error: ..." (stream idle
174
+ // timeout, overload, etc.), never a permission denial, schema violation,
175
+ // or any other deterministic content failure, which would just fail the
176
+ // same way again. Confirmed safe to retry: every agent dispatched through
177
+ // this path is read-only by design (no Write tool — see this function's
178
+ // own comment on why outputs are host-written, not agent-written; any
179
+ // Bash access granted is explicitly scoped to read-only assessment), so
180
+ // there is no partial side effect from a dead stream to clean up before
181
+ // retrying. Token usage from EVERY attempt is accumulated — a failed
182
+ // attempt still burns real output tokens before the stream dies — and
183
+ // each attempt's outcome is kept in evidence for diagnosing a recurring
184
+ // pattern like this one, rather than only ever seeing the last attempt.
185
+ let result;
186
+ let accumulatedTokens = 0;
187
+ const attempts = [];
188
+ for (let attempt = 1; attempt <= WORKER_MAX_ATTEMPTS; attempt++) {
189
+ result = await (options.dispatch || spawnClaudeAgent)({
190
+ claudePath: options.claudePath, cwd: root, args: cliArgs, prompt,
191
+ taskTimeoutMs: options.taskTimeoutMs || 1800000,
192
+ });
193
+ const usage = result.result?.usage;
194
+ const counts = usage && [usage.input_tokens, usage.output_tokens,
195
+ usage.cache_read_input_tokens ?? 0, usage.cache_creation_input_tokens ?? 0];
196
+ const tokens = counts && counts.every(n => Number.isInteger(n) && n >= 0)
197
+ ? counts.reduce((a, b) => a + b, 0) : null;
198
+ const attemptTokens = Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
199
+ if (attemptTokens) accumulatedTokens += attemptTokens;
200
+ attempts.push({ attempt, exitCode: result.exitCode, isError: result.result?.is_error ?? null,
201
+ resultPreview: typeof result.result?.result === 'string' ? result.result.result.slice(0, 300) : null,
202
+ tokens: attemptTokens });
203
+ evidence.save({ attempts });
204
+ if (!_isTransientWorkerApiError(result) || attempt === WORKER_MAX_ATTEMPTS) break;
205
+ await _sleep(WORKER_RETRY_BACKOFF_MS[attempt - 1]);
206
+ }
176
207
  // Count usage before any business/protocol check, including denied tools.
177
- const usage = result.result?.usage;
178
- const counts = usage && [usage.input_tokens, usage.output_tokens,
179
- usage.cache_read_input_tokens ?? 0, usage.cache_creation_input_tokens ?? 0];
180
- const tokens = counts && counts.every(n => Number.isInteger(n) && n >= 0)
181
- ? counts.reduce((a, b) => a + b, 0) : null;
182
- measuredTokens = Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
208
+ measuredTokens = accumulatedTokens > 0 ? accumulatedTokens : null;
183
209
  spent += measuredTokens || 0;
184
210
  tokenLedger.push({ agent: identity.nativeName, model: definition.model,
185
- phase_delta_tokens: measuredTokens, usage: usage || null, evidence_path: evidence.file });
186
- evidence.save({ response: result });
211
+ phase_delta_tokens: measuredTokens, usage: result.result?.usage || null, evidence_path: evidence.file });
212
+ evidence.save({ response: result, attempts });
187
213
  const denials = result.result?.permission_denials;
188
214
  if (Array.isArray(denials) && denials.length) {
189
215
  const tools = [...new Set(denials.map(d => d.tool_name || 'unknown'))].join(', ');
190
216
  throw new Error('Worker permission denied: ' + id + '; tools: ' + tools);
191
217
  }
192
218
  if (result.exitCode !== 0 || result.parseError || result.timedOut ||
193
- !result.result || result.result.is_error !== false) throw new Error('Worker failed: ' + id);
219
+ !result.result || result.result.is_error !== false) {
220
+ throw new Error('Worker failed: ' + id + (attempts.length > 1 ? ' (after ' + attempts.length + ' attempts)' : ''));
221
+ }
194
222
  let payload = opts.schema ? result.result.structured_output : result.result.result;
195
223
  let usedTextFallback = false;
196
224
  // The CLI's structured-output extraction is turn-bound: it only populates
@@ -305,6 +333,65 @@ function createControl(options) {
305
333
  return api;
306
334
  }
307
335
 
336
+ // worker()'s retry policy for transient CLI/SDK-level infrastructure failures (e.g. "API
337
+ // Error: Stream idle timeout - no chunks received") — confirmed against a real pipeline run
338
+ // that failed 3/3 identically with no automatic recovery, forcing the whole workflow to be
339
+ // retried by hand from the CLI. 3 total attempts, waiting 5s then 20s between them: long
340
+ // enough for a brief network stall to clear, short enough not to turn a real, deterministic
341
+ // failure into a multi-minute stall before it's reported.
342
+ const WORKER_MAX_ATTEMPTS = 3;
343
+ const WORKER_RETRY_BACKOFF_MS = [5000, 20000];
344
+
345
+ function _sleep(ms) {
346
+ return new Promise(resolve => setTimeout(resolve, ms));
347
+ }
348
+
349
+ // Recognizes ONLY the CLI/SDK's own infrastructure-error reporting convention — never a
350
+ // blanket "any failure is retryable" rule. A permission denial, a schema violation, or any
351
+ // other content-level failure will fail the exact same way on a retry, so retrying those
352
+ // would only burn tokens and time for no benefit; this checks specifically for the "API
353
+ // Error:" prefix the CLI uses for its own transport/provider-level errors, which (unlike
354
+ // those) are plausibly transient.
355
+ function _isTransientWorkerApiError(result) {
356
+ return !!(result && result.result && result.result.is_error === true &&
357
+ typeof result.result.result === 'string' && /^API Error:/i.test(result.result.result.trim()));
358
+ }
359
+
360
+ // A model asked to emit a JSON blob as part of a chat reply (rather than through a real
361
+ // serializer, i.e. whenever structured_output didn't get populated and extraction has to
362
+ // recover the payload from free text) is prone to one specific, well-understood defect: a
363
+ // long free-text field (a "report") gets its literal line breaks typed as real newlines
364
+ // instead of the required `\n` escape. The JSON otherwise reads as entirely correct to a
365
+ // human, but JSON.parse rejects it outright ("Bad control character in string literal") —
366
+ // confirmed against a real run where the model's JSON was discarded for exactly this reason
367
+ // despite carrying the correct ```json fence tag and schema-correct structure. This is a
368
+ // narrow, mechanical repair for that one defect, not a lenient parser: it tracks whether
369
+ // each character lies inside a double-quoted JSON string literal (honoring \" and \\
370
+ // escapes) and re-escapes a raw newline/CR/tab ONLY there, leaving structural whitespace
371
+ // between tokens untouched. Applied as a fallback after a plain JSON.parse fails, never in
372
+ // place of one, so already-valid JSON is never second-guessed.
373
+ function _repairUnescapedControlCharsInStrings(text) {
374
+ let result = '';
375
+ let inString = false;
376
+ let escaped = false;
377
+ for (let i = 0; i < text.length; i++) {
378
+ const ch = text[i];
379
+ if (inString) {
380
+ if (escaped) { result += ch; escaped = false; }
381
+ else if (ch === '\\') { result += ch; escaped = true; }
382
+ else if (ch === '"') { result += ch; inString = false; }
383
+ else if (ch === '\n') { result += '\\n'; }
384
+ else if (ch === '\r') { result += '\\r'; }
385
+ else if (ch === '\t') { result += '\\t'; }
386
+ else { result += ch; }
387
+ } else {
388
+ if (ch === '"') inString = true;
389
+ result += ch;
390
+ }
391
+ }
392
+ return result;
393
+ }
394
+
308
395
  // The model is not consistent about which delimiter it uses to mark its final structured
309
396
  // answer inside otherwise-free text — observed empirically, same agent and schema, run to
310
397
  // run: an <structured_output> tag, a ```json fence, and an unprompted <json> tag. Growing
@@ -332,8 +419,15 @@ function extractDelimitedStructuredOutput(text, schema) {
332
419
  const last = text.lastIndexOf('}');
333
420
  if (first !== -1 && last > first) candidates.push(text.slice(first, last + 1));
334
421
  for (const raw of candidates) {
422
+ const trimmed = raw.trim();
423
+ let parsed;
424
+ try {
425
+ parsed = JSON.parse(trimmed);
426
+ } catch (e) {
427
+ try { parsed = JSON.parse(_repairUnescapedControlCharsInStrings(trimmed)); }
428
+ catch (e2) { continue; }
429
+ }
335
430
  try {
336
- const parsed = JSON.parse(raw.trim());
337
431
  validateSchema(parsed, schema);
338
432
  return parsed;
339
433
  } catch (e) { /* try the next candidate, or fall through to null */ }
@@ -354,7 +448,18 @@ function extractDelimitedStructuredOutput(text, schema) {
354
448
  // remains the one thing that fails closed on it; this function only undoes wrapping, it is not
355
449
  // itself a validation gate.
356
450
  function extractJsonDocument(text) {
357
- const tryParse = candidate => { try { JSON.parse(candidate); return candidate; } catch (e) { return null; } };
451
+ // Same "try the candidate as-is, then try it with raw control characters inside
452
+ // string literals re-escaped" fallback as extractDelimitedStructuredOutput, and
453
+ // for the identical reason: a long free-text field (e.g. a task description)
454
+ // typed with literal line breaks instead of `\n` is otherwise indistinguishable
455
+ // from genuinely malformed JSON, even though it's one mechanical fix away from
456
+ // valid. Returns the (possibly repaired) STRING to write to disk, never the
457
+ // parsed object — this function's contract is text recovery, not parsing.
458
+ const tryParse = candidate => {
459
+ try { JSON.parse(candidate); return candidate; } catch (e) {}
460
+ const repaired = _repairUnescapedControlCharsInStrings(candidate);
461
+ try { JSON.parse(repaired); return repaired; } catch (e) { return null; }
462
+ };
358
463
  const whole = tryParse(text.trim());
359
464
  if (whole != null) return whole;
360
465
  const fenceMatches = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.17.3",
3
+ "version": "0.17.5",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -11,6 +11,24 @@ const force = args.includes('--force')
11
11
  return c.run(meta.name, featureDir, prefix, async () => {
12
12
  const file = suffix => `${featureDir}/${prefix}-${suffix}.md`
13
13
  const fresh = (output, inputs = [featurePath]) => c.exists(output) && c.read(output).trim().length > 0 && inputs.every(input => c.exists(input) && c.mtime(output) >= c.mtime(input))
14
+ // A long single-turn response (a feature complex enough to need many Use
15
+ // Cases/sections) can exceed the model's configured output-token ceiling
16
+ // mid-reply — confirmed against a real run where the CLI reported a clean
17
+ // exit (exitCode 0, is_error false, stop_reason "end_turn") with nothing in
18
+ // the response envelope signaling that anything was cut off. Only the
19
+ // document's own expected structure can catch this. Both Requirements (9
20
+ // top-level sections) and Tech-Spec (12) have a fixed, numbered skeleton
21
+ // (see their own agent definitions' "Output Structure"), so a missing
22
+ // "## N. " heading is a reliable, deterministic sign of truncation.
23
+ // Checked both right after generation (fail fast with an attributable
24
+ // error, not a silently-accepted half-document) and as part of the
25
+ // freshness test (so a previously truncated file left on disk from an
26
+ // earlier failed run is never mistaken for "fresh enough" on a later resume).
27
+ const missingSections = (content, count) => Array.from({ length: count }, (_, i) => i + 1).filter(n => !new RegExp(`^## ${n}\\.\\s`, 'm').test(content))
28
+ const assertCompleteSections = (label, content, count) => {
29
+ const missing = missingSections(content, count)
30
+ if (missing.length) throw new Error(`${label} output is incomplete: missing section(s) ${missing.join(', ')} of ${count} — likely truncated mid-generation (a model output-length ceiling was probably exceeded)`)
31
+ }
14
32
  const reqId = 'gaia.agent.planner.requirements'
15
33
  const specId = 'gaia.agent.planner.tech-spec'
16
34
  const valId = 'gaia.agent.planner.validate-feature-docs'
@@ -18,14 +36,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
18
36
  const requirements = () => c.worker(reqId, featurePath, { outputs: [output('Requirements')] })
19
37
  const techSpec = () => c.worker(specId, featurePath, { outputs: [output('Tech-Spec', [featurePath, file('Requirements')])] })
20
38
  let changed = false
21
- if (force || !fresh(file('Requirements'))) {
39
+ if (force || !fresh(file('Requirements')) || missingSections(c.read(file('Requirements')), 9).length) {
22
40
  await requirements()
23
41
  if (!c.exists(file('Requirements'))) throw new Error('Requirements output missing')
42
+ assertCompleteSections('Requirements', c.read(file('Requirements')), 9)
24
43
  changed = true
25
44
  }
26
- if (force || changed || !fresh(file('Tech-Spec'), [featurePath, file('Requirements')])) {
45
+ if (force || changed || !fresh(file('Tech-Spec'), [featurePath, file('Requirements')]) || missingSections(c.read(file('Tech-Spec')), 12).length) {
27
46
  await techSpec()
28
47
  if (!c.exists(file('Tech-Spec'))) throw new Error('Tech-Spec output missing')
48
+ assertCompleteSections('Tech-Spec', c.read(file('Tech-Spec')), 12)
29
49
  changed = true
30
50
  }
31
51
  let validationSummary = 'skipped (fresh)'