@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 +9 -0
- package/lib/workflow-control.js +65 -13
- package/package.json +1 -1
- package/src/claude/workflows/pm-phase1.js +22 -2
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
|
|
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,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
|
@@ -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)'
|