@dtlabs/ai-toolkit 0.17.4 → 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,13 @@
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
+
2
11
  ## [0.17.4](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.3...v0.17.4) (2026-10-13)
3
12
 
4
13
 
@@ -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,30 @@ 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
+
308
360
  // A model asked to emit a JSON blob as part of a chat reply (rather than through a real
309
361
  // serializer, i.e. whenever structured_output didn't get populated and extraction has to
310
362
  // recover the payload from free text) is prone to one specific, well-understood defect: a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.17.4",
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)'