@mjasnikovs/pi-task 0.38.20 → 0.38.22
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/config/reasoning.js
CHANGED
|
@@ -148,7 +148,40 @@ export const DEFAULT_REASONING_TABLE = {
|
|
|
148
148
|
// so the `off` arm decodes on sampling tuned for the `on` arm. That is the
|
|
149
149
|
// regime this machine really runs pi-task in, so the result is
|
|
150
150
|
// ecologically valid — it is NOT a clean comparison.
|
|
151
|
-
|
|
151
|
+
//
|
|
152
|
+
// OVERRIDDEN TO `medium` BY USER DECISION, 2026-08-27. THIS IS NOT A NEW
|
|
153
|
+
// MEASUREMENT — the trials above are unchanged and still say the arms tied.
|
|
154
|
+
// What changed is the prior. Rung 3 says "no reason found to pay for
|
|
155
|
+
// thinking"; it does not say thinking is worthless, and at n=12 an absent
|
|
156
|
+
// effect and an undetected one look identical.
|
|
157
|
+
//
|
|
158
|
+
// WHAT THE SAME LEDGER SAYS ABOUT THE CLOCK, re-read for this decision:
|
|
159
|
+
// medium is the FASTER arm on the mean, 62.9s vs 105.6s over the 12 paired
|
|
160
|
+
// stimuli (median 51.7s vs 87.7s), and it is faster on 6 of 12 by 1-265s
|
|
161
|
+
// while slower on 6 by 0-43s. The mean gap is entirely `off`'s TAIL: off's
|
|
162
|
+
// two slowest trials are 289s and 367s, medium's slowest is 131s. So the
|
|
163
|
+
// typical trial is a coin flip worth under a minute and the difference the
|
|
164
|
+
// arms actually have is in the tail.
|
|
165
|
+
//
|
|
166
|
+
// AND THE TAIL IS THE PATHOLOGY THIS CHANGE IS AIMED AT. mx5-n 2026-08-27:
|
|
167
|
+
// 40.7 of the research phase's 81.4 wall-clock minutes were thrown away on
|
|
168
|
+
// restarts, all of them in `worker:tooling` and `worker:context` — `files`
|
|
169
|
+
// and `apis` never restarted once. The worst was 550 tool calls over 20
|
|
170
|
+
// distinct files in 20 minutes for 25s of useful work.
|
|
171
|
+
//
|
|
172
|
+
// THE HONEST LIMIT OF THIS DECISION: no axis in
|
|
173
|
+
// scripts/live-reasoning-group-ab.ts scores tool-call count or repetition —
|
|
174
|
+
// the Trial record has no such field and RunWorkerResult exposes no count —
|
|
175
|
+
// so the ledger CANNOT say whether thinking prevents the wander. It was
|
|
176
|
+
// never asked. And the ledger's stimuli are the FILES worker, not the two
|
|
177
|
+
// that looped. Anyone flipping this back should note that the evidence for
|
|
178
|
+
// `off` is equally silent on the question.
|
|
179
|
+
//
|
|
180
|
+
// The structural fix for the wander shipped separately and does not depend
|
|
181
|
+
// on this cell: StallDetector is now wired into runWorker (it was in phase
|
|
182
|
+
// children only), so a rotation is killed ~8 calls into its second lap
|
|
183
|
+
// rather than at the 20-minute ceiling.
|
|
184
|
+
research: 'medium',
|
|
152
185
|
// NOT MEASURED. DECIDED BY PRIOR, 2026-08-27 — the same prior that carries
|
|
153
186
|
// every rung-3 cell in this table: thinking that buys nothing measurable is
|
|
154
187
|
// not worth its tokens. `off`.
|
|
@@ -453,7 +486,40 @@ export const DEFAULT_REASONING_TABLE = {
|
|
|
453
486
|
// carry the cell. The value is unchanged; only its standing improved, from
|
|
454
487
|
// a stated prior to a measured win. Rescored from the original ledger on 2026-08-25 when the harness
|
|
455
488
|
// moved to a forced two-way verdict; the trials are unchanged.
|
|
456
|
-
|
|
489
|
+
//
|
|
490
|
+
// OVERRIDDEN TO `medium` BY USER DECISION, 2026-08-27, AND THIS ONE
|
|
491
|
+
// OVERRIDES A MEASUREMENT RATHER THAN A PRIOR. Say so plainly: quality tied
|
|
492
|
+
// 12/20 in both arms, and the PAIRED clock — the statistic that matches this
|
|
493
|
+
// design — put `off` ahead in 9 of the 12 specs that pass in both arms,
|
|
494
|
+
// geometric mean 0.55x, p=0.0166. That is the rung-2 win that WROTE this
|
|
495
|
+
// cell `off`, and it is not withdrawn by anything measured since.
|
|
496
|
+
//
|
|
497
|
+
// WHAT THE DECISION COSTS, from those same numbers: expect implementation
|
|
498
|
+
// turns to take roughly 1.8x as long, for no measured quality gain. The
|
|
499
|
+
// quality CI is the widest in the table ([0.39, 0.78] at 12/20), so a real
|
|
500
|
+
// quality difference either way would have been invisible at n=20 — that is
|
|
501
|
+
// the room the decision is being made in, and it cuts both ways.
|
|
502
|
+
//
|
|
503
|
+
// THE STATED REASON FOR THE OVERRIDE is that the implementation turn was
|
|
504
|
+
// observed wandering in the same mx5-n run. That observation is NOT in this
|
|
505
|
+
// repo's evidence: the run's .pi-tasks logs were destroyed before they could
|
|
506
|
+
// be mined, so no loop was ever counted here and this cell must not claim
|
|
507
|
+
// one. It is the user's judgement, recorded as such.
|
|
508
|
+
//
|
|
509
|
+
// WHAT IS STRUCTURALLY TRUE, and checked: the implementation turn runs in
|
|
510
|
+
// the USER'S OWN SESSION (see task/implementation-thinking.ts and
|
|
511
|
+
// task/implementation-turn.ts), not as a child. It therefore has NO
|
|
512
|
+
// LoopDetector and NO StallDetector — only the per-tool-call command
|
|
513
|
+
// watchdog and the steer loop's resume cap. The 2026-08-27 fix that wired
|
|
514
|
+
// StallDetector into runWorker does NOT reach it. So of the groups in this
|
|
515
|
+
// table, `implementation` is the one where a rotation has no guard at all,
|
|
516
|
+
// and thinking is the only lever currently pointed at it.
|
|
517
|
+
//
|
|
518
|
+
// TO PUT THIS CELL BACK ON EVIDENCE, re-run
|
|
519
|
+
// scripts/live-implementation-thinking-ab.ts at a larger n. The ledger is
|
|
520
|
+
// /home/edgars/hub/ab-implab/impl-ledger.jsonl, 40 rows, b10618 — the odd
|
|
521
|
+
// build out, so a re-run on b10620 is not a replicate of it.
|
|
522
|
+
implementation: 'medium'
|
|
457
523
|
};
|
|
458
524
|
/**
|
|
459
525
|
* A hand-edited or stale mode must not reach {@link resolveReasoning}'s switch as
|
package/dist/config/register.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
1
|
+
import { getKeybindings, SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
2
2
|
import { registerBridgeCommand } from '../remote/bridge.js';
|
|
3
3
|
import { readPkgVersion } from '../shared/pkg-version.js';
|
|
4
4
|
import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
|
|
@@ -74,22 +74,43 @@ export const SECTIONS = [
|
|
|
74
74
|
{ key: 'checks', title: 'after each task' },
|
|
75
75
|
{ key: 'research', title: 'research' },
|
|
76
76
|
{ key: 'reasoning', title: 'reasoning' },
|
|
77
|
-
{ key: 'timeouts', title: 'timeouts' },
|
|
78
77
|
{ key: 'unattended', title: 'unattended' },
|
|
79
78
|
{ key: 'logging', title: 'logging' },
|
|
80
|
-
{ key: 'extensions', title: 'child extensions' }
|
|
79
|
+
{ key: 'extensions', title: 'child extensions' },
|
|
80
|
+
// Last on purpose. It is the longest block (a fixed timeout plus one row
|
|
81
|
+
// per live tool, so it grows with the host) and the least often changed —
|
|
82
|
+
// in front of `unattended` it pushed every short section off the screen.
|
|
83
|
+
{ key: 'timeouts', title: 'timeouts' }
|
|
81
84
|
];
|
|
82
85
|
/** Marks a header row, so onChange can ignore one and tests can find them. */
|
|
83
86
|
export const SECTION_ID_PREFIX = 'section:';
|
|
84
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* An inert titled row. No `values` ⇒ SettingsList's Enter handler no-ops on it,
|
|
89
|
+
* and {@link SkipInertRows} steps the cursor straight over it.
|
|
90
|
+
*
|
|
91
|
+
* Upper case, and styled muted by {@link makeTheme}, because the dashed
|
|
92
|
+
* lower-case form it replaces was the same case, colour and weight as the
|
|
93
|
+
* setting labels underneath it — eight headings that read as nine more rows.
|
|
94
|
+
*/
|
|
85
95
|
function sectionHeader(title) {
|
|
86
96
|
return {
|
|
87
97
|
id: SECTION_ID_PREFIX + title,
|
|
88
|
-
label:
|
|
98
|
+
label: title.toUpperCase(),
|
|
89
99
|
description: '',
|
|
90
100
|
currentValue: ''
|
|
91
101
|
};
|
|
92
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* A blank row between two sections.
|
|
105
|
+
*
|
|
106
|
+
* `SettingsList` renders exactly one line per item, so the only way to put air
|
|
107
|
+
* above a heading is to hand it an empty row. It carries the header prefix so
|
|
108
|
+
* everything that already treats a header as scenery — the inert check, the
|
|
109
|
+
* cursor skip, the headless rendering — covers it with no second rule.
|
|
110
|
+
*/
|
|
111
|
+
function sectionGap(title) {
|
|
112
|
+
return { id: `${SECTION_ID_PREFIX}gap:${title}`, label: '', description: '', currentValue: '' };
|
|
113
|
+
}
|
|
93
114
|
/**
|
|
94
115
|
* The shared pair for a boolean setting: shown as on/off, stored as a boolean.
|
|
95
116
|
* Every non-enum row uses this, so a boolean cannot be given a bespoke parser by
|
|
@@ -345,7 +366,7 @@ export function applyReasoningLevel(cfg, group, chosen) {
|
|
|
345
366
|
/** Overlay width; the list gets `- 4` of it, the description `- 4` again. */
|
|
346
367
|
const OVERLAY_WIDTH = 68;
|
|
347
368
|
/** Settings rows shown at once before the list scrolls. */
|
|
348
|
-
const MAX_VISIBLE =
|
|
369
|
+
const MAX_VISIBLE = 11;
|
|
349
370
|
/**
|
|
350
371
|
* Tallest body the settings list can render, so {@link BorderedBox} can pad
|
|
351
372
|
* every frame to it and hold the border still. Mirrors SettingsList's own
|
|
@@ -356,9 +377,19 @@ export function settingsBodyHeight(descriptions, maxVisible, wrapWidth) {
|
|
|
356
377
|
const tallestDescription = Math.max(0, ...descriptions.map(d => wrapTextWithAnsi(d, wrapWidth).length));
|
|
357
378
|
return 1 + maxVisible + 1 + 1 + tallestDescription + 1 + 1;
|
|
358
379
|
}
|
|
359
|
-
function makeTheme(theme) {
|
|
380
|
+
function makeTheme(theme, isHeader) {
|
|
360
381
|
return {
|
|
361
|
-
label: (text, selected) =>
|
|
382
|
+
label: (text, selected) => {
|
|
383
|
+
// Headers are scenery, so they are rendered quieter than the rows
|
|
384
|
+
// they title rather than louder. `isHeader` is asked by text
|
|
385
|
+
// because SettingsListTheme only ever sees the padded label —
|
|
386
|
+
// matching on the text is what keeps the styling in one place
|
|
387
|
+
// instead of pre-colouring the string back in panelItems, which
|
|
388
|
+
// has no theme to colour it with.
|
|
389
|
+
if (isHeader(text))
|
|
390
|
+
return theme.fg('muted', theme.bold(text));
|
|
391
|
+
return selected ? theme.fg('accent', theme.bold(text)) : theme.fg('text', text);
|
|
392
|
+
},
|
|
362
393
|
// A filled/hollow dot makes the on/off column scannable at a glance
|
|
363
394
|
// without reading a word on every row. Enum values (an engine name, a
|
|
364
395
|
// duration) are real content, so they stay readable rather than muted.
|
|
@@ -376,6 +407,70 @@ function makeTheme(theme) {
|
|
|
376
407
|
hint: text => theme.fg('dim', text)
|
|
377
408
|
};
|
|
378
409
|
}
|
|
410
|
+
/** The arrow key SettingsList moves down on, under the default bindings. */
|
|
411
|
+
const DOWN_KEY = '\x1b[B';
|
|
412
|
+
/**
|
|
413
|
+
* Moves the cursor over the section headers and the blank rows between them.
|
|
414
|
+
*
|
|
415
|
+
* Those rows are decoration: they carry no `values`, so Enter already does
|
|
416
|
+
* nothing on them. Without this they were still stops on the way down — with a
|
|
417
|
+
* heading AND a blank line per section that is sixteen dead keypresses in a
|
|
418
|
+
* thirty-row menu, and the panel opens with the cursor parked on a heading that
|
|
419
|
+
* has no description to show.
|
|
420
|
+
*
|
|
421
|
+
* It drives the list through its own public `handleInput` — pressing the very
|
|
422
|
+
* key the user pressed, N times — rather than reaching for the private
|
|
423
|
+
* `selectedIndex`. The mirror it keeps cannot drift: with search off and no
|
|
424
|
+
* submenus, up and down are the only two things that move that index.
|
|
425
|
+
*/
|
|
426
|
+
class SkipInertRows {
|
|
427
|
+
list;
|
|
428
|
+
selectable;
|
|
429
|
+
index = 0;
|
|
430
|
+
constructor(list,
|
|
431
|
+
/** True where a row can be selected, in the list's own order. */
|
|
432
|
+
selectable) {
|
|
433
|
+
this.list = list;
|
|
434
|
+
this.selectable = selectable;
|
|
435
|
+
// The first row is a header, so the panel would open on it. Only
|
|
436
|
+
// synthesise the keypress if it is actually bound to "down" — feeding
|
|
437
|
+
// a key the list ignores would move the mirror and not the cursor.
|
|
438
|
+
if (!getKeybindings().matches(DOWN_KEY, 'tui.select.down'))
|
|
439
|
+
return;
|
|
440
|
+
while (this.index < selectable.length && !selectable[this.index]) {
|
|
441
|
+
this.list.handleInput(DOWN_KEY);
|
|
442
|
+
this.index++;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
render(width) {
|
|
446
|
+
return this.list.render(width);
|
|
447
|
+
}
|
|
448
|
+
invalidate() {
|
|
449
|
+
this.list.invalidate();
|
|
450
|
+
}
|
|
451
|
+
handleInput(data) {
|
|
452
|
+
const kb = getKeybindings();
|
|
453
|
+
const step = kb.matches(data, 'tui.select.down') ? 1
|
|
454
|
+
: kb.matches(data, 'tui.select.up') ? -1
|
|
455
|
+
: 0;
|
|
456
|
+
if (step === 0) {
|
|
457
|
+
this.list.handleInput(data);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const n = this.selectable.length;
|
|
461
|
+
let target = this.index;
|
|
462
|
+
for (let moved = 1; moved <= n; moved++) {
|
|
463
|
+
target = (target + step + n) % n;
|
|
464
|
+
if (this.selectable[target]) {
|
|
465
|
+
for (let i = 0; i < moved; i++)
|
|
466
|
+
this.list.handleInput(data);
|
|
467
|
+
this.index = target;
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// Every row is scenery. Nothing to select, so nothing to move.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
379
474
|
/**
|
|
380
475
|
* Builds the framed settings panel. Split out of the command handler so the
|
|
381
476
|
* exact component the overlay shows can be rendered to a string in a test or a
|
|
@@ -392,8 +487,10 @@ export function createSettingsPanel(items, theme,
|
|
|
392
487
|
* beside seven rows still claiming `inherit` — which is what it did.
|
|
393
488
|
*/
|
|
394
489
|
onChange, onCancel) {
|
|
395
|
-
|
|
396
|
-
|
|
490
|
+
// A row with no `values` is a header or the blank line above one.
|
|
491
|
+
const headerLabels = new Set(items.filter(i => i.values === undefined).map(i => i.label));
|
|
492
|
+
const list = new SettingsList(items, MAX_VISIBLE, makeTheme(theme, label => headerLabels.has(label.trimEnd())), (id, newValue) => onChange(id, newValue, list), onCancel);
|
|
493
|
+
return new BorderedBox(new SkipInertRows(list, items.map(i => (i.values?.length ?? 0) > 0)), CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
|
|
397
494
|
}
|
|
398
495
|
/** The full settings row list for the current config, in menu order. */
|
|
399
496
|
export function panelItems(cfg, installed, tools = []) {
|
|
@@ -421,6 +518,8 @@ export function panelItems(cfg, installed, tools = []) {
|
|
|
421
518
|
// all, so with nothing installed the heading would otherwise sit alone.
|
|
422
519
|
if (rows.length === 0)
|
|
423
520
|
continue;
|
|
521
|
+
if (out.length > 0)
|
|
522
|
+
out.push(sectionGap(title));
|
|
424
523
|
out.push(sectionHeader(title), ...rows);
|
|
425
524
|
}
|
|
426
525
|
return out;
|
|
@@ -449,8 +548,13 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
|
449
548
|
// Built from panelItems, not a second hand-written walk of the same
|
|
450
549
|
// tables: the two renderings used to be able to disagree about what a
|
|
451
550
|
// setting said, and a headless run is the one place nobody would notice.
|
|
452
|
-
const lines = panelItems(cfg, installed, tools)
|
|
453
|
-
|
|
551
|
+
const lines = panelItems(cfg, installed, tools)
|
|
552
|
+
// The blank rows between sections are there to give the TUI air.
|
|
553
|
+
// One line of `|`-joined text has none to give, and an empty label
|
|
554
|
+
// would print as a stray `[]`.
|
|
555
|
+
.filter(i => i.label !== '')
|
|
556
|
+
.map(i => i.values === undefined ?
|
|
557
|
+
`[${i.label.trim()}]`
|
|
454
558
|
: `${i.label.padEnd(22)} ${i.currentValue}`);
|
|
455
559
|
ctx.ui.notify(lines.join(' | '), 'info');
|
|
456
560
|
return;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { type ContextSnapshot, type SpawnFn } from '../shared/child-process.js';
|
|
2
|
-
import { type LoopHit } from '../task/loop-detector.js';
|
|
1
|
+
import { type ContextSnapshot, type LoopHit, type SpawnFn } from '../shared/child-process.js';
|
|
3
2
|
/**
|
|
4
3
|
* Tool calls that can GROUND an APIS claim — i.e. return content a signature or
|
|
5
4
|
* command could be cited from. `pi-worker-docs` (the primary), `read` and `grep`
|
|
@@ -125,6 +124,27 @@ export interface RunWorkerInput {
|
|
|
125
124
|
threshold?: number;
|
|
126
125
|
pathThreshold?: number;
|
|
127
126
|
} | false;
|
|
127
|
+
/**
|
|
128
|
+
* Whole-run progress guard (task/stall-detector.ts). Default ON.
|
|
129
|
+
*
|
|
130
|
+
* WHY BOTH. LoopDetector judges ARGUMENTS over a 20-call window, so a child
|
|
131
|
+
* that rotates through MORE DISTINCT CALLS THAN THE WINDOW HOLDS is invisible
|
|
132
|
+
* to it — every key occurs once per window and the count never reaches the
|
|
133
|
+
* threshold. Measured: mx5-n 2026-08-27, worker:tooling made 550 calls over
|
|
134
|
+
* exactly 20 distinct files, ~36 reads each, and neither the exact rule nor
|
|
135
|
+
* the path rule ever tripped. It died 20 minutes later on the absolute
|
|
136
|
+
* progress ceiling, having done 25s of useful work.
|
|
137
|
+
*
|
|
138
|
+
* StallDetector judges RESULTS, which a rotating reader cannot vary. It was
|
|
139
|
+
* written for exactly this class and was wired only into phase children
|
|
140
|
+
* (task/child-runner.ts) until this option existed.
|
|
141
|
+
*
|
|
142
|
+
* Pass `false` to disable, or override the thresholds (tests, harnesses).
|
|
143
|
+
*/
|
|
144
|
+
stallGuard?: {
|
|
145
|
+
limit?: number;
|
|
146
|
+
churnFactor?: number;
|
|
147
|
+
} | false;
|
|
128
148
|
/**
|
|
129
149
|
* Dead-backend stall guard override. Default ON: no output for
|
|
130
150
|
* STALL_AFTER_MS → probe the model endpoints pi is configured with →
|
|
@@ -4,6 +4,7 @@ import { CommandWatchdog, commandTimeoutHint, realTimerDeps } from '../shared/co
|
|
|
4
4
|
import { isGroundingRetrieval as isGrounding, workerChannel } from './worker-channels.js';
|
|
5
5
|
import { childBaseArgs } from '../shared/child-extensions.js';
|
|
6
6
|
import { LoopDetector } from '../task/loop-detector.js';
|
|
7
|
+
import { StallDetector, formatStallHint } from '../task/stall-detector.js';
|
|
7
8
|
import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint, isConnectionError, connectionRetryBackoffMs } from '../task/child-runner.js';
|
|
8
9
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
9
10
|
import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
|
|
@@ -282,9 +283,16 @@ const RESTART_RULES = [
|
|
|
282
283
|
// re-spawn avoids it. Bounded by the shared restart budget.
|
|
283
284
|
reason: 'loop',
|
|
284
285
|
detect: s => s.loopHit && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
|
|
285
|
-
{
|
|
286
|
+
{
|
|
287
|
+
// A stall hit carries no meaningful windowSize (rule 1 sets
|
|
288
|
+
// it to 0), so printing the loop shape would misname why the
|
|
289
|
+
// attempt died.
|
|
290
|
+
detail: s.loopHit.stall ?
|
|
291
|
+
`${s.loopHit.call.name} ${s.loopHit.stall} ×${s.loopHit.count}`
|
|
292
|
+
: `${s.loopHit.call.name} ×${s.loopHit.count}/${s.loopHit.windowSize}`
|
|
293
|
+
}
|
|
286
294
|
: null,
|
|
287
|
-
hint: s => formatLoopHint(s.loopHit),
|
|
295
|
+
hint: s => s.loopHit.stall ? formatStallHint(s.loopHit.stall) : formatLoopHint(s.loopHit),
|
|
288
296
|
counters: { shared: true }
|
|
289
297
|
},
|
|
290
298
|
{
|
|
@@ -499,6 +507,16 @@ export async function runWorker(input) {
|
|
|
499
507
|
const threshold = input.loop?.threshold ?? LOOP_THRESHOLD;
|
|
500
508
|
return new LoopDetector(window, threshold, input.loop?.pathThreshold ?? threshold);
|
|
501
509
|
})();
|
|
510
|
+
// Reset EACH attempt, like the loop detector: a restart discards the
|
|
511
|
+
// previous attempt's calls along with its text, so a fresh child must not
|
|
512
|
+
// inherit a dead streak it did not earn.
|
|
513
|
+
const stallDetector = input.stallGuard === false ?
|
|
514
|
+
null
|
|
515
|
+
: new StallDetector(input.stallGuard?.limit, input.stallGuard?.churnFactor);
|
|
516
|
+
// Arm the churn rule BEFORE the first tool call. pi's stream carries no
|
|
517
|
+
// context event (issue #16), so waiting for one leaves the rule
|
|
518
|
+
// permanently disarmed. The parent knows the window at spawn time.
|
|
519
|
+
stallDetector?.noteContext(input.contextWindow ?? 0);
|
|
502
520
|
// Capture the hit the detector reports (it also returns it to the unified
|
|
503
521
|
// runner, which kills the child on a hit). Without capturing it here the
|
|
504
522
|
// SIGTERM that kill produces would surface as a bare non-zero exit the
|
|
@@ -543,9 +561,11 @@ export async function runWorker(input) {
|
|
|
543
561
|
}
|
|
544
562
|
if (isGrounding(call.name))
|
|
545
563
|
groundingRetrievalCount++;
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
564
|
+
// Loop detector first: it names the offending call and its
|
|
565
|
+
// hint is the more specific one. The stall detector is the
|
|
566
|
+
// backstop for the thrash shapes a 20-call argument window
|
|
567
|
+
// cannot see.
|
|
568
|
+
const hit = loopDetector?.record(call) ?? stallDetector?.record(call) ?? null;
|
|
549
569
|
if (hit && !loopHit)
|
|
550
570
|
loopHit = hit;
|
|
551
571
|
return hit;
|
|
@@ -565,9 +585,15 @@ export async function runWorker(input) {
|
|
|
565
585
|
onToolResult: r => {
|
|
566
586
|
timeout.progress();
|
|
567
587
|
cmdWatch?.onEnd(r.toolCallId);
|
|
588
|
+
// The RESULT is what entered the child's context, so it —
|
|
589
|
+
// not the arguments — decides whether it learned anything.
|
|
590
|
+
stallDetector?.noteResult(r.text, r.isError);
|
|
568
591
|
input.onToolResult?.(r);
|
|
569
592
|
},
|
|
570
|
-
onContextUsage:
|
|
593
|
+
onContextUsage: snapshot => {
|
|
594
|
+
stallDetector?.noteContext(snapshot.contextWindow);
|
|
595
|
+
input.onContextUsage?.(snapshot);
|
|
596
|
+
},
|
|
571
597
|
...(input.contextWindow && input.contextWindow > 0 ?
|
|
572
598
|
{ contextWindow: input.contextWindow }
|
|
573
599
|
: {})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.22",
|
|
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",
|