@mjasnikovs/pi-task 0.38.31 → 0.39.0

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.
Files changed (59) hide show
  1. package/README.md +2 -1
  2. package/dist/config/config.d.ts +16 -2
  3. package/dist/config/config.js +7 -2
  4. package/dist/config/group-args.d.ts +52 -0
  5. package/dist/config/group-args.js +110 -0
  6. package/dist/config/group-models.d.ts +88 -0
  7. package/dist/config/group-models.js +117 -0
  8. package/dist/config/groups.d.ts +76 -0
  9. package/dist/config/groups.js +110 -0
  10. package/dist/config/option-picker.d.ts +70 -0
  11. package/dist/config/option-picker.js +113 -0
  12. package/dist/config/reasoning.d.ts +26 -64
  13. package/dist/config/reasoning.js +31 -115
  14. package/dist/config/register.d.ts +144 -24
  15. package/dist/config/register.js +345 -56
  16. package/dist/index.js +2 -0
  17. package/dist/remote/push.js +1 -7
  18. package/dist/shared/data-home.d.ts +8 -0
  19. package/dist/shared/data-home.js +14 -0
  20. package/dist/shared/model-endpoint.d.ts +53 -0
  21. package/dist/shared/model-endpoint.js +98 -2
  22. package/dist/shared/reasoning-capability.d.ts +25 -5
  23. package/dist/shared/reasoning-capability.js +18 -9
  24. package/dist/task/child-runner.d.ts +19 -16
  25. package/dist/task/child-runner.js +64 -36
  26. package/dist/task/context-usage.d.ts +46 -0
  27. package/dist/task/context-usage.js +41 -0
  28. package/dist/task/gate-child.d.ts +15 -4
  29. package/dist/task/gate-child.js +2 -2
  30. package/dist/task/gate-deps.js +7 -2
  31. package/dist/task/implementation-hold.d.ts +118 -0
  32. package/dist/task/implementation-hold.js +165 -0
  33. package/dist/task/model-hold-stash.d.ts +43 -0
  34. package/dist/task/model-hold-stash.js +70 -0
  35. package/dist/task/orchestrator.d.ts +18 -5
  36. package/dist/task/orchestrator.js +36 -4
  37. package/dist/task/phases.js +2 -2
  38. package/dist/task/research-worker.d.ts +2 -2
  39. package/dist/task/research-worker.js +1 -1
  40. package/dist/workers/docs-core.js +2 -2
  41. package/dist/workers/docs-lookup.d.ts +4 -3
  42. package/dist/workers/docs-lookup.js +1 -1
  43. package/dist/workers/fetch-core.js +2 -2
  44. package/dist/workers/focused-extractor.d.ts +4 -3
  45. package/dist/workers/focused-extractor.js +5 -4
  46. package/dist/workers/index.js +2 -0
  47. package/dist/workers/model-warning.d.ts +69 -0
  48. package/dist/workers/model-warning.js +113 -0
  49. package/dist/workers/pi-worker-core.d.ts +7 -7
  50. package/dist/workers/pi-worker-core.js +4 -3
  51. package/dist/workers/pi-worker-docs.js +2 -2
  52. package/dist/workers/pi-worker.js +4 -4
  53. package/dist/workers/reasoning-warning.d.ts +17 -9
  54. package/dist/workers/reasoning-warning.js +69 -22
  55. package/package.json +1 -1
  56. package/dist/config/reasoning-args.d.ts +0 -23
  57. package/dist/config/reasoning-args.js +0 -28
  58. package/dist/task/implementation-thinking.d.ts +0 -56
  59. package/dist/task/implementation-thinking.js +0 -32
@@ -24,7 +24,7 @@ import type { DebugLine } from './debug-log.js';
24
24
  /**
25
25
  * One research worker's row. `section` is the heading its output is assembled
26
26
  * and cached under; `label` is its child NAME — what the loader and the debug
27
- * trail print, and the key into `REASONING_GROUP_BY_CHILD` (config/reasoning.ts).
27
+ * trail print, and the key into `GROUP_BY_CHILD` (config/groups.ts).
28
28
  */
29
29
  export interface ResearchWorkerSpec {
30
30
  section: string;
@@ -72,7 +72,7 @@ export interface ResearchWorkerRun {
72
72
  signal: AbortSignal;
73
73
  spawn?: SpawnFn;
74
74
  /** The `--thinking` fragment for a named child. */
75
- thinkingFor: (label: string) => string[];
75
+ groupArgsFor: (label: string) => string[];
76
76
  logDebug?: (msg: string, kind?: DebugLine) => void;
77
77
  onChildOutput?: (line: string) => void;
78
78
  /** Record one finished worker's timing splits. */
@@ -241,7 +241,7 @@ export async function runResearchWorker(spec, run, prior = []) {
241
241
  // config/reasoning.ts is where each one's level lives, so this
242
242
  // line decides what THIS worker runs at for a default-mode
243
243
  // user.
244
- thinking: run.thinkingFor(spec.label),
244
+ groupArgs: run.groupArgsFor(spec.label),
245
245
  ...(spec.tools ? { tools: spec.tools } : {}),
246
246
  ...(spec.extensions ? { extensions: spec.extensions } : {}),
247
247
  // The three lever spreads that would otherwise sit here are the
@@ -11,7 +11,7 @@ import { runChild } from '../shared/child-process.js';
11
11
  import { docsLookup } from './docs-lookup.js';
12
12
  import { buildExtractionPrompt } from './abstention.js';
13
13
  import {} from '../shared/child-output.js';
14
- import { groupThinkingArgs } from '../config/reasoning-args.js';
14
+ import { groupChildArgs } from '../config/group-args.js';
15
15
  const DEFAULT_LIMIT = PACKAGE_RETRIEVE_LIMIT;
16
16
  const DEFAULT_BUDGET = RETRIEVE_CONTENT_BUDGET;
17
17
  const NO_CACHE_HEAD = 25_000;
@@ -550,7 +550,7 @@ export async function docsFocused(input) {
550
550
  spawn,
551
551
  // The `extraction` group's level. Resolved at the call site so neither
552
552
  // the lookup nor the extractor reads ambient config.
553
- thinking: groupThinkingArgs('extraction')
553
+ groupArgs: groupChildArgs('extraction')
554
554
  });
555
555
  const extraction = r.extraction;
556
556
  const base = {
@@ -36,10 +36,11 @@ export interface DocsLookupInput {
36
36
  signal?: AbortSignal;
37
37
  spawn?: SpawnFn;
38
38
  /**
39
- * The `extraction` group's `--thinking` fragment. Resolved by the CALLER so
40
- * this module like the extractor it wraps never reads ambient config.
39
+ * The `extraction` group's argv fragment its model and its thinking level.
40
+ * Resolved by the CALLER so this module, like the extractor it wraps, never
41
+ * reads ambient config.
41
42
  */
42
- thinking: readonly string[];
43
+ groupArgs: readonly string[];
43
44
  }
44
45
  export type DocsLookup = {
45
46
  kind: 'answer';
@@ -28,7 +28,7 @@ export async function docsLookup(input) {
28
28
  cwd: input.cwd,
29
29
  signal: input.signal,
30
30
  spawn: input.spawn,
31
- thinking: input.thinking,
31
+ groupArgs: input.groupArgs,
32
32
  abortedMessage: input.corpus.abortedMessage
33
33
  });
34
34
  if (!extraction.ok)
@@ -2,7 +2,7 @@ import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
2
2
  import { runFocusedExtraction } from './focused-extractor.js';
3
3
  import { abstentionSentence } from './abstention.js';
4
4
  import {} from '../shared/child-output.js';
5
- import { groupThinkingArgs } from '../config/reasoning-args.js';
5
+ import { groupChildArgs } from '../config/group-args.js';
6
6
  const CONTENT_BUDGET = 30_000;
7
7
  const HEAD_CHARS = 25_000;
8
8
  const TAIL_CHARS = 5_000;
@@ -79,7 +79,7 @@ export async function fetchFocused(input) {
79
79
  spawn: input.spawn,
80
80
  // The `extraction` group's level. Resolved at the call site so the
81
81
  // extractor itself never reads ambient config.
82
- thinking: groupThinkingArgs('extraction'),
82
+ groupArgs: groupChildArgs('extraction'),
83
83
  abortedMessage: 'Fetch aborted.'
84
84
  });
85
85
  const base = {
@@ -3,12 +3,13 @@ import { type ExcerptVerification } from '../shared/child-output.js';
3
3
  /**
4
4
  * The argv every focused extraction child runs with: the shared child base — any whitelisted
5
5
  * extensions, then `--print --no-skills --no-extensions --no-prompt-templates
6
- * --no-context-files --no-session` — then the caller's thinking fragment, then `--no-tools`.
6
+ * --no-context-files --no-session` — then the caller's group fragment (its model and its
7
+ * thinking level), then `--no-tools`.
7
8
  *
8
9
  * `--no-tools` is the contract, not a default: the child is given all the content it may use
9
10
  * inside its prompt, so a tool call could only reach for something unsourced.
10
11
  */
11
- export declare const focusedChildArgs: (thinking?: readonly string[]) => string[];
12
+ export declare const focusedChildArgs: (groupArgs?: readonly string[]) => string[];
12
13
  export interface FocusedRequest {
13
14
  /** The fully assembled prompt, including the content block. Delivered on stdin. */
14
15
  prompt: string;
@@ -42,7 +43,7 @@ export interface FocusedRequest {
42
43
  * `focusedChildArgs` read ambient config, and a function that reads config
43
44
  * internally cannot be tested without the developer's own machine state.
44
45
  */
45
- thinking?: readonly string[];
46
+ groupArgs?: readonly string[];
46
47
  }
47
48
  /** What every outcome carries, success or failure — the raw child evidence. */
48
49
  interface FocusedChildEvidence {
@@ -29,14 +29,15 @@ import { formatChildFailure } from './shared.js';
29
29
  /**
30
30
  * The argv every focused extraction child runs with: the shared child base — any whitelisted
31
31
  * extensions, then `--print --no-skills --no-extensions --no-prompt-templates
32
- * --no-context-files --no-session` — then the caller's thinking fragment, then `--no-tools`.
32
+ * --no-context-files --no-session` — then the caller's group fragment (its model and its
33
+ * thinking level), then `--no-tools`.
33
34
  *
34
35
  * `--no-tools` is the contract, not a default: the child is given all the content it may use
35
36
  * inside its prompt, so a tool call could only reach for something unsourced.
36
37
  */
37
- export const focusedChildArgs = (thinking = []) => [
38
+ export const focusedChildArgs = (groupArgs = []) => [
38
39
  ...childBaseArgs(),
39
- ...thinking,
40
+ ...groupArgs,
40
41
  '--no-tools'
41
42
  ];
42
43
  /**
@@ -49,7 +50,7 @@ export const focusedChildArgs = (thinking = []) => [
49
50
  */
50
51
  export async function runFocusedExtraction(req) {
51
52
  const spawn = req.spawn ?? defaultSpawn;
52
- const invocation = getPiInvocation(focusedChildArgs(req.thinking), req.prompt);
53
+ const invocation = getPiInvocation(focusedChildArgs(req.groupArgs), req.prompt);
53
54
  const child = await runChild(spawn, invocation, req.cwd, req.signal);
54
55
  const evidence = {
55
56
  exitCode: child.exitCode,
@@ -4,6 +4,7 @@ import { registerPiWorkerFetch } from './pi-worker-fetch.js';
4
4
  import { registerPiWorkerDocs } from './pi-worker-docs.js';
5
5
  import { registerBraveKeyWarning } from './brave-warning.js';
6
6
  import { registerReasoningWarning } from './reasoning-warning.js';
7
+ import { registerModelWarning } from './model-warning.js';
7
8
  export function registerWorkers(pi) {
8
9
  registerPiWorker(pi);
9
10
  registerPiWorkerSearch(pi);
@@ -11,4 +12,5 @@ export function registerWorkers(pi) {
11
12
  registerPiWorkerDocs(pi);
12
13
  registerBraveKeyWarning(pi);
13
14
  registerReasoningWarning(pi);
15
+ registerModelWarning(pi);
14
16
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Resolve every model cell ONCE per session, and say what does not work.
3
+ *
4
+ * WHY HERE AND NOT AT ARGV TIME
5
+ * -----------------------------
6
+ * The honest question is "can a `--no-extensions` child resolve this spec?", and
7
+ * only `ctx.modelRegistry` can answer it. Five of the six argv producers have no
8
+ * `ctx` at all, and the answer cannot be read from disk either: models.json plus
9
+ * models-store.json are only part of the catalogue, since pi-ai ships built-in
10
+ * lists for 39 providers and this project does not depend on pi-ai. So it is
11
+ * asked at `session_start`, where ctx exists and every task is still in the
12
+ * future, and the verdict is left in group-args.ts for the producers to consult.
13
+ *
14
+ * WHY DROPPING THE FLAG IS THE RIGHT DEGRADE
15
+ * ------------------------------------------
16
+ * A spec whose model is gone but whose PROVIDER still has other models does not
17
+ * make pi exit. `buildFallbackModel` invents a synthetic model id, forces
18
+ * `reasoning: true` onto it, inherits the provider's default baseUrl, and
19
+ * answers at exit 0 — so the child silently runs a model nobody chose. Dropping
20
+ * the flag runs the child exactly as it ran last week and names the cell.
21
+ *
22
+ * A SEPARATE hint from the reasoning one, with its own widget key: the two have
23
+ * different fixes (`models.json` vs `/task-config`) and that line is already at
24
+ * its length budget.
25
+ */
26
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
27
+ import { type ChildGroup } from '../config/groups.js';
28
+ /** One cell that will not do what it says. */
29
+ export interface ModelProblem {
30
+ group: ChildGroup;
31
+ spec: string;
32
+ /**
33
+ * `unresolved` — no such model here, so the flag is dropped.
34
+ * `extension` — resolvable only because a host extension registered its
35
+ * provider. Children run `--no-extensions`, which disables DISCOVERY only,
36
+ * so it works exactly when that extension is in the child whitelist. We
37
+ * cannot tell which extension registered it: `getRegisteredProviderIds()`
38
+ * gives ids, and the `{name, config, extensionPath}` triples live in the
39
+ * runner's internal state. So this is a warning, not a drop — and getting it
40
+ * wrong fails loudly anyway, since the child's resolver reports "not found"
41
+ * and exits 1.
42
+ */
43
+ why: 'unresolved' | 'extension';
44
+ }
45
+ /** The registry questions this needs, so a test can answer them with a literal. */
46
+ export interface ModelLookup {
47
+ find: (provider: string, id: string) => unknown;
48
+ extensionProviders: ReadonlySet<string>;
49
+ }
50
+ export declare function findModelProblems(lookup: ModelLookup, specs: Readonly<Record<ChildGroup, string>>): ModelProblem[];
51
+ /**
52
+ * The hint line, or null when every cell is fine.
53
+ *
54
+ * Names at most two cells per cause and appends `(+N more)`, the same budget the
55
+ * reasoning line keeps and for the same reason.
56
+ */
57
+ export declare function formatModelWarning(problems: readonly ModelProblem[]): string | null;
58
+ export declare function registerModelWarning(pi: ExtensionAPI,
59
+ /** Injected by tests, which must not depend on the developer's saved config. */
60
+ readSpecs?: () => Readonly<Record<ChildGroup, string>>): void;
61
+ /**
62
+ * Answer, once, what every model cell resolves to — and leave both answers where
63
+ * the code that has no `ctx` can read them.
64
+ *
65
+ * `unresolved` only feeds the argv drop: an extension-provided model may well
66
+ * work, since children load explicitly whitelisted extensions, and dropping its
67
+ * flag would break a config that is merely fragile.
68
+ */
69
+ export declare function resolveModelCells(ctx: ExtensionContext, specs: Readonly<Record<ChildGroup, string>>): ModelProblem[];
@@ -0,0 +1,113 @@
1
+ import { getConfig } from '../config/config.js';
2
+ import { setGroupWindows, setUnusableSpecs } from '../config/group-args.js';
3
+ import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
4
+ import { CHILD_GROUPS } from '../config/groups.js';
5
+ import { contextWindowForSpec } from '../task/context-usage.js';
6
+ import { registerSessionHint } from './session-hint.js';
7
+ const WIDGET_KEY = 'pi-task-model-warning';
8
+ export function findModelProblems(lookup, specs) {
9
+ const out = [];
10
+ for (const group of CHILD_GROUPS) {
11
+ const spec = specs[group];
12
+ if (spec === MODEL_INHERIT)
13
+ continue;
14
+ const parts = splitSpec(spec);
15
+ if (!parts || lookup.find(parts.provider, parts.id) === undefined) {
16
+ out.push({ group, spec, why: 'unresolved' });
17
+ continue;
18
+ }
19
+ if (lookup.extensionProviders.has(parts.provider)) {
20
+ out.push({ group, spec, why: 'extension' });
21
+ }
22
+ }
23
+ return out;
24
+ }
25
+ /**
26
+ * The hint line, or null when every cell is fine.
27
+ *
28
+ * Names at most two cells per cause and appends `(+N more)`, the same budget the
29
+ * reasoning line keeps and for the same reason.
30
+ */
31
+ export function formatModelWarning(problems) {
32
+ if (problems.length === 0)
33
+ return null;
34
+ const list = (why) => {
35
+ const hits = problems.filter(p => p.why === why);
36
+ const shown = hits.slice(0, 2).map(p => `${p.group}→${p.spec}`);
37
+ return shown.join(', ') + (hits.length > 2 ? ` (+${hits.length - 2} more)` : '');
38
+ };
39
+ const parts = [];
40
+ if (problems.some(p => p.why === 'unresolved')) {
41
+ parts.push(`no such model here — ${list('unresolved')}. Those steps run on pi's default `
42
+ + 'instead; fix the entry in ~/.pi/agent/models.json or pick another model in '
43
+ + '/task-config');
44
+ }
45
+ if (problems.some(p => p.why === 'extension')) {
46
+ parts.push(`provider comes from an extension — ${list('extension')}. Children run `
47
+ + '--no-extensions, so add that extension under "child extensions" in '
48
+ + '/task-config or those steps exit 1');
49
+ }
50
+ return `⚠ pi-task models: ${parts.join('. Also: ')}`;
51
+ }
52
+ export function registerModelWarning(pi,
53
+ /** Injected by tests, which must not depend on the developer's saved config. */
54
+ readSpecs = () => getConfig().groupModels) {
55
+ // TWO handlers, deliberately. The resolution pass must run in EVERY mode:
56
+ // `registerSessionHint` returns early when `ctx.mode !== 'tui'`, so folding
57
+ // this into it would leave the argv drop and the churn windows disarmed for
58
+ // every headless and `--print` run — a guard that only works when someone is
59
+ // watching is not a guard.
60
+ pi.on('session_start', (_event, ctx) => {
61
+ resolveModelCells(ctx, readSpecs());
62
+ });
63
+ registerSessionHint(pi, WIDGET_KEY, ctx => {
64
+ const text = formatModelWarning(findModelProblems(lookupFor(ctx), readSpecs()));
65
+ return text === null ? null : { text };
66
+ });
67
+ }
68
+ /**
69
+ * Answer, once, what every model cell resolves to — and leave both answers where
70
+ * the code that has no `ctx` can read them.
71
+ *
72
+ * `unresolved` only feeds the argv drop: an extension-provided model may well
73
+ * work, since children load explicitly whitelisted extensions, and dropping its
74
+ * flag would break a config that is merely fragile.
75
+ */
76
+ export function resolveModelCells(ctx, specs) {
77
+ const problems = findModelProblems(lookupFor(ctx), specs);
78
+ setUnusableSpecs(problems.filter(p => p.why === 'unresolved').map(p => p.spec));
79
+ // The SAME walk fills the window table, so the argv and the churn rule can
80
+ // never disagree about which model a group runs on.
81
+ //
82
+ // An `inherit` cell gets NO entry, not the parent's window. Storing one
83
+ // would freeze a session_start snapshot in front of the live per-run value,
84
+ // so a user who switches the session model with Ctrl+P to a bigger one would
85
+ // have every child judged against the old window — the churn rule then fires
86
+ // early and kills a healthy child.
87
+ //
88
+ // The whole walk is guarded because `ctx.model` and `ctx.modelRegistry` are
89
+ // GETTERS that call `assertActive()` and throw on a stale context. Losing the
90
+ // windows must not also lose the `setUnusableSpecs` above it, nor the hint.
91
+ try {
92
+ setGroupWindows(Object.fromEntries(CHILD_GROUPS.filter(g => specs[g] !== MODEL_INHERIT).map(g => [g, contextWindowForSpec(ctx, specs[g])])));
93
+ }
94
+ catch {
95
+ setGroupWindows({});
96
+ }
97
+ return problems;
98
+ }
99
+ function lookupFor(ctx) {
100
+ try {
101
+ const registry = ctx.modelRegistry;
102
+ return {
103
+ find: (provider, id) => registry.find(provider, id),
104
+ extensionProviders: new Set(registry.getRegisteredProviderIds())
105
+ };
106
+ }
107
+ catch {
108
+ // A registry that cannot answer must not condemn every cell. Claiming
109
+ // every spec is unresolved would drop every --model flag on a session
110
+ // whose runtime simply was not ready.
111
+ return { find: () => ({}), extensionProviders: new Set() };
112
+ }
113
+ }
@@ -157,15 +157,15 @@ export interface RunWorkerInput {
157
157
  promptCharsBefore: number;
158
158
  }) => void;
159
159
  /**
160
- * An already-resolved `['--thinking', level]` fragment, or `[]`/omitted to
161
- * inherit the session default exactly as before.
160
+ * An already-resolved group fragment — `--model` then `--thinking` or
161
+ * `[]`/omitted to inherit both defaults exactly as before.
162
162
  *
163
- * Resolved by the CALLER because runWorker serves three different reasoning
164
- * groups — the research workers, the post-implementation gates, and the
165
- * ad-hoc `pi-worker` tool — and has nothing in its input that tells them
166
- * apart. Guessing here would give a verify gate the research workers' level.
163
+ * Resolved by the CALLER because runWorker serves three different groups —
164
+ * the research workers, the post-implementation gates, and the ad-hoc
165
+ * `pi-worker` tool — and has nothing in its input that tells them apart.
166
+ * Guessing here would give a verify gate the research workers' model.
167
167
  */
168
- thinking?: readonly string[];
168
+ groupArgs?: readonly string[];
169
169
  /**
170
170
  * Called once per DISCARDED attempt, at the moment the worker decides to
171
171
  * re-spawn — the only window in which a restart is observable at all.
@@ -8,7 +8,8 @@ import { LoopDetector, MAX_LOOP_RESTARTS } from '../task/loop-detector.js';
8
8
  import { StallDetector, formatStallHint } from '../task/stall-detector.js';
9
9
  import { formatLoopHint, isConnectionError, connectionRetryBackoffMs } from '../task/child-runner.js';
10
10
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
11
- import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
11
+ import { childModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
12
+ import { modelSpecFromArgs } from '../config/group-models.js';
12
13
  import { streamStallHint } from '../shared/stream-watchdog.js';
13
14
  import { classifyWorkerFailure } from './worker-failure.js';
14
15
  import { CARRY_FORWARD_IDS, RESTART_ORDER } from './worker-kill.js';
@@ -344,7 +345,7 @@ export async function runWorker(input) {
344
345
  // moments before close and leave workMs at nearly zero.
345
346
  const baseArgs = [
346
347
  ...childBaseArgs(input.extensions ?? []),
347
- ...(input.thinking ?? []),
348
+ ...(input.groupArgs ?? []),
348
349
  '--mode',
349
350
  'json',
350
351
  '--tools',
@@ -449,7 +450,7 @@ export async function runWorker(input) {
449
450
  // Kept as data so a resolved policy stays plain
450
451
  // comparable data — see StalledGuard.probe.
451
452
  probe: guards.stalled.probe
452
- ?? (() => probeModelEndpoints(discoverModelEndpoints()))
453
+ ?? (() => probeModelEndpoints(childModelEndpoints(modelSpecFromArgs(input.groupArgs ?? []))))
453
454
  }
454
455
  }),
455
456
  ...(guards['stream-stall'] ? { streamInactivityMs: guards['stream-stall'] } : {}),
@@ -16,7 +16,7 @@ import { normalizeQuery } from './research-cache.js';
16
16
  import { projectDocsRaw } from './docs-project.js';
17
17
  import { projectDocsBudget, projectDocsBudgetExhausted } from '../task/research-fanout-budget.js';
18
18
  import { isAbstention } from './abstention.js';
19
- import { groupThinkingArgs } from '../config/reasoning-args.js';
19
+ import { groupChildArgs } from '../config/group-args.js';
20
20
  const RENDER_QUERY_MAX = 100;
21
21
  const Params = Type.Object({
22
22
  module: Type.String({
@@ -139,7 +139,7 @@ export function registerPiWorkerDocs(pi, internals = {}) {
139
139
  cwd: ctx.cwd,
140
140
  signal,
141
141
  spawn,
142
- thinking: groupThinkingArgs('extraction')
142
+ groupArgs: groupChildArgs('extraction')
143
143
  });
144
144
  // ── Project source lookup ───────────────────────────────────────
145
145
  if (params.module === '.') {
@@ -14,9 +14,9 @@
14
14
  import { Text } from '@earendil-works/pi-tui';
15
15
  import { Type } from '@sinclair/typebox';
16
16
  import { getConfig } from '../config/config.js';
17
- import { groupThinkingArgs } from '../config/reasoning-args.js';
17
+ import { groupChildArgs } from '../config/group-args.js';
18
18
  import { runWorker } from './pi-worker-core.js';
19
- import { getParentContextWindow } from '../task/context-usage.js';
19
+ import { contextWindowForGroup } from '../task/context-usage.js';
20
20
  import { childFailureReason, formatChildFailure, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
21
21
  const RENDER_PROMPT_MAX = 120;
22
22
  const WorkerParams = Type.Object({
@@ -66,8 +66,8 @@ export function registerPiWorker(pi) {
66
66
  // this codebase passes `-m`, so the parent's model IS the child's
67
67
  // model and its window is the honest one. Without this the churn
68
68
  // rule cannot fire — see RunWorkerInput.contextWindow.
69
- contextWindow: getParentContextWindow(ctx) || 'unknown',
70
- thinking: groupThinkingArgs('research')
69
+ contextWindow: contextWindowForGroup(ctx, 'research') || 'unknown',
70
+ groupArgs: groupChildArgs('research')
71
71
  });
72
72
  const details = { exitCode: result.exitCode };
73
73
  const failure = formatChildFailure(result, 'Worker aborted.');
@@ -20,19 +20,22 @@
20
20
  * silences it: an all-`inherit` table yields no mismatches for any model.
21
21
  */
22
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
23
- import { type GroupSetting, type ReasoningGroup } from '../config/reasoning.js';
23
+ import { type GroupSetting, type ChildGroup } from '../config/reasoning.js';
24
24
  import { type ReasoningMismatch } from '../shared/reasoning-capability.js';
25
25
  import { type ChatTemplateCaps } from '../shared/model-endpoint.js';
26
26
  /**
27
27
  * The warning line for a set of mismatches.
28
28
  *
29
- * Names the MODEL it checked, because children carry no `-m` and resolve pi's
30
- * default model, which need not be the host session's a warning that does not
31
- * say what it looked at cannot be acted on. Names at most two groups and appends
32
- * `(+N more)` only when there are more than two, since a line long enough to list
33
- * every group is a line nobody reads. Null when nothing mismatched.
29
+ * Each item names its OWN model `phase@acme/small medium→off` because groups
30
+ * can now run on different ones. A single leading `model "X" will not run …`
31
+ * would be a lie about what was checked the moment two groups differ, and a
32
+ * warning that misdescribes its own subject cannot be acted on.
33
+ *
34
+ * Names at most two groups and appends `(+N more)` only when there are more than
35
+ * two, since a line long enough to list every group is a line nobody reads. Null
36
+ * when nothing mismatched.
34
37
  */
35
- export declare function formatReasoningWarning(modelName: string, mismatches: readonly ReasoningMismatch[]): string | null;
38
+ export declare function formatReasoningWarning(mismatches: readonly ReasoningMismatch[]): string | null;
36
39
  /**
37
40
  * The extra cause line, when the SERVER disagrees with models.json.
38
41
  *
@@ -49,11 +52,16 @@ export declare function registerReasoningWarning(pi: ExtensionAPI,
49
52
  * `session_start` so a /task-config change since the last session counts.
50
53
  * Injected by tests, which must not depend on the developer's saved config.
51
54
  */
52
- readSettings?: () => Readonly<Record<ReasoningGroup, GroupSetting>>,
55
+ readSettings?: () => Readonly<Record<ChildGroup, GroupSetting>>,
53
56
  /**
54
57
  * The server-side chat-template probe. Injected so the REFINE path — the
55
58
  * only half of this hint that talks to a network — is drivable at all; with
56
59
  * the real probe it is reachable only from a model entry carrying a
57
60
  * `baseUrl`, which no test model has.
58
61
  */
59
- probe?: (baseUrl: string) => Promise<ChatTemplateCaps | null>): void;
62
+ probe?: (baseUrl: string) => Promise<ChatTemplateCaps | null>,
63
+ /**
64
+ * Which model each group runs on. Injected for the same reason `readSettings`
65
+ * is: a test must not depend on the developer's saved config.
66
+ */
67
+ readSpecs?: () => Readonly<Record<ChildGroup, string>>): void;
@@ -22,27 +22,31 @@
22
22
  import { getConfig } from '../config/config.js';
23
23
  import { effectiveReasoning } from '../config/reasoning.js';
24
24
  import { reasoningMismatches } from '../shared/reasoning-capability.js';
25
+ import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
25
26
  import { probeChatTemplateCaps } from '../shared/model-endpoint.js';
26
27
  import { registerSessionHint } from './session-hint.js';
27
28
  const WIDGET_KEY = 'pi-task-reasoning-warning';
28
29
  /**
29
30
  * The warning line for a set of mismatches.
30
31
  *
31
- * Names the MODEL it checked, because children carry no `-m` and resolve pi's
32
- * default model, which need not be the host session's a warning that does not
33
- * say what it looked at cannot be acted on. Names at most two groups and appends
34
- * `(+N more)` only when there are more than two, since a line long enough to list
35
- * every group is a line nobody reads. Null when nothing mismatched.
32
+ * Each item names its OWN model `phase@acme/small medium→off` because groups
33
+ * can now run on different ones. A single leading `model "X" will not run …`
34
+ * would be a lie about what was checked the moment two groups differ, and a
35
+ * warning that misdescribes its own subject cannot be acted on.
36
+ *
37
+ * Names at most two groups and appends `(+N more)` only when there are more than
38
+ * two, since a line long enough to list every group is a line nobody reads. Null
39
+ * when nothing mismatched.
36
40
  */
37
- export function formatReasoningWarning(modelName, mismatches) {
41
+ export function formatReasoningWarning(mismatches) {
38
42
  if (mismatches.length === 0)
39
43
  return null;
40
44
  const shown = mismatches
41
45
  .slice(0, 2)
42
- .map(m => `${m.group} ${m.wanted}→${m.actual}`)
46
+ .map(m => `${m.group}@${m.modelName} ${m.wanted}→${m.actual}`)
43
47
  .join(', ');
44
48
  const rest = mismatches.length > 2 ? ` (+${mismatches.length - 2} more)` : '';
45
- return (`⚠ pi-task: model "${modelName}" will not run the reasoning levels /task-config asks `
49
+ return ('⚠ pi-task: some steps will not run the reasoning levels /task-config asks '
46
50
  + `for — ${shown}${rest}. pi clamps to what the model declares. Fix "reasoning" / `
47
51
  + '"thinkingLevelMap" for it in ~/.pi/agent/models.json, or set those steps back to '
48
52
  + '"inherit" in /task-config');
@@ -82,31 +86,74 @@ readSettings = () => effectiveReasoning(getConfig()),
82
86
  * the real probe it is reachable only from a model entry carrying a
83
87
  * `baseUrl`, which no test model has.
84
88
  */
85
- probe = probeChatTemplateCaps) {
89
+ probe = probeChatTemplateCaps,
90
+ /**
91
+ * Which model each group runs on. Injected for the same reason `readSettings`
92
+ * is: a test must not depend on the developer's saved config.
93
+ */
94
+ readSpecs = () => getConfig().groupModels) {
86
95
  registerSessionHint(pi, WIDGET_KEY, ctx => {
87
- const model = ctx.model;
88
- const mismatches = reasoningMismatches(model, readSettings());
89
- if (mismatches.length === 0)
90
- return null;
91
- const base = formatReasoningWarning(model?.name ?? model?.id ?? 'unknown', mismatches);
96
+ const facts = (g) => groupModelFacts(ctx, readSpecs()[g]);
97
+ const mismatches = reasoningMismatches(facts, readSettings());
98
+ const base = formatReasoningWarning(mismatches);
92
99
  if (base === null)
93
100
  return null;
94
101
  // Fire-and-forget: the server probe only ever REFINES the cause line, so it
95
102
  // must not delay the warning or be able to prevent it. `probeChatTemplateCaps`
96
103
  // carries its own short timeout and returns null on any failure, so a
97
104
  // backend that does not answer `/props` costs nothing.
98
- const baseUrl = model?.baseUrl;
99
- if (model === undefined || baseUrl === undefined || baseUrl === '')
105
+ //
106
+ // One probe per DISTINCT baseUrl among the mismatching groups, not one
107
+ // per group: eleven groups usually collapse to one or two servers, and
108
+ // four research workers on one server would otherwise print the same
109
+ // sentence four times. `allSettled`, so one dead endpoint cannot blank
110
+ // the line for the others.
111
+ const probes = distinctBackends(mismatches, facts);
112
+ if (probes.length === 0)
100
113
  return { text: base };
101
- const declares = model.reasoning;
102
114
  return {
103
115
  text: base,
104
- refine: probe(baseUrl).then(caps => {
105
- if (caps === null)
106
- return null;
107
- const extra = formatCapabilityConflict(caps.supportsReasoningEffort, declares);
108
- return extra === null ? null : base + extra;
116
+ refine: Promise.allSettled(probes.map(async (b) => {
117
+ const caps = await probe(b.baseUrl);
118
+ return caps === null ? null : (formatCapabilityConflict(caps.supportsReasoningEffort, b.declares));
119
+ })).then(results => {
120
+ const causes = new Set(results.flatMap(r => r.status === 'fulfilled' && r.value !== null ? [r.value] : []));
121
+ return causes.size === 0 ? null : base + [...causes].join('');
109
122
  })
110
123
  };
111
124
  });
112
125
  }
126
+ /** What one group runs on, as {@link reasoningMismatches} wants it. */
127
+ function groupModelFacts(ctx, spec) {
128
+ // `inherit` is the session's model. That is decision 3 of the model table —
129
+ // children are NOT switched to follow the host — and the honest value is
130
+ // settings.json's default, which need not be the session's. Naming the
131
+ // session's model is still the better of the two: it is the one the user can
132
+ // see, and on every machine with one provider the two agree.
133
+ const model = spec === MODEL_INHERIT ?
134
+ ctx.model
135
+ : (() => {
136
+ const parts = splitSpec(spec);
137
+ return parts ? ctx.modelRegistry.find(parts.provider, parts.id) : undefined;
138
+ })();
139
+ if (!model)
140
+ return undefined;
141
+ return {
142
+ name: model.name || model.id,
143
+ reasoning: model.reasoning,
144
+ ...(model.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: model.thinkingLevelMap }),
145
+ ...(model.baseUrl ? { baseUrl: model.baseUrl } : {})
146
+ };
147
+ }
148
+ /** The distinct servers behind a set of mismatches, deduped by URL. */
149
+ function distinctBackends(mismatches, facts) {
150
+ const byUrl = new Map();
151
+ for (const m of mismatches) {
152
+ const f = facts(m.group);
153
+ if (!f?.baseUrl)
154
+ continue;
155
+ if (!byUrl.has(f.baseUrl))
156
+ byUrl.set(f.baseUrl, { baseUrl: f.baseUrl, declares: f.reasoning });
157
+ }
158
+ return [...byUrl.values()];
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.31",
3
+ "version": "0.39.0",
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",