@mjasnikovs/pi-task 0.38.18 → 0.38.20
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/README.md +1 -0
- package/dist/config/config.d.ts +19 -0
- package/dist/config/config.js +10 -2
- package/dist/config/reasoning-args.d.ts +10 -0
- package/dist/config/reasoning-args.js +23 -0
- package/dist/config/reasoning.d.ts +160 -0
- package/dist/config/reasoning.js +530 -0
- package/dist/config/register.d.ts +72 -2
- package/dist/config/register.js +182 -33
- package/dist/shared/child-process.d.ts +19 -0
- package/dist/shared/child-process.js +9 -10
- package/dist/shared/model-endpoint.d.ts +34 -0
- package/dist/shared/model-endpoint.js +36 -0
- package/dist/shared/reasoning-capability.d.ts +86 -0
- package/dist/shared/reasoning-capability.js +82 -0
- package/dist/task/child-runner.d.ts +36 -26
- package/dist/task/child-runner.js +63 -6
- package/dist/task/child-status.d.ts +11 -3
- package/dist/task/child-status.js +14 -3
- package/dist/task/context-usage.d.ts +1 -1
- package/dist/task/context-usage.js +1 -1
- package/dist/task/decompose-fidelity.d.ts +21 -8
- package/dist/task/decompose-fidelity.js +98 -17
- package/dist/task/gate-child.d.ts +10 -0
- package/dist/task/gate-child.js +5 -1
- package/dist/task/gate-deps.js +4 -0
- package/dist/task/implementation-thinking.d.ts +54 -0
- package/dist/task/implementation-thinking.js +33 -0
- package/dist/task/orchestrator.d.ts +7 -0
- package/dist/task/orchestrator.js +46 -14
- package/dist/task/phases.js +47 -24
- package/dist/task/prompts.d.ts +0 -23
- package/dist/task/prompts.js +0 -25
- package/dist/task/reasoning-groups.d.ts +36 -0
- package/dist/task/reasoning-groups.js +36 -0
- package/dist/task/spec-validation.d.ts +28 -0
- package/dist/task/spec-validation.js +44 -0
- package/dist/task/stall-detector.d.ts +5 -0
- package/dist/task/stall-detector.js +5 -0
- package/dist/task/title-label.js +2 -2
- package/dist/workers/docs-core.js +4 -0
- package/dist/workers/fetch-core.js +4 -0
- package/dist/workers/focused-extractor.d.ts +12 -1
- package/dist/workers/focused-extractor.js +6 -2
- package/dist/workers/index.js +2 -0
- package/dist/workers/pi-worker-core.d.ts +33 -4
- package/dist/workers/pi-worker-core.js +28 -7
- package/dist/workers/pi-worker-docs.js +4 -0
- package/dist/workers/pi-worker.js +11 -1
- package/dist/workers/reasoning-warning.d.ts +64 -0
- package/dist/workers/reasoning-warning.js +142 -0
- package/package.json +4 -4
|
@@ -20,6 +20,7 @@ import { type WidgetState } from './widget.js';
|
|
|
20
20
|
import { type RunTaskFn } from './gate-deps.js';
|
|
21
21
|
import { type GateDeps } from './task-gates.js';
|
|
22
22
|
import { type PhaseDeps } from './child-runner.js';
|
|
23
|
+
import { type ThinkingControl } from './implementation-thinking.js';
|
|
23
24
|
import { type RunEnd } from './run-end.js';
|
|
24
25
|
import type { SpawnFn } from '../shared/child-process.js';
|
|
25
26
|
import { type SuperviseOptions } from './implementation-turn.js';
|
|
@@ -166,6 +167,12 @@ export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId'
|
|
|
166
167
|
* internal per-task runs, which must stay silent. Default false.
|
|
167
168
|
*/
|
|
168
169
|
notifyFinish?: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* How the implementation turn's thinking level is read and written. Defaults
|
|
172
|
+
* to the live pi session; injected by tests, which must be able to assert
|
|
173
|
+
* the restore without a real session to restore.
|
|
174
|
+
*/
|
|
175
|
+
thinkingControl?: ThinkingControl;
|
|
169
176
|
}
|
|
170
177
|
export interface RunSingleTaskResult {
|
|
171
178
|
taskId: string;
|
|
@@ -36,6 +36,7 @@ import { findDeliveryPhantoms, formatApiOverrideBanner } from '../workers/phanto
|
|
|
36
36
|
import { titleForDisplay } from './parsers.js';
|
|
37
37
|
import { USER_CANCELLED } from './child-runner.js';
|
|
38
38
|
import { cancelCheckpoint } from './cancel-points.js';
|
|
39
|
+
import { holdImplementationThinking } from './implementation-thinking.js';
|
|
39
40
|
import { rearmCancelListener } from './cancel-input.js';
|
|
40
41
|
import { takeHeldInput } from './mid-run-input.js';
|
|
41
42
|
import { withRun, announceTerminal } from './run-bracket.js';
|
|
@@ -57,6 +58,21 @@ function clearActiveTask(runner) {
|
|
|
57
58
|
}
|
|
58
59
|
// Captured from the factory so command handlers can call pi.sendUserMessage.
|
|
59
60
|
let piApi = null;
|
|
61
|
+
/**
|
|
62
|
+
* The live session's thinking level, as a {@link ThinkingControl}.
|
|
63
|
+
*
|
|
64
|
+
* Goes through `piApi` rather than the command ctx because
|
|
65
|
+
* `ExtensionCommandContext` exposes `thinkingLevel` READ-ONLY; the setter lives
|
|
66
|
+
* on the extension API. Before `registerTask(pi)` has run there is nothing to
|
|
67
|
+
* control, so this degrades to a no-op pair rather than throwing — a task that
|
|
68
|
+
* cannot move the level should still run.
|
|
69
|
+
*/
|
|
70
|
+
function piThinkingControl() {
|
|
71
|
+
const api = piApi;
|
|
72
|
+
if (!api)
|
|
73
|
+
return { get: () => 'off', set: () => { } };
|
|
74
|
+
return { get: () => api.getThinkingLevel(), set: level => api.setThinkingLevel(level) };
|
|
75
|
+
}
|
|
60
76
|
// ─── TaskRunner class ────────────────────────────────────────────────────────
|
|
61
77
|
/** Encapsulates the full lifecycle of a single pi-task run. */
|
|
62
78
|
export class TaskRunner {
|
|
@@ -122,6 +138,10 @@ export class TaskRunner {
|
|
|
122
138
|
onChildOutput: (line) => {
|
|
123
139
|
this._widgetState.lastLine = line;
|
|
124
140
|
},
|
|
141
|
+
// Handed DOWN so the child's own snapshot carries a window and the
|
|
142
|
+
// StallDetector's churn rule can arm; resolveContextUsage below stays
|
|
143
|
+
// as the fallback for a child that still reports none (issue #16).
|
|
144
|
+
contextWindow: parentContextWindow,
|
|
125
145
|
onContextUsage: snapshot => {
|
|
126
146
|
this._widgetState.contextUsage = resolveContextUsage(snapshot, this._widgetState.contextUsage, parentContextWindow);
|
|
127
147
|
},
|
|
@@ -432,20 +452,32 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
|
|
|
432
452
|
rawPrompt,
|
|
433
453
|
resumeId: opts.resumeId,
|
|
434
454
|
sendSpec: async (spec) => {
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
455
|
+
// The implementation turn is the one "child" that is not a
|
|
456
|
+
// child: it runs in the user's own session, so its reasoning
|
|
457
|
+
// group is applied by moving pi's level and moving it back.
|
|
458
|
+
// The autofix re-runner (gateRunTask) re-enters runSingleTask
|
|
459
|
+
// and so re-enters this closure, which is why the hold lives
|
|
460
|
+
// here rather than at either call site.
|
|
461
|
+
const release = holdImplementationThinking(opts.thinkingControl ?? piThinkingControl());
|
|
462
|
+
try {
|
|
463
|
+
// Queue-or-run: never throws, whatever else is on the session (issue #8).
|
|
464
|
+
await newCtx.sendUserMessage(spec, { deliverAs: 'followUp' });
|
|
465
|
+
if (opts.waitForImplementation) {
|
|
466
|
+
await newCtx.waitForIdle();
|
|
467
|
+
// A threshold auto-compaction parks the turn at idle WITHOUT
|
|
468
|
+
// auto-continuing, and a user ESC ends it "aborted": the
|
|
469
|
+
// first idle is not the turn's real end. superviseImplementation
|
|
470
|
+
// resumes across compactions, steers across interrupts, and
|
|
471
|
+
// reads how the turn ACTUALLY ended.
|
|
472
|
+
const outcome = await superviseImplementation(newCtx, {
|
|
473
|
+
promptSteer: opts.promptSteer
|
|
474
|
+
});
|
|
475
|
+
interrupted = outcome.interrupted;
|
|
476
|
+
implError = outcome.error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
release();
|
|
449
481
|
}
|
|
450
482
|
},
|
|
451
483
|
spawnFn: opts.spawnFn,
|
package/dist/task/phases.js
CHANGED
|
@@ -21,7 +21,7 @@ import { getConfig } from '../config/config.js';
|
|
|
21
21
|
import { readFile } from 'node:fs/promises';
|
|
22
22
|
import { resolve } from 'node:path';
|
|
23
23
|
import { buildExternalContext, gatherExternalContext } from './external-context.js';
|
|
24
|
-
import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS
|
|
24
|
+
import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS } from './prompts.js';
|
|
25
25
|
import { appendGateRecord, readSection, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
|
|
26
26
|
import { applyRefutations } from './refuted-constraint.js';
|
|
27
27
|
import { spawnSync } from 'node:child_process';
|
|
@@ -29,7 +29,7 @@ import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js'
|
|
|
29
29
|
import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
|
|
30
30
|
import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyToolingOutput, deriveTitle } from './parsers.js';
|
|
31
31
|
import { compressTitle } from './title-label.js';
|
|
32
|
-
import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
|
|
32
|
+
import { parseVerifyBlock, validateSpecShape, validateRefineShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
|
|
33
33
|
import { collectCritiqueDefects } from './critique-probes.js';
|
|
34
34
|
import { buildOptionCards, resolveAnswer } from './question-dialog.js';
|
|
35
35
|
import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js';
|
|
@@ -42,6 +42,7 @@ import { runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED } from
|
|
|
42
42
|
import { SessionUI } from '../remote/bridge.js';
|
|
43
43
|
import { isYoloMode, yoloPickAutoAnswer } from './yolo.js';
|
|
44
44
|
import { QaTranscript, GRILL_QA_POLICY } from './qa-transcript.js';
|
|
45
|
+
import { groupThinkingArgs } from '../config/reasoning-args.js';
|
|
45
46
|
// ─── Re-export constants from their home modules ────────────────────────────
|
|
46
47
|
export { MAX_GRILL_QUESTIONS };
|
|
47
48
|
// ─── Tooling helpers ─────────────────────────────────────────────────────────
|
|
@@ -265,7 +266,7 @@ export const phaseRefine = async (deps, raw, planContext) => {
|
|
|
265
266
|
// re-check the output below (lever). Empty on an ordinary prompt → refine unchanged.
|
|
266
267
|
const directives = extractUserDirectives(raw);
|
|
267
268
|
const directivesBlock = preserveDirectivesBlock(directives);
|
|
268
|
-
const refined = await runPhaseChild(deps, 'refine', 'read',
|
|
269
|
+
const refined = await runPhaseChild(deps, 'refine', 'read', REFINE_PROMPT(raw, planContext, existingFiles, contracts, directivesBlock),
|
|
269
270
|
// refine's deliverable is a 4-section text rewrite that never strictly
|
|
270
271
|
// needs a successful read — on a test-writing task against a large
|
|
271
272
|
// existing codebase the model over-explores (re-reads source hunting for
|
|
@@ -274,6 +275,16 @@ export const phaseRefine = async (deps, raw, planContext) => {
|
|
|
274
275
|
// refine looped 3×/resume forever; the deliverable was always producible
|
|
275
276
|
// from the title + design doc alone.
|
|
276
277
|
{ degradeOnExhaustion: true, verb: 'restart' });
|
|
278
|
+
// Shape check, REPORTED not enforced. Refine's four sections are what
|
|
279
|
+
// extractCapsSection, scopedToolingGoal, deriveTitle and extractEnrichTargets
|
|
280
|
+
// each look for, and all four fail SILENTLY when one is missing — so the loss
|
|
281
|
+
// was previously invisible. Deliberately not a retry or a throw: refine's
|
|
282
|
+
// output is usable prose even when a heading is gone (the four consumers
|
|
283
|
+
// degrade, they do not break), and a run must not die over a heading. This
|
|
284
|
+
// puts the miss in the debug log where an audit can find it.
|
|
285
|
+
const shape = validateRefineShape(refined);
|
|
286
|
+
if (shape)
|
|
287
|
+
deps.logDebug?.(`refine: ${shape}`);
|
|
277
288
|
// Deterministic backstop: if the refined spec still dropped a directive, append
|
|
278
289
|
// it verbatim rather than trusting the paraphrase. No model in this path.
|
|
279
290
|
const { text, appended } = enforceDirectives(refined, directives);
|
|
@@ -291,7 +302,7 @@ export async function phaseVerifyTooling(deps, research) {
|
|
|
291
302
|
const toolingList = commands.join('\n');
|
|
292
303
|
let verifyOutput;
|
|
293
304
|
try {
|
|
294
|
-
verifyOutput = await runPhaseChild(deps, 'verify-tooling', 'read,bash',
|
|
305
|
+
verifyOutput = await runPhaseChild(deps, 'verify-tooling', 'read,bash', VERIFY_TOOLING_PROMPT(toolingList));
|
|
295
306
|
}
|
|
296
307
|
catch {
|
|
297
308
|
return replaceToolingWithVerified(research, commands);
|
|
@@ -717,26 +728,33 @@ export async function phaseResearch(deps, refined) {
|
|
|
717
728
|
return r;
|
|
718
729
|
});
|
|
719
730
|
// Run the four workers ONE AT A TIME. Settled by an A/B on the local
|
|
720
|
-
// llama.cpp backend (single GPU, same task/model) — and the answer
|
|
731
|
+
// llama.cpp backend (single GPU, same task/model) — and the answer FLIPS
|
|
721
732
|
// with thinking:
|
|
722
|
-
// - thinking ON
|
|
733
|
+
// - thinking ON → parallel wins: long decodes batch well, 4 concurrent
|
|
723
734
|
// finish in ~max(worker), not the sum.
|
|
724
|
-
// -
|
|
735
|
+
// - thinking OFF → sequential wins: with short decodes the batching upside
|
|
725
736
|
// is gone, but 4 concurrent streams still split the one GPU and slow
|
|
726
737
|
// each other ~4x (context worker measured 27s solo vs 128s under load),
|
|
727
738
|
// so summed-but-fast (~100s) beats max-of-slowed (~130s).
|
|
728
|
-
// Every worker runs /no_think (below), so sequential is the faster regime.
|
|
729
|
-
// Do NOT switch the DEFAULT back to concurrent without re-running that A/B;
|
|
730
|
-
// the opt-in `parallelResearchWorkers` config flag exists for backends that
|
|
731
|
-
// genuinely serve parallel streams.
|
|
732
739
|
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
+
// KNOWN-OPEN, AND THIS IS THE HONEST STATE OF IT. That A/B was run when
|
|
741
|
+
// every worker carried Qwen3's `/no_think` prompt suffix, so "thinking OFF"
|
|
742
|
+
// was assumed and sequential followed. The suffix has since been measured
|
|
743
|
+
// INERT — with server thinking on and `/no_think` still in the prompt,
|
|
744
|
+
// Qwen3.8 emitted a median 17k-char trace anyway (n=25) — and it has been
|
|
745
|
+
// removed in favour of the `research` reasoning group (config/reasoning.ts).
|
|
746
|
+
// So the arm this default was chosen under may never have been the arm that
|
|
747
|
+
// ran, and the group's level is now a user-visible setting rather than a
|
|
748
|
+
// constant.
|
|
749
|
+
//
|
|
750
|
+
// The default stays SEQUENTIAL because that is the measured-safe arm on a
|
|
751
|
+
// single-GPU box and because flipping a shipped default on an invalidated
|
|
752
|
+
// premise would be replacing one unmeasured claim with another. The
|
|
753
|
+
// parallel x reasoning-level interaction is unmeasured; re-run the A/B
|
|
754
|
+
// before changing this, and treat `parallelResearchWorkers` as the opt-in
|
|
755
|
+
// for backends that genuinely serve parallel streams.
|
|
756
|
+
//
|
|
757
|
+
// Result order (files, apis, context, tooling) is preserved for assembly.
|
|
740
758
|
// Resolved once: `searchConfigured()` reads the environment, and the tools
|
|
741
759
|
// string and the `-e` paths must be derived from the SAME answer.
|
|
742
760
|
const apisChannels = apisWorkerChannels();
|
|
@@ -745,7 +763,7 @@ export async function phaseResearch(deps, refined) {
|
|
|
745
763
|
section: 'FILES',
|
|
746
764
|
label: 'worker:files',
|
|
747
765
|
// Read-heavy: gets the orientation core (see note above).
|
|
748
|
-
prompt:
|
|
766
|
+
prompt: orientation.block + promptHeader + RESEARCH_FILES_PROMPT(refined)
|
|
749
767
|
},
|
|
750
768
|
{
|
|
751
769
|
section: 'APIS',
|
|
@@ -756,7 +774,7 @@ export async function phaseResearch(deps, refined) {
|
|
|
756
774
|
// the worker doesn't re-derive where-things-live via docs-"."
|
|
757
775
|
// queries the FILES worker just answered (run-7 F7: up to 10
|
|
758
776
|
// duplicate `.`-decodes per task through the serial bottleneck).
|
|
759
|
-
prompt: prior =>
|
|
777
|
+
prompt: prior => orientation.block
|
|
760
778
|
+ promptHeader
|
|
761
779
|
+ RESEARCH_APIS_PROMPT(refined, prior.find(s => s.name === 'FILES')?.text || undefined)
|
|
762
780
|
+ (searchConfigured() ? RESEARCH_SEARCH_HINT : '')
|
|
@@ -764,7 +782,7 @@ export async function phaseResearch(deps, refined) {
|
|
|
764
782
|
// set. The tool-side half lives in pi-worker-docs.ts; a
|
|
765
783
|
// budget enforced without being announced would just read
|
|
766
784
|
// to the worker as a broken tool.
|
|
767
|
-
+ (fanoutBudget === null ? '' : projectDocsBudgetNotice(fanoutBudget))
|
|
785
|
+
+ (fanoutBudget === null ? '' : projectDocsBudgetNotice(fanoutBudget)),
|
|
768
786
|
// The tools string and the `-e` paths are ONE fact — which worker
|
|
769
787
|
// channels this research worker is given — and used to be two literals
|
|
770
788
|
// kept in step by eye. `channelSet` derives both from the same rows.
|
|
@@ -798,7 +816,7 @@ export async function phaseResearch(deps, refined) {
|
|
|
798
816
|
{
|
|
799
817
|
section: 'CONTEXT',
|
|
800
818
|
label: 'worker:context',
|
|
801
|
-
prompt:
|
|
819
|
+
prompt: promptHeader + RESEARCH_CONTEXT_PROMPT(refined),
|
|
802
820
|
// Context owns architectural understanding, not path discovery —
|
|
803
821
|
// FILES handles that. Dropping `find`/`ls` keeps the worker from
|
|
804
822
|
// spawning long enumeration loops whose output then inflates
|
|
@@ -843,7 +861,7 @@ export async function phaseResearch(deps, refined) {
|
|
|
843
861
|
label: 'worker:tooling',
|
|
844
862
|
// Scope to the goal prose only — not the per-file edit checklist that
|
|
845
863
|
// makes a weak model spelunk source and loop. See scopedToolingGoal.
|
|
846
|
-
prompt:
|
|
864
|
+
prompt: promptHeader + RESEARCH_TOOLING_PROMPT(scopedToolingGoal(refined)),
|
|
847
865
|
// Block re-reads in-process so a weak model that wants to re-read the
|
|
848
866
|
// same file is told to answer from what it has instead of looping.
|
|
849
867
|
extensions: [SINGLE_READ_EXTENSION_PATH]
|
|
@@ -883,6 +901,11 @@ export async function phaseResearch(deps, refined) {
|
|
|
883
901
|
cwd: deps.cwd,
|
|
884
902
|
signal: deps.signal,
|
|
885
903
|
spawn: deps.spawn,
|
|
904
|
+
// All four research workers share one group: they are the same
|
|
905
|
+
// job (read-only exploration of the repo) run over four
|
|
906
|
+
// questions, and a table that could set them apart would be
|
|
907
|
+
// four cells nobody has the trials to fill.
|
|
908
|
+
thinking: groupThinkingArgs('research'),
|
|
886
909
|
...(spec.tools ? { tools: spec.tools } : {}),
|
|
887
910
|
...(spec.extensions ? { extensions: spec.extensions } : {}),
|
|
888
911
|
// 5B SCALE arm — null unless both env vars are set. Only the
|
|
@@ -1453,7 +1476,7 @@ extraDefects) {
|
|
|
1453
1476
|
// Granting `read` here let it wander the repo to "verify" findings,
|
|
1454
1477
|
// which made the supposedly-cheap pass cost as much as a rewrite
|
|
1455
1478
|
// (observed ~133s). The judgement needs no file access.
|
|
1456
|
-
verdict = await runPhaseChild(deps, 'critique-triage', '',
|
|
1479
|
+
verdict = await runPhaseChild(deps, 'critique-triage', '', CRITIQUE_TRIAGE_PROMPT(spec, refined, qa, contractsBlock));
|
|
1457
1480
|
}
|
|
1458
1481
|
catch {
|
|
1459
1482
|
verdict = null;
|
package/dist/task/prompts.d.ts
CHANGED
|
@@ -5,29 +5,6 @@
|
|
|
5
5
|
* effects, trivially testable.
|
|
6
6
|
*/
|
|
7
7
|
export declare const MAX_GRILL_QUESTIONS = 20;
|
|
8
|
-
/**
|
|
9
|
-
* Qwen3 "soft switch": placing `/no_think` in the prompt disables the model's
|
|
10
|
-
* <think> reasoning trace for that turn and persists across the tool-call loop
|
|
11
|
-
* within the same child session.
|
|
12
|
-
*
|
|
13
|
-
* On a local reasoning model decode is the bottleneck (~50 t/s here) while
|
|
14
|
-
* prefill is ~10x faster, so cost is dominated by *generated* tokens. A runaway
|
|
15
|
-
* think trace can be 10k+ tokens — minutes — even when the phase's real output
|
|
16
|
-
* is a short list or a one-word verdict (an observed triage spent ~384s
|
|
17
|
-
* thinking to emit "CLEAN"). Stripping the monologue does NOT limit the model's
|
|
18
|
-
* exploration: it still calls every tool it wants and takes every step it
|
|
19
|
-
* needs — it just stops narrating between actions.
|
|
20
|
-
*
|
|
21
|
-
* We strip thinking from the mechanical / exploration phases (refine, the four
|
|
22
|
-
* research workers, verify-tooling, triage) and keep it ON for the judgment
|
|
23
|
-
* phases (compose, grill, critique rewrite) where the reasoning earns its
|
|
24
|
-
* decode cost. pi's `--thinking off` flag is a no-op for this provider
|
|
25
|
-
* (`supportsReasoningEffort: false` in models.json), so the in-prompt soft
|
|
26
|
-
* switch is the reliable control.
|
|
27
|
-
*/
|
|
28
|
-
export declare const NO_THINK = "/no_think";
|
|
29
|
-
/** Append the Qwen3 `/no_think` soft switch to a prompt. See {@link NO_THINK}. */
|
|
30
|
-
export declare function appendNoThink(prompt: string): string;
|
|
31
8
|
/**
|
|
32
9
|
* Compress a verbose task title into a short status-bar label. Pure judgment, no
|
|
33
10
|
* tools — the child reads only the title we hand it. The output is sanitised and
|
package/dist/task/prompts.js
CHANGED
|
@@ -8,31 +8,6 @@
|
|
|
8
8
|
// and the grill loop's total iterations, so a model that never emits NONE can't
|
|
9
9
|
// run unbounded.
|
|
10
10
|
export const MAX_GRILL_QUESTIONS = 20;
|
|
11
|
-
/**
|
|
12
|
-
* Qwen3 "soft switch": placing `/no_think` in the prompt disables the model's
|
|
13
|
-
* <think> reasoning trace for that turn and persists across the tool-call loop
|
|
14
|
-
* within the same child session.
|
|
15
|
-
*
|
|
16
|
-
* On a local reasoning model decode is the bottleneck (~50 t/s here) while
|
|
17
|
-
* prefill is ~10x faster, so cost is dominated by *generated* tokens. A runaway
|
|
18
|
-
* think trace can be 10k+ tokens — minutes — even when the phase's real output
|
|
19
|
-
* is a short list or a one-word verdict (an observed triage spent ~384s
|
|
20
|
-
* thinking to emit "CLEAN"). Stripping the monologue does NOT limit the model's
|
|
21
|
-
* exploration: it still calls every tool it wants and takes every step it
|
|
22
|
-
* needs — it just stops narrating between actions.
|
|
23
|
-
*
|
|
24
|
-
* We strip thinking from the mechanical / exploration phases (refine, the four
|
|
25
|
-
* research workers, verify-tooling, triage) and keep it ON for the judgment
|
|
26
|
-
* phases (compose, grill, critique rewrite) where the reasoning earns its
|
|
27
|
-
* decode cost. pi's `--thinking off` flag is a no-op for this provider
|
|
28
|
-
* (`supportsReasoningEffort: false` in models.json), so the in-prompt soft
|
|
29
|
-
* switch is the reliable control.
|
|
30
|
-
*/
|
|
31
|
-
export const NO_THINK = '/no_think';
|
|
32
|
-
/** Append the Qwen3 `/no_think` soft switch to a prompt. See {@link NO_THINK}. */
|
|
33
|
-
export function appendNoThink(prompt) {
|
|
34
|
-
return `${prompt}\n\n${NO_THINK}`;
|
|
35
|
-
}
|
|
36
11
|
/**
|
|
37
12
|
* Compress a verbose task title into a short status-bar label. Pure judgment, no
|
|
38
13
|
* tools — the child reads only the title we hand it. The output is sanitised and
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child NAME → reasoning group, for every child that goes through
|
|
3
|
+
* `runPhaseChild` / `runPlanningChild`.
|
|
4
|
+
*
|
|
5
|
+
* WHY KEYED ON THE NAME
|
|
6
|
+
* ---------------------
|
|
7
|
+
* The name is the only identifier in scope at all three spawn paths (phases,
|
|
8
|
+
* /task-auto planning, /task-plan), it is what the loader and the debug trail
|
|
9
|
+
* already print, and it is the one thing a reader can check against the phase
|
|
10
|
+
* list without following the call graph. Threading a group parameter through
|
|
11
|
+
* `PhaseDeps` / `AutoDeps` instead would touch both orchestrators' dep bags to
|
|
12
|
+
* express something the call site already says out loud.
|
|
13
|
+
*
|
|
14
|
+
* AN UNMAPPED NAME IS A BUILD FAILURE, not a silent `inherit`.
|
|
15
|
+
* `reasoning-groups.test.ts` scans every literal child name in src/ and fails if
|
|
16
|
+
* it is missing here. A defaulting lookup would let a phase added next year opt
|
|
17
|
+
* itself out of a measured setting without anyone deciding to — which is exactly
|
|
18
|
+
* how `/no_think` ended up applied to eight prompts and read by none of them.
|
|
19
|
+
*
|
|
20
|
+
* The gate, research and extraction groups are NOT here: those children reach the
|
|
21
|
+
* model through `runWorker` / `focusedChildArgs`, where the group is a property
|
|
22
|
+
* of the call site rather than of a name, and is passed directly.
|
|
23
|
+
*/
|
|
24
|
+
import type { ReasoningGroup } from '../config/reasoning.js';
|
|
25
|
+
export declare const REASONING_GROUP_BY_CHILD: Readonly<Record<string, ReasoningGroup>>;
|
|
26
|
+
/**
|
|
27
|
+
* The group a named child belongs to.
|
|
28
|
+
*
|
|
29
|
+
* Returns `undefined` for a name the table does not know, and the CALLER decides
|
|
30
|
+
* what that means. `runPhaseChild` treats it as `inherit` — a child that reaches
|
|
31
|
+
* the model with today's argv is always safe — while the test treats it as a
|
|
32
|
+
* failure. That split is deliberate: the guard belongs at build time, where
|
|
33
|
+
* someone can fix it, not at run time, where it would abort a user's task over a
|
|
34
|
+
* missing table row.
|
|
35
|
+
*/
|
|
36
|
+
export declare function reasoningGroupForChild(name: string): ReasoningGroup | undefined;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const REASONING_GROUP_BY_CHILD = {
|
|
2
|
+
// ── phase: task/phases.ts + task/title-label.ts ──────────────────────────
|
|
3
|
+
refine: 'phase',
|
|
4
|
+
'verify-tooling': 'phase',
|
|
5
|
+
'grill-auto': 'phase',
|
|
6
|
+
'grill-gen': 'phase',
|
|
7
|
+
compose: 'phase',
|
|
8
|
+
critique: 'phase',
|
|
9
|
+
'critique-triage': 'phase',
|
|
10
|
+
'compress-label': 'phase',
|
|
11
|
+
// ── planning: task/auto-orchestrator.ts ──────────────────────────────────
|
|
12
|
+
'clarify-triage': 'planning',
|
|
13
|
+
'auto-clarify': 'planning',
|
|
14
|
+
'auto-decompose': 'planning',
|
|
15
|
+
'requirement-extract': 'planning',
|
|
16
|
+
'decompose-coverage': 'planning',
|
|
17
|
+
'coverage-map': 'planning',
|
|
18
|
+
'contract-extract': 'planning',
|
|
19
|
+
'launch-extract': 'planning',
|
|
20
|
+
// ── plan: task/plan-orchestrator.ts ──────────────────────────────────────
|
|
21
|
+
'plan-question': 'plan',
|
|
22
|
+
'plan-answer': 'plan'
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The group a named child belongs to.
|
|
26
|
+
*
|
|
27
|
+
* Returns `undefined` for a name the table does not know, and the CALLER decides
|
|
28
|
+
* what that means. `runPhaseChild` treats it as `inherit` — a child that reaches
|
|
29
|
+
* the model with today's argv is always safe — while the test treats it as a
|
|
30
|
+
* failure. That split is deliberate: the guard belongs at build time, where
|
|
31
|
+
* someone can fix it, not at run time, where it would abort a user's task over a
|
|
32
|
+
* missing table row.
|
|
33
|
+
*/
|
|
34
|
+
export function reasoningGroupForChild(name) {
|
|
35
|
+
return REASONING_GROUP_BY_CHILD[name];
|
|
36
|
+
}
|
|
@@ -35,3 +35,31 @@ export declare function isCritiqueClean(text: string): boolean;
|
|
|
35
35
|
*/
|
|
36
36
|
export declare function stripSpecPreamble(spec: string): string;
|
|
37
37
|
export declare function validateSpecShape(spec: string): string | null;
|
|
38
|
+
/** The four sections REFINE_PROMPT demands, in the order it demands them. */
|
|
39
|
+
export declare const REFINE_SECTIONS: readonly ["GOAL", "CONSTRAINTS", "KNOWN-UNKNOWNS", "EXTERNAL-DEPENDENCIES"];
|
|
40
|
+
/**
|
|
41
|
+
* Is a refine child's output shaped like a refined prompt?
|
|
42
|
+
*
|
|
43
|
+
* Refine shipped with NO shape check at all — `phaseRefine` passes no validator,
|
|
44
|
+
* unlike compose and critique — and every downstream reader of a refined prompt
|
|
45
|
+
* is a PARTIAL parser that tolerates a missing section SILENTLY:
|
|
46
|
+
* `extractCapsSection` (refuted-constraint.ts) returns null, `scopedToolingGoal`
|
|
47
|
+
* (phases.ts) returns the whole text, `deriveTitle` and `extractEnrichTargets`
|
|
48
|
+
* fall back. So a refine answer that dropped a heading degrades four features at
|
|
49
|
+
* once and says nothing. This names the contract in one place.
|
|
50
|
+
*
|
|
51
|
+
* WHAT IT CHECKS, and why it is only this. Every one of those consumers looks
|
|
52
|
+
* for a BARE ALL-CAPS heading alone on its own line — `l.trim() === heading`,
|
|
53
|
+
* `/^GOAL[ \t]*\n/m`. That is the operative contract, so that is the test.
|
|
54
|
+
*
|
|
55
|
+
* It does NOT require the text to START with GOAL, even though REFINE_PROMPT
|
|
56
|
+
* says "four sections, exact headings, in this order" and forbids a preamble.
|
|
57
|
+
* Measured over the 57-task mx5 corpus: 55/56 non-empty refined prompts carry
|
|
58
|
+
* all four bare headings, but only 25/56 open with one — a preamble is what real
|
|
59
|
+
* refine output usually looks like, and production has always consumed it fine.
|
|
60
|
+
* A validator stricter than its consumers would reject work that works.
|
|
61
|
+
*
|
|
62
|
+
* Returns a problem string, or null when the shape is good — same contract as
|
|
63
|
+
* `validateSpecShape` above.
|
|
64
|
+
*/
|
|
65
|
+
export declare function validateRefineShape(refined: string): string | null;
|
|
@@ -108,3 +108,47 @@ export function validateSpecShape(spec) {
|
|
|
108
108
|
}
|
|
109
109
|
return null;
|
|
110
110
|
}
|
|
111
|
+
// ─── Refine shape gate ───────────────────────────────────────────────────────
|
|
112
|
+
/** The four sections REFINE_PROMPT demands, in the order it demands them. */
|
|
113
|
+
export const REFINE_SECTIONS = [
|
|
114
|
+
'GOAL',
|
|
115
|
+
'CONSTRAINTS',
|
|
116
|
+
'KNOWN-UNKNOWNS',
|
|
117
|
+
'EXTERNAL-DEPENDENCIES'
|
|
118
|
+
];
|
|
119
|
+
/**
|
|
120
|
+
* Is a refine child's output shaped like a refined prompt?
|
|
121
|
+
*
|
|
122
|
+
* Refine shipped with NO shape check at all — `phaseRefine` passes no validator,
|
|
123
|
+
* unlike compose and critique — and every downstream reader of a refined prompt
|
|
124
|
+
* is a PARTIAL parser that tolerates a missing section SILENTLY:
|
|
125
|
+
* `extractCapsSection` (refuted-constraint.ts) returns null, `scopedToolingGoal`
|
|
126
|
+
* (phases.ts) returns the whole text, `deriveTitle` and `extractEnrichTargets`
|
|
127
|
+
* fall back. So a refine answer that dropped a heading degrades four features at
|
|
128
|
+
* once and says nothing. This names the contract in one place.
|
|
129
|
+
*
|
|
130
|
+
* WHAT IT CHECKS, and why it is only this. Every one of those consumers looks
|
|
131
|
+
* for a BARE ALL-CAPS heading alone on its own line — `l.trim() === heading`,
|
|
132
|
+
* `/^GOAL[ \t]*\n/m`. That is the operative contract, so that is the test.
|
|
133
|
+
*
|
|
134
|
+
* It does NOT require the text to START with GOAL, even though REFINE_PROMPT
|
|
135
|
+
* says "four sections, exact headings, in this order" and forbids a preamble.
|
|
136
|
+
* Measured over the 57-task mx5 corpus: 55/56 non-empty refined prompts carry
|
|
137
|
+
* all four bare headings, but only 25/56 open with one — a preamble is what real
|
|
138
|
+
* refine output usually looks like, and production has always consumed it fine.
|
|
139
|
+
* A validator stricter than its consumers would reject work that works.
|
|
140
|
+
*
|
|
141
|
+
* Returns a problem string, or null when the shape is good — same contract as
|
|
142
|
+
* `validateSpecShape` above.
|
|
143
|
+
*/
|
|
144
|
+
export function validateRefineShape(refined) {
|
|
145
|
+
const trimmed = refined.trim();
|
|
146
|
+
if (trimmed.length === 0)
|
|
147
|
+
return 'refined prompt is empty';
|
|
148
|
+
const lines = trimmed.split('\n').map(l => l.trim());
|
|
149
|
+
const missing = REFINE_SECTIONS.filter(s => !lines.includes(s));
|
|
150
|
+
if (missing.length > 0) {
|
|
151
|
+
return `refined prompt missing required section(s): ${missing.join(', ')}`;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
* rule 1 alone would not have caught it. The bound scales with the model's
|
|
39
39
|
* OWN window, so a 1M-context model gets a 1M-context allowance.
|
|
40
40
|
*
|
|
41
|
+
* The window is supplied by the PARENT at spawn (`PhaseDeps.contextWindow`),
|
|
42
|
+
* not read off the child's event stream: pi emits no context event at all,
|
|
43
|
+
* which is why this rule sat disarmed from the day it was written until
|
|
44
|
+
* GitHub issue #16.
|
|
45
|
+
*
|
|
41
46
|
* Neither rule can fire on a child that is thinking rather than calling tools:
|
|
42
47
|
* that case is bounded by the model's max tokens (server-enforced) and by the
|
|
43
48
|
* stream watchdog if the stream goes silent. Between the three there is no
|
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
* rule 1 alone would not have caught it. The bound scales with the model's
|
|
39
39
|
* OWN window, so a 1M-context model gets a 1M-context allowance.
|
|
40
40
|
*
|
|
41
|
+
* The window is supplied by the PARENT at spawn (`PhaseDeps.contextWindow`),
|
|
42
|
+
* not read off the child's event stream: pi emits no context event at all,
|
|
43
|
+
* which is why this rule sat disarmed from the day it was written until
|
|
44
|
+
* GitHub issue #16.
|
|
45
|
+
*
|
|
41
46
|
* Neither rule can fire on a child that is thinking rather than calling tools:
|
|
42
47
|
* that case is bounded by the model's max tokens (server-enforced) and by the
|
|
43
48
|
* stream watchdog if the stream goes silent. Between the three there is no
|
package/dist/task/title-label.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { runPhaseChild } from './child-runner.js';
|
|
14
14
|
import { LABEL_MAX, truncateLabel } from './parsers.js';
|
|
15
|
-
import { COMPRESS_LABEL_PROMPT
|
|
15
|
+
import { COMPRESS_LABEL_PROMPT } from './prompts.js';
|
|
16
16
|
/**
|
|
17
17
|
* Reduce a raw model completion to a single clean label line: first non-empty
|
|
18
18
|
* line, stripped of surrounding quotes/backticks and a leading "label:" echo,
|
|
@@ -37,7 +37,7 @@ export async function compressTitle(deps, title) {
|
|
|
37
37
|
if (title.replace(/\s+/g, ' ').trim().length <= LABEL_MAX)
|
|
38
38
|
return fallback;
|
|
39
39
|
try {
|
|
40
|
-
const raw = await runPhaseChild(deps, 'compress-label', '',
|
|
40
|
+
const raw = await runPhaseChild(deps, 'compress-label', '', COMPRESS_LABEL_PROMPT(title, LABEL_MAX));
|
|
41
41
|
const cleaned = sanitizeLabel(raw);
|
|
42
42
|
if (cleaned.length === 0)
|
|
43
43
|
return fallback;
|
|
@@ -10,6 +10,7 @@ import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
|
|
|
10
10
|
import { runChild } from '../shared/child-process.js';
|
|
11
11
|
import { runFocusedExtraction } from './focused-extractor.js';
|
|
12
12
|
import { buildExtractionPrompt } from './abstention.js';
|
|
13
|
+
import { groupThinkingArgs } from '../config/reasoning-args.js';
|
|
13
14
|
const DEFAULT_LIMIT = PACKAGE_RETRIEVE_LIMIT;
|
|
14
15
|
const DEFAULT_BUDGET = RETRIEVE_CONTENT_BUDGET;
|
|
15
16
|
const NO_CACHE_HEAD = 25_000;
|
|
@@ -562,6 +563,9 @@ export async function docsFocused(input) {
|
|
|
562
563
|
cwd: input.cwd,
|
|
563
564
|
signal: input.signal,
|
|
564
565
|
spawn,
|
|
566
|
+
// The `extraction` group's level. Resolved at the call site so the
|
|
567
|
+
// extractor itself never reads ambient config.
|
|
568
|
+
thinking: groupThinkingArgs('extraction'),
|
|
565
569
|
abortedMessage: 'Docs lookup aborted.'
|
|
566
570
|
});
|
|
567
571
|
const base = {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
|
|
2
2
|
import { runFocusedExtraction } from './focused-extractor.js';
|
|
3
3
|
import { abstentionSentence } from './abstention.js';
|
|
4
|
+
import { groupThinkingArgs } from '../config/reasoning-args.js';
|
|
4
5
|
const CONTENT_BUDGET = 30_000;
|
|
5
6
|
const HEAD_CHARS = 25_000;
|
|
6
7
|
const TAIL_CHARS = 5_000;
|
|
@@ -79,6 +80,9 @@ export async function fetchFocused(input) {
|
|
|
79
80
|
cwd: input.cwd,
|
|
80
81
|
signal: input.signal,
|
|
81
82
|
spawn: input.spawn,
|
|
83
|
+
// The `extraction` group's level. Resolved at the call site so the
|
|
84
|
+
// extractor itself never reads ambient config.
|
|
85
|
+
thinking: groupThinkingArgs('extraction'),
|
|
82
86
|
abortedMessage: 'Fetch aborted.'
|
|
83
87
|
});
|
|
84
88
|
const base = {
|
|
@@ -8,7 +8,7 @@ import { type ExcerptVerification } from '../shared/child-output.js';
|
|
|
8
8
|
* `--no-tools` is the contract, not a default: the child is given all the content it may use
|
|
9
9
|
* inside its prompt, so a tool call could only reach for something unsourced.
|
|
10
10
|
*/
|
|
11
|
-
export declare const focusedChildArgs: () => string[];
|
|
11
|
+
export declare const focusedChildArgs: (thinking?: readonly string[]) => string[];
|
|
12
12
|
export interface FocusedRequest {
|
|
13
13
|
/** The fully assembled prompt, including the content block. Delivered on stdin. */
|
|
14
14
|
prompt: string;
|
|
@@ -33,6 +33,17 @@ export interface FocusedRequest {
|
|
|
33
33
|
spawn?: SpawnFn;
|
|
34
34
|
/** The message reported when the child was aborted, e.g. `'Docs lookup aborted.'`. */
|
|
35
35
|
abortedMessage: string;
|
|
36
|
+
/**
|
|
37
|
+
* An already-resolved `['--thinking', level]` fragment, or `[]`/omitted to
|
|
38
|
+
* inherit the session default.
|
|
39
|
+
*
|
|
40
|
+
* Supplied by the CALLER even though all three call sites are the same
|
|
41
|
+
* `extraction` group. Resolving it inside this module would make
|
|
42
|
+
* `focusedChildArgs` read ambient config, and a function that reads config
|
|
43
|
+
* internally cannot be tested without the developer's own machine state —
|
|
44
|
+
* which is exactly how a `getConfig()` assertion got into a unit test.
|
|
45
|
+
*/
|
|
46
|
+
thinking?: readonly string[];
|
|
36
47
|
}
|
|
37
48
|
/** What every outcome carries, success or failure — the raw child evidence. */
|
|
38
49
|
interface FocusedChildEvidence {
|
|
@@ -37,7 +37,11 @@ import { formatChildFailure } from './shared.js';
|
|
|
37
37
|
* `--no-tools` is the contract, not a default: the child is given all the content it may use
|
|
38
38
|
* inside its prompt, so a tool call could only reach for something unsourced.
|
|
39
39
|
*/
|
|
40
|
-
export const focusedChildArgs = () => [
|
|
40
|
+
export const focusedChildArgs = (thinking = []) => [
|
|
41
|
+
...childBaseArgs(),
|
|
42
|
+
...thinking,
|
|
43
|
+
'--no-tools'
|
|
44
|
+
];
|
|
41
45
|
/**
|
|
42
46
|
* Run one focused extraction: spawn the no-tools child on `prompt`, and on success parse its
|
|
43
47
|
* `<answer>`/`<excerpt>` and verify the excerpt against `verifyAgainst`.
|
|
@@ -47,7 +51,7 @@ export const focusedChildArgs = () => [...childBaseArgs(), '--no-tools'];
|
|
|
47
51
|
*/
|
|
48
52
|
export async function runFocusedExtraction(req) {
|
|
49
53
|
const spawn = req.spawn ?? defaultSpawn;
|
|
50
|
-
const invocation = getPiInvocation(focusedChildArgs(), req.prompt);
|
|
54
|
+
const invocation = getPiInvocation(focusedChildArgs(req.thinking), req.prompt);
|
|
51
55
|
const child = await runChild(spawn, invocation, req.cwd, req.signal);
|
|
52
56
|
const evidence = {
|
|
53
57
|
exitCode: child.exitCode,
|