@mjasnikovs/pi-task 0.23.1 → 0.24.1
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/dist/task/phases.d.ts +15 -0
- package/dist/task/phases.js +159 -10
- package/dist/task/requirements.d.ts +12 -1
- package/dist/task/requirements.js +113 -2
- package/dist/workers/pi-worker-core.d.ts +24 -0
- package/dist/workers/pi-worker-core.js +2 -0
- package/package.json +1 -1
package/dist/task/phases.d.ts
CHANGED
|
@@ -108,6 +108,21 @@ export declare function scopedToolingGoal(refined: string): string;
|
|
|
108
108
|
* partial text, so an empty degrade is never mistaken for a real finding.
|
|
109
109
|
*/
|
|
110
110
|
export declare function degradedSectionBody(name: string, reason: string, partial: string): string;
|
|
111
|
+
/**
|
|
112
|
+
* The body written for a research section the worker confirmed has no entries.
|
|
113
|
+
*
|
|
114
|
+
* Three states have to stay distinguishable to anyone — human or later phase —
|
|
115
|
+
* reading a research section, so each carries its own marker:
|
|
116
|
+
* `(none — …)` the worker RAN and answered "nothing applies" (this)
|
|
117
|
+
* `(degraded: …)` the worker was killed mid-answer, text may be partial
|
|
118
|
+
* (degradedSectionBody)
|
|
119
|
+
* section absent the worker never got that far — the phase threw
|
|
120
|
+
*
|
|
121
|
+
* Naming the worker inside the marker keeps it true after assembly, where the
|
|
122
|
+
* section headings are all that separate the four workers' output.
|
|
123
|
+
*/
|
|
124
|
+
export declare function emptySectionBody(name: string): string;
|
|
125
|
+
export declare function isBareNoneAnswer(text: string): boolean;
|
|
111
126
|
export declare function phaseResearch(deps: PhaseDeps, refined: string, researchDeps?: PhaseResearchDeps): Promise<string>;
|
|
112
127
|
export interface PhaseAutoAnswerDeps {
|
|
113
128
|
docsFocused?: typeof docsFocused;
|
package/dist/task/phases.js
CHANGED
|
@@ -55,6 +55,12 @@ export function extractToolingCommands(research) {
|
|
|
55
55
|
const line = raw.trim();
|
|
56
56
|
if (!line)
|
|
57
57
|
continue;
|
|
58
|
+
// A section MARKER describes the worker, not a command. Without this the
|
|
59
|
+
// "(none — the TOOLING worker ran …)" / "(degraded: …)" line is handed to the
|
|
60
|
+
// verify child as a command to run, which can only be rejected — noise in the
|
|
61
|
+
// prompt and one more thing that reads like a real tool in the task file.
|
|
62
|
+
if (/^\((?:none —|degraded:)/.test(line))
|
|
63
|
+
continue;
|
|
58
64
|
const match = line.match(/^\S.*?\s{2,}(.+)$/);
|
|
59
65
|
if (match) {
|
|
60
66
|
commands.push(match[1].trim());
|
|
@@ -300,11 +306,27 @@ export function scopedToolingGoal(refined) {
|
|
|
300
306
|
* (exit 143) OR a clean exit 0 with truncated text, so loopHit/timedOut — not
|
|
301
307
|
* exitCode — are the reliable signal and are checked first.
|
|
302
308
|
*
|
|
303
|
-
* - 'fatal' (non-zero exit that isn't a loop-kill,
|
|
304
|
-
* never-executed tool call): the output is
|
|
305
|
-
* can't paper over (broken env, model
|
|
306
|
-
* These still throw — degrading them
|
|
307
|
-
*
|
|
309
|
+
* - 'fatal' (non-zero exit that isn't a loop-kill, a provider error behind an
|
|
310
|
+
* empty answer, or a leaked never-executed tool call): the output is
|
|
311
|
+
* untrustworthy in a way partial text can't paper over (broken env, model
|
|
312
|
+
* disconnect, wrong tool-call dialect). These still throw — degrading them
|
|
313
|
+
* would launder a real breakage into a plausible-looking section.
|
|
314
|
+
*
|
|
315
|
+
* - 'empty' (clean exit 0, no provider error, no loop/timeout — the model simply
|
|
316
|
+
* wrote nothing): NOT a failure. On an extremely simple task ("create a folder
|
|
317
|
+
* with an index.html in it") three of the four workers have genuinely nothing
|
|
318
|
+
* to report, and each worker prompt tells the model to emit ONLY what this task
|
|
319
|
+
* touches and to drop everything else — so silence is the CORRECT answer and
|
|
320
|
+
* was killing the whole task at research (issue #10). Measured live on the
|
|
321
|
+
* issue's own prompt (30 reps/worker, local Qwen3.6-27B): every APIS answer was
|
|
322
|
+
* semantically "there is nothing here", and 2/30 were literally zero bytes on a
|
|
323
|
+
* clean exit — the other 28 survived only because the model happened to wrap the
|
|
324
|
+
* same non-answer in a parenthetical, which is model style, not signal. The
|
|
325
|
+
* caller retries once and then accepts an explicit empty section; what stays
|
|
326
|
+
* fatal is silence WITH a reported cause, which is the masked-disconnect case
|
|
327
|
+
* this branch was written for and which `modelError` now names outright.
|
|
328
|
+
*
|
|
329
|
+
* Returns null when the result is trustworthy.
|
|
308
330
|
*/
|
|
309
331
|
function classifyResearchWorker(name, result) {
|
|
310
332
|
if (result.loopHit) {
|
|
@@ -326,7 +348,33 @@ function classifyResearchWorker(name, result) {
|
|
|
326
348
|
};
|
|
327
349
|
}
|
|
328
350
|
if (result.text.trim().length === 0) {
|
|
329
|
-
|
|
351
|
+
// NOTHING CAME BACK — two different events wear the same face, and the whole
|
|
352
|
+
// point of this branch is to tell them apart:
|
|
353
|
+
//
|
|
354
|
+
// FAILED, cause reported: pi delivers a failed turn as an empty assistant
|
|
355
|
+
// message with stopReason "error" and exit 0, so the real cause used to be
|
|
356
|
+
// discarded and reported as the useless "produced no output". Name it.
|
|
357
|
+
// FAILED, child never spoke: no stdout at all means the child died before it
|
|
358
|
+
// could run (unresolvable provider, missing key, bad argv) — it never
|
|
359
|
+
// answered, so it cannot have answered "nothing".
|
|
360
|
+
// EMPTY: a child that streamed, exited 0, reported no error, and wrote no
|
|
361
|
+
// answer. The worker ran and the model had nothing to say — a real answer
|
|
362
|
+
// on a task that touches nothing, not a failure.
|
|
363
|
+
if (result.modelError) {
|
|
364
|
+
return {
|
|
365
|
+
kind: 'fatal',
|
|
366
|
+
error: new Error(`Research ${name} worker: model error — ${result.modelError.slice(0, 200)}`)
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (!result.sawOutput) {
|
|
370
|
+
return {
|
|
371
|
+
kind: 'fatal',
|
|
372
|
+
error: new Error(`Research ${name} worker produced no output — the child never wrote a `
|
|
373
|
+
+ 'single byte, so it died before it could answer'
|
|
374
|
+
+ (result.stderr ? `: ${result.stderr.slice(-300)}` : ''))
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return { kind: 'empty' };
|
|
330
378
|
}
|
|
331
379
|
if (result.leakedToolCall) {
|
|
332
380
|
return {
|
|
@@ -365,6 +413,67 @@ async function manifestDependencyNames(cwd) {
|
|
|
365
413
|
return [];
|
|
366
414
|
}
|
|
367
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* The body written for a research section the worker confirmed has no entries.
|
|
418
|
+
*
|
|
419
|
+
* Three states have to stay distinguishable to anyone — human or later phase —
|
|
420
|
+
* reading a research section, so each carries its own marker:
|
|
421
|
+
* `(none — …)` the worker RAN and answered "nothing applies" (this)
|
|
422
|
+
* `(degraded: …)` the worker was killed mid-answer, text may be partial
|
|
423
|
+
* (degradedSectionBody)
|
|
424
|
+
* section absent the worker never got that far — the phase threw
|
|
425
|
+
*
|
|
426
|
+
* Naming the worker inside the marker keeps it true after assembly, where the
|
|
427
|
+
* section headings are all that separate the four workers' output.
|
|
428
|
+
*/
|
|
429
|
+
export function emptySectionBody(name) {
|
|
430
|
+
return `(none — the ${name} worker ran and reported no entries for this task)`;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* A worker answer that IS the word "nothing" and carries no other content:
|
|
434
|
+
* `(none)`, `N/A`, `- none`, `(no content)`, `(no entries)`. Live workers write
|
|
435
|
+
* these often on a task that touches nothing (measured on the issue's prompt:
|
|
436
|
+
* `(no content)`, `(no response)`, a bare `(none)` from the gate's own retry),
|
|
437
|
+
* and each one means exactly what an empty answer means — so they are recorded
|
|
438
|
+
* with the same marker rather than passed through in whatever shape the model
|
|
439
|
+
* happened to pick. Deliberately NARROW: it matches only a lone token, never
|
|
440
|
+
* prose like "(no APIs to list — this task creates a plain HTML file …)", which
|
|
441
|
+
* carries a reason worth keeping.
|
|
442
|
+
*/
|
|
443
|
+
const BARE_NONE_ANSWER = /^[-*\s]*\(?\s*(?:none|n\/?a|nothing|no (?:content|entries|response|items|results))\s*\.?\s*\)?\s*$/i;
|
|
444
|
+
export function isBareNoneAnswer(text) {
|
|
445
|
+
return BARE_NONE_ANSWER.test(text.trim());
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Prepended on the ONE retry the empty-section gate triggers. A zero-byte answer is
|
|
449
|
+
* ambiguous — a crashed worker looks exactly like a worker with nothing to say — so
|
|
450
|
+
* the retry's only job is to remove the ambiguity: answer properly, or say "(none)"
|
|
451
|
+
* in as many words.
|
|
452
|
+
*
|
|
453
|
+
* It must NOT turn into an invitation to skip the work: `(none)` is offered only
|
|
454
|
+
* behind an explicit "after you have looked" condition, because this retry also
|
|
455
|
+
* fires on a normal project where the first attempt died for an unrelated reason,
|
|
456
|
+
* and an easy opt-out there would silence real research.
|
|
457
|
+
*
|
|
458
|
+
* MEASUREMENT OPEN. The recovery path's QUALITY on a real repo is being measured
|
|
459
|
+
* (scripts live under /home/edgars/tmp/issue10: first FILES answer faulted to
|
|
460
|
+
* empty, every other child live, against an uninterrupted control). First rep on
|
|
461
|
+
* an earlier wording did NOT take the `(none)` exit but drifted into writing code
|
|
462
|
+
* instead of listing paths — the deliverable-not-inputs failure the base prompt
|
|
463
|
+
* already forbids below this preamble. Blast radius is bounded: the gate fires
|
|
464
|
+
* only on a run that would otherwise have FAILED outright, so a mediocre recovered
|
|
465
|
+
* section is strictly better than the dead task it replaces — but if the drift
|
|
466
|
+
* reproduces, this preamble must restate the section's output contract, not just
|
|
467
|
+
* demand an answer.
|
|
468
|
+
*/
|
|
469
|
+
const EMPTY_SECTION_PREAMBLE = 'STOP. Your previous attempt returned an EMPTY answer — zero characters. An empty '
|
|
470
|
+
+ 'response cannot be accepted, because it is indistinguishable from a worker that '
|
|
471
|
+
+ 'crashed before it wrote anything. Answer again now, and do the research properly '
|
|
472
|
+
+ 'this time: look first, then write what you found, in the required format. Only if '
|
|
473
|
+
+ 'you have looked and there is genuinely nothing to report — the task touches no '
|
|
474
|
+
+ 'existing file, needs no external symbol, or the project has no such tooling — write '
|
|
475
|
+
+ 'exactly `(none)` and nothing else. Do not answer `(none)` to avoid the work, and '
|
|
476
|
+
+ 'never answer with silence.';
|
|
368
477
|
/**
|
|
369
478
|
* Prepended to worker:apis's prompt on the ONE retry the zero-retrieval gate triggers. It
|
|
370
479
|
* names the exact failure (a section written with no retrieval) so the correction is concrete,
|
|
@@ -638,6 +747,35 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
638
747
|
}
|
|
639
748
|
}));
|
|
640
749
|
let r = await runOnce();
|
|
750
|
+
// EMPTY-SECTION GATE (issue #10). A worker that returns zero bytes on a clean run
|
|
751
|
+
// used to fail the whole task ("Research APIS worker produced no output"), which is
|
|
752
|
+
// exactly what an extremely simple task provokes: with nothing on disk to survey and
|
|
753
|
+
// no external symbol in play, silence is the correct answer and the run died on it.
|
|
754
|
+
// Retry ONCE — silence is genuinely ambiguous, and a worker that crashed before
|
|
755
|
+
// writing deserves a second attempt — then accept an explicitly empty section. A
|
|
756
|
+
// provider error behind the silence is classified fatal below and never reaches here.
|
|
757
|
+
let confirmedEmpty = false;
|
|
758
|
+
if (classifyResearchWorker(spec.section, r)?.kind === 'empty') {
|
|
759
|
+
deps.logDebug?.(`${spec.label}: EMPTY answer on a clean exit — retrying once before`
|
|
760
|
+
+ ' accepting the section as having no entries');
|
|
761
|
+
deps.onChildOutput?.(`${spec.label}: empty — retrying`);
|
|
762
|
+
const retry = await runOnce(EMPTY_SECTION_PREAMBLE);
|
|
763
|
+
if (retry.text.trim().length > 0) {
|
|
764
|
+
deps.logDebug?.(`${spec.label}: retry answered (len=${retry.text.trim().length})`
|
|
765
|
+
+ ' — replacing the empty section');
|
|
766
|
+
r = retry;
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
confirmedEmpty = classifyResearchWorker(spec.section, retry)?.kind === 'empty';
|
|
770
|
+
deps.logDebug?.(`${spec.label}: retry STILL empty — `
|
|
771
|
+
+ (confirmedEmpty ?
|
|
772
|
+
'the worker ran twice and reported no entries; recording the'
|
|
773
|
+
+ ' section as empty (NOT a failure)'
|
|
774
|
+
: 'and this attempt did not run cleanly — failing the phase'));
|
|
775
|
+
if (!confirmedEmpty)
|
|
776
|
+
r = retry;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
641
779
|
// ZERO-RETRIEVAL GATE — a deterministic handle, not another instruction. A non-empty
|
|
642
780
|
// section produced with no grounding-retrieval call was written from memory; retry ONCE
|
|
643
781
|
// with a forced retrieval-first pass and keep the retry only if it actually retrieved.
|
|
@@ -675,7 +813,10 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
675
813
|
degradedSectionBody(spec.section, f.reason, res.text)
|
|
676
814
|
: res.text.trim();
|
|
677
815
|
};
|
|
678
|
-
|
|
816
|
+
// `confirmedEmpty` already spent a retry on exactly this ("you wrote nothing"), and
|
|
817
|
+
// the worker answered "nothing applies" a second time — re-asking here would just
|
|
818
|
+
// burn a third child for the same answer.
|
|
819
|
+
if (spec.retryIfSilent && !confirmedEmpty) {
|
|
679
820
|
const body = silentBodyOf(r);
|
|
680
821
|
const verdict = body === null ? null : classifyContextSilence(body);
|
|
681
822
|
if (verdict?.silent && verdict.genuineLoss) {
|
|
@@ -703,12 +844,20 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
703
844
|
const failure = classifyResearchWorker(spec.section, r);
|
|
704
845
|
if (failure?.kind === 'fatal')
|
|
705
846
|
throw failure.error;
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
847
|
+
// A worker that answers "nothing applies" is recorded the same way whether it
|
|
848
|
+
// said so with zero bytes (confirmedEmpty) or with a bare "(none)"/"N/A" — the
|
|
849
|
+
// two are the same answer, and only the marker makes either one distinguishable
|
|
850
|
+
// from a worker that never answered at all.
|
|
851
|
+
const rawText = failure?.kind === 'runaway' ? degradedSectionBody(spec.section, failure.reason, r.text)
|
|
852
|
+
: confirmedEmpty || isBareNoneAnswer(r.text) ? emptySectionBody(spec.section)
|
|
853
|
+
: r.text.trim();
|
|
709
854
|
if (failure?.kind === 'runaway') {
|
|
710
855
|
deps.logDebug?.(`${spec.label}: degraded — ${failure.reason}`);
|
|
711
856
|
}
|
|
857
|
+
if (!confirmedEmpty && isBareNoneAnswer(r.text)) {
|
|
858
|
+
deps.logDebug?.(`${spec.label}: answered "${r.text.trim().slice(0, 40)}" — recording it as`
|
|
859
|
+
+ ' an empty section (the worker ran and found nothing)');
|
|
860
|
+
}
|
|
712
861
|
// Post-check the worker's own output before it is persisted, so the cache a
|
|
713
862
|
// resume reads back is already gated. A degraded partial goes through it too —
|
|
714
863
|
// a truncated section can still carry a laundered claim.
|
|
@@ -31,7 +31,18 @@ export declare function keepGroundedRequirements(entries: RequirementEntry[], so
|
|
|
31
31
|
* never the model-authored anchor text. Without `sourceDoc` (or for quotes that
|
|
32
32
|
* cannot be located) the fill degrades to the old given-order behavior.
|
|
33
33
|
*/
|
|
34
|
-
export declare function capRequirements(entries: RequirementEntry[], passages: string[], sourceDoc?: string
|
|
34
|
+
export declare function capRequirements(entries: RequirementEntry[], passages: string[], sourceDoc?: string,
|
|
35
|
+
/** A/B seam: `false` reproduces the pre-budget rule, so an offline harness can
|
|
36
|
+
* score the shipped rule against the one it replaced without transcribing
|
|
37
|
+
* sectionFairFill and letting the copy drift. Production never passes it. */
|
|
38
|
+
deprioritiseLowValue?: boolean): RequirementEntry[];
|
|
39
|
+
/**
|
|
40
|
+
* Quotes that pass the grounding guard (verbatim substring of the doc) but state
|
|
41
|
+
* no obligation. There is no obligation test anywhere else in the pipeline —
|
|
42
|
+
* `keepGroundedRequirements` only checks the quote really appears in the source,
|
|
43
|
+
* so any sentence at all survives it and precision rests entirely on the model.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isLowValueQuote(quote: string): boolean;
|
|
35
46
|
/**
|
|
36
47
|
* DETERMINISTIC RECALL FLOOR (same medicine as the launch-contract checklist):
|
|
37
48
|
* paragraphs carrying an obligation marker (word-bounded "required"/"must").
|
|
@@ -98,7 +98,11 @@ export function keepGroundedRequirements(entries, sourceDoc) {
|
|
|
98
98
|
* never the model-authored anchor text. Without `sourceDoc` (or for quotes that
|
|
99
99
|
* cannot be located) the fill degrades to the old given-order behavior.
|
|
100
100
|
*/
|
|
101
|
-
export function capRequirements(entries, passages, sourceDoc
|
|
101
|
+
export function capRequirements(entries, passages, sourceDoc,
|
|
102
|
+
/** A/B seam: `false` reproduces the pre-budget rule, so an offline harness can
|
|
103
|
+
* score the shipped rule against the one it replaced without transcribing
|
|
104
|
+
* sectionFairFill and letting the copy drift. Production never passes it. */
|
|
105
|
+
deprioritiseLowValue = true) {
|
|
102
106
|
if (entries.length <= MAX_REQUIREMENTS)
|
|
103
107
|
return entries;
|
|
104
108
|
const norms = passages.map(normalise);
|
|
@@ -109,7 +113,114 @@ export function capRequirements(entries, passages, sourceDoc) {
|
|
|
109
113
|
const marked = entries.filter(covers);
|
|
110
114
|
const rest = entries.filter(e => !covers(e));
|
|
111
115
|
const budget = MAX_REQUIREMENTS - Math.min(marked.length, MAX_REQUIREMENTS);
|
|
112
|
-
|
|
116
|
+
// The low-value filter applies to the UNMARKED remainder only. Quoting an
|
|
117
|
+
// obligation-marked passage is the pipeline's existing, validated evidence
|
|
118
|
+
// that a quote states an obligation, and it outranks any lexical heuristic:
|
|
119
|
+
// "MUST log every request" is 22 characters and every length-based rule reads
|
|
120
|
+
// it as a fragment. Filtering ahead of the marked/rest split deleted it.
|
|
121
|
+
//
|
|
122
|
+
// INSURANCE, not a measured win on mx5: across both 30-run pools exactly one
|
|
123
|
+
// distinct quote per pool is low-value AND marked, and it is a genuinely
|
|
124
|
+
// truncated one. The layering matters for specs whose obligations are SHORT,
|
|
125
|
+
// which mx5's are not. Do not cite it as the reason tail coverage holds —
|
|
126
|
+
// tail coverage is identical with the filter applied before the split.
|
|
127
|
+
const pool = deprioritiseLowValue ? budgetedByObligation(rest, budget, sourceDoc) : rest;
|
|
128
|
+
return [...marked.slice(0, MAX_REQUIREMENTS), ...sectionFairFill(pool, budget, sourceDoc)];
|
|
129
|
+
}
|
|
130
|
+
/** Longest a dependency-pin row can be before it is presumed to carry an
|
|
131
|
+
* obligation after the pin. Real pins in the measured corpus run 32..48 chars;
|
|
132
|
+
* the pin-prefixed lines that DO obligate ("TypeScript `6.0.3` — one strict
|
|
133
|
+
* `tsconfig.json`: `strict`, `noUncheckedIndexedAccess`, …") run 130..260. */
|
|
134
|
+
const MAX_PIN_LENGTH = 80;
|
|
135
|
+
/** Cut mid-expression: an unbalanced fence or bracket, or a trailing separator.
|
|
136
|
+
* NOT `;` — a complete clause legitimately ends with one, and dropping on `;`
|
|
137
|
+
* discarded a runnable `lint` = `prettier … && eslint … && tsc --noEmit` line. */
|
|
138
|
+
function isTruncatedQuote(q) {
|
|
139
|
+
if ((q.match(/`/g) ?? []).length % 2 === 1)
|
|
140
|
+
return true;
|
|
141
|
+
for (const [open, close] of [
|
|
142
|
+
['(', ')'],
|
|
143
|
+
['[', ']'],
|
|
144
|
+
['{', '}']
|
|
145
|
+
]) {
|
|
146
|
+
const o = (q.match(new RegExp(`\\${open}`, 'g')) ?? []).length;
|
|
147
|
+
const c = (q.match(new RegExp(`\\${close}`, 'g')) ?? []).length;
|
|
148
|
+
if (o > c)
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return /[,:([{]\s*$/.test(q.trim());
|
|
152
|
+
}
|
|
153
|
+
/** A bare version row — data, not an obligation. Length-gated, see MAX_PIN_LENGTH. */
|
|
154
|
+
function isDependencyPin(q) {
|
|
155
|
+
const t = q.trim().replace(/^\*\*|\*\*$/g, '');
|
|
156
|
+
if (t.length > MAX_PIN_LENGTH)
|
|
157
|
+
return false;
|
|
158
|
+
return /^\**[`*]?[\w@/-]+[`*]?\**\s*[`']?\d+\.\d+/.test(t);
|
|
159
|
+
}
|
|
160
|
+
/** A DDL/schema row: `col type …`. */
|
|
161
|
+
function isSchemaRow(q) {
|
|
162
|
+
return /^\s*[\w_]+\s+(?:uuid|text|int|integer|bigint|boolean|timestamptz|bytea|jsonb|numeric|smallint)\b/i.test(q);
|
|
163
|
+
}
|
|
164
|
+
/** Too short to state an obligation. MIN_QUOTE_LENGTH (6) admits "Contact
|
|
165
|
+
* seller"; a clause needs a subject and a predicate. */
|
|
166
|
+
function isQuoteFragment(q) {
|
|
167
|
+
const t = q.trim();
|
|
168
|
+
return t.length < 25 || t.split(/\s+/).length < 4;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Quotes that pass the grounding guard (verbatim substring of the doc) but state
|
|
172
|
+
* no obligation. There is no obligation test anywhere else in the pipeline —
|
|
173
|
+
* `keepGroundedRequirements` only checks the quote really appears in the source,
|
|
174
|
+
* so any sentence at all survives it and precision rests entirely on the model.
|
|
175
|
+
*/
|
|
176
|
+
export function isLowValueQuote(quote) {
|
|
177
|
+
return (isTruncatedQuote(quote)
|
|
178
|
+
|| isDependencyPin(quote)
|
|
179
|
+
|| isSchemaRow(quote)
|
|
180
|
+
|| isQuoteFragment(quote));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Deprioritise obligation-free quotes, but only as far as the BUDGET requires.
|
|
184
|
+
*
|
|
185
|
+
* Measured (mx5, 20402-char spec, two independent 30-run extraction pools): the
|
|
186
|
+
* extractor's single-pass yield swings 20..160 for byte-identical input, and the
|
|
187
|
+
* padding crowds real obligations out of the 40 that ship — high-yield runs land
|
|
188
|
+
* FEWER critical obligations than low-yield ones. Critical obligations reaching
|
|
189
|
+
* the shipped list go 8.50 → 9.37 of 16 on the design pool (10 runs better, 0
|
|
190
|
+
* worse, p=0.0020) and 7.70 → 8.43 on the confirmation pool (12 / 0, p=0.0005).
|
|
191
|
+
*
|
|
192
|
+
* BUDGETED, not absolute. Below the cap no slot is contested, so dropping there
|
|
193
|
+
* destroys information and buys nothing; a run that filtered 55 quotes down to 20
|
|
194
|
+
* lost its only carrier of the Argon2id obligation — a DDL row that was correctly
|
|
195
|
+
* classified as one — while 20 slots sat empty. So the low-value entries come back
|
|
196
|
+
* in source-doc order until the list reaches the cap.
|
|
197
|
+
*
|
|
198
|
+
* Absolute filtering scored marginally higher on raw count (24 gains vs 22 across
|
|
199
|
+
* both pools) and was rejected anyway: its extra gains are one more obligation in
|
|
200
|
+
* an already-populated list, while its one loss is an obligation vanishing from a
|
|
201
|
+
* run outright. Those are not the same size of mistake.
|
|
202
|
+
*
|
|
203
|
+
* Doc order for the restore is the neutral choice: which entries return only
|
|
204
|
+
* matters when more were dropped than there are free slots, and ordering by
|
|
205
|
+
* anything fitted to an observed loss would be tuning the rule to one pool.
|
|
206
|
+
*/
|
|
207
|
+
function budgetedByObligation(entries, budget, sourceDoc) {
|
|
208
|
+
const keep = entries.filter(e => !isLowValueQuote(e.quote));
|
|
209
|
+
if (keep.length >= budget)
|
|
210
|
+
return keep;
|
|
211
|
+
const at = (e) => {
|
|
212
|
+
if (!sourceDoc)
|
|
213
|
+
return Number.MAX_SAFE_INTEGER;
|
|
214
|
+
const i = sourceDoc.indexOf(e.quote.trim());
|
|
215
|
+
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
|
|
216
|
+
};
|
|
217
|
+
const restored = entries
|
|
218
|
+
.filter(e => isLowValueQuote(e.quote))
|
|
219
|
+
.map((e, given) => ({ e, at: at(e), given }))
|
|
220
|
+
.sort((x, y) => x.at - y.at || x.given - y.given)
|
|
221
|
+
.slice(0, budget - keep.length)
|
|
222
|
+
.map(x => x.e);
|
|
223
|
+
return [...keep, ...restored];
|
|
113
224
|
}
|
|
114
225
|
/** The doc split into heading-delimited sections, each pre-normalised for
|
|
115
226
|
* containment tests. Text before the first heading is its own section. */
|
|
@@ -92,6 +92,30 @@ export interface RunWorkerResult {
|
|
|
92
92
|
exitCode: number;
|
|
93
93
|
stderr: string;
|
|
94
94
|
aborted: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* The provider-reported cause when the model turn itself failed (disconnect,
|
|
97
|
+
* fetch failed, 5xx after pi's own retries): pi delivers it as an assistant
|
|
98
|
+
* message with stopReason "error" and EMPTY text, exit code 0. Phase children
|
|
99
|
+
* have always surfaced this (child-runner.ts) — research workers did not, so a
|
|
100
|
+
* swallowed provider error reached the caller as an indistinguishable empty
|
|
101
|
+
* answer and was reported as the useless "produced no output" (issue #10).
|
|
102
|
+
* Only meaningful when `text` is empty: a turn that produced text after pi
|
|
103
|
+
* recovered is a success, and the first-error capture must not relabel it.
|
|
104
|
+
*/
|
|
105
|
+
modelError?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Whether the child ever produced a single byte of stdout. Under `--mode json`
|
|
108
|
+
* a live pi child streams protocol events long before any assistant text, so
|
|
109
|
+
* this separates the two ways a worker can come back with nothing:
|
|
110
|
+
* sawOutput true — the child ran and the MODEL chose to write nothing
|
|
111
|
+
* (a legitimately empty section on a trivial task)
|
|
112
|
+
* sawOutput false — the child never spoke at all: it died at startup
|
|
113
|
+
* (unresolvable provider, missing key, bad argv). That is
|
|
114
|
+
* a FAILURE and must never be recorded as "no entries".
|
|
115
|
+
* Derived from the same first-byte timestamp `waitMs`/`workMs` use, so it
|
|
116
|
+
* cannot disagree with them.
|
|
117
|
+
*/
|
|
118
|
+
sawOutput: boolean;
|
|
95
119
|
/**
|
|
96
120
|
* Milliseconds between spawn and the child's first stdout chunk. When
|
|
97
121
|
* multiple workers run concurrently and the upstream model API queues at
|
|
@@ -331,7 +331,9 @@ export async function runWorker(input) {
|
|
|
331
331
|
aborted: result.aborted,
|
|
332
332
|
waitMs,
|
|
333
333
|
workMs,
|
|
334
|
+
sawOutput: tFirstByte !== null,
|
|
334
335
|
groundingRetrievalCount,
|
|
336
|
+
...(result.modelError ? { modelError: result.modelError } : {}),
|
|
335
337
|
...(leaked ? { leakedToolCall: leaked } : {}),
|
|
336
338
|
...(loopHit ? { loopHit } : {}),
|
|
337
339
|
...(timedOut ? { timedOut: true } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|