@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 +17 -0
- package/lib/workflow-control.js +120 -15
- package/package.json +1 -1
- package/src/claude/workflows/pm-phase1.js +22 -2
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
|
|
package/lib/workflow-control.js
CHANGED
|
@@ -169,28 +169,56 @@ function createControl(options) {
|
|
|
169
169
|
});
|
|
170
170
|
(options.log || (() => {}))('Worker evidence: ' + evidence.file);
|
|
171
171
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
@@ -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)'
|