pi-pstack-adapter 0.8.0 → 0.9.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.
package/src/config.ts CHANGED
@@ -7,6 +7,21 @@ import { expandHome } from "./paths.ts";
7
7
  import { MAX_PANEL_ENTRIES, isPanelRole } from "./registry.ts";
8
8
  import { DEFAULT_DELEGATION, DELEGATION_PROVIDERS, isDelegationProvider } from "./delegation.ts";
9
9
  import type { DelegationProvider, SourceSelection, Trust } from "./types.ts";
10
+ import { type ModelFilter, parseModelFilter } from "./filter.ts";
11
+
12
+ /**
13
+ * A role's model. A list is a pool of alternatives for an ordinary role: each delegation picks one
14
+ * eligible entry. A panel role keeps its space-separated string, one seat per entry.
15
+ */
16
+ export type ModelValue = string | readonly string[];
17
+
18
+ /** A pool holds this many alternatives at most, like a panel's seat limit. */
19
+ export const MAX_POOL_ENTRIES = 8;
20
+
21
+ /** The entries of a role value, one for a plain string. */
22
+ export function poolEntries(value: ModelValue): readonly string[] {
23
+ return typeof value === "string" ? [value] : value;
24
+ }
10
25
 
11
26
  export interface AdapterConfig {
12
27
  readonly namespace: string;
@@ -19,7 +34,9 @@ export interface AdapterConfig {
19
34
  readonly pinnedCommit?: string;
20
35
  readonly allowBootstrap: boolean;
21
36
  readonly cacheDir?: string;
22
- readonly models: Readonly<Record<string, string>>;
37
+ readonly models: Readonly<Record<string, ModelValue>>;
38
+ /** The saved model filter and the layer it came from. A later layer replaces it whole. */
39
+ readonly modelFilter?: { readonly filter: ModelFilter; readonly source: string };
23
40
  /** The delegation backend tried first. The other one is the fallback. */
24
41
  readonly delegation: DelegationProvider;
25
42
  readonly sources: readonly string[];
@@ -34,7 +51,8 @@ export interface ConfigFile {
34
51
  pinnedCommit?: string;
35
52
  allowBootstrap?: boolean;
36
53
  cacheDir?: string;
37
- models?: Record<string, string>;
54
+ models?: Record<string, ModelValue>;
55
+ modelFilter?: ModelFilter;
38
56
  delegation?: string;
39
57
  }
40
58
 
@@ -304,9 +322,64 @@ function invalidModelMessage(model: unknown): string {
304
322
  return `Invalid model value ${typeof model === "string" ? `(${model.length} characters)` : safeEcho(model)}. Use provider/model or provider/model:thinking with no spaces or markup characters, at most 128 characters.`;
305
323
  }
306
324
 
307
- /** `role=provider/model[:thinking],role2=provider/model2` */
308
- export function parseModelRoles(value: string): Record<string, string> {
309
- const roles: Record<string, string> = {};
325
+ export type PoolAssignment =
326
+ | { readonly kind: "assign"; readonly value: ModelValue }
327
+ | { readonly kind: "rejected"; readonly message: string };
328
+
329
+ /**
330
+ * Validates a `set_model` pool for an ordinary role. `thinking` applies to each entry without its own
331
+ * `:level`. Every entry must be a registry model; one bad entry rejects the whole pool. A pool of one
332
+ * is written as a plain string, the format older adapters read.
333
+ */
334
+ export function planPoolAssignment(
335
+ entries: readonly string[],
336
+ thinking: string | undefined,
337
+ available: readonly AvailableModel[],
338
+ ): PoolAssignment {
339
+ const requested = thinking?.trim().toLowerCase() || undefined;
340
+ if (requested !== undefined && !isThinkingLevel(requested)) {
341
+ return {
342
+ kind: "rejected",
343
+ message: `Unknown thinking level \`${safeEcho(thinking).replaceAll('"', "")}\`. Use one of ${THINKING_LEVELS.map((level) => `\`${level}\``).join(", ")}.`,
344
+ };
345
+ }
346
+ const trimmed = entries.map((entry) => (typeof entry === "string" ? entry.trim() : entry));
347
+ if (trimmed.length === 0 || trimmed.length > MAX_POOL_ENTRIES) {
348
+ return { kind: "rejected", message: `A pool takes 1 to ${MAX_POOL_ENTRIES} models. Pass \`inherit\` as a plain string to clear the role.` };
349
+ }
350
+ const written: string[] = [];
351
+ for (const entry of trimmed) {
352
+ if (typeof entry !== "string" || entry === "") return { kind: "rejected", message: "A pool entry must be a non-empty `provider/model` string." };
353
+ if (entry.toLowerCase() === INHERIT_ENTRY) {
354
+ return { kind: "rejected", message: "A pool lists models only. To make the role inherit, pass `model: \"inherit\"` as a plain string." };
355
+ }
356
+ const own = available.some((candidate) => candidate.id === entry) ? undefined : splitModelSpec(entry).thinking;
357
+ const plan = planModelAssignment(entry, own === undefined ? thinking : undefined, available);
358
+ if (plan.kind === "rejected") return { kind: "rejected", message: `Entry \`${safeEcho(entry).replaceAll('"', "")}\`: ${plan.message}` };
359
+ if (plan.kind === "assign") written.push(joinModelSpec(plan.spec));
360
+ }
361
+ return { kind: "assign", value: written.length === 1 ? (written[0] as string) : written };
362
+ }
363
+
364
+ function isValidPoolValue(value: unknown): value is readonly string[] {
365
+ return (
366
+ Array.isArray(value) &&
367
+ value.length > 0 &&
368
+ value.length <= MAX_POOL_ENTRIES &&
369
+ value.every((entry) => typeof entry === "string" && entry.toLowerCase() !== INHERIT_ENTRY && isValidModelSpec(entry))
370
+ );
371
+ }
372
+
373
+ function invalidPoolMessage(value: readonly unknown[]): string {
374
+ return `Invalid pool (${value.length} entries). Use a list of 1 to ${MAX_POOL_ENTRIES} entries, each \`provider/model\` or \`provider/model:thinking\`, with no \`inherit\` entry.`;
375
+ }
376
+
377
+ /**
378
+ * `role=provider/model[:thinking],role2=provider/model2`. An ordinary role takes a pool as
379
+ * `role=a/x|b/y`; `|` never appears in a model id. A panel keeps space-separated seats.
380
+ */
381
+ export function parseModelRoles(value: string): Record<string, ModelValue> {
382
+ const roles: Record<string, ModelValue> = {};
310
383
  for (const pair of value.split(",")) {
311
384
  const trimmed = pair.trim();
312
385
  if (trimmed === "") continue;
@@ -321,6 +394,11 @@ export function parseModelRoles(value: string): Record<string, string> {
321
394
  if (model === "") continue;
322
395
  if (isPanelRole(role)) {
323
396
  if (!isValidPanelValue(model)) throw new Error(`Role ${safeEcho(role)}. ${invalidPanelMessage(model)}`);
397
+ } else if (model.includes("|")) {
398
+ const pool = model.split("|").map((entry) => entry.trim());
399
+ if (!isValidPoolValue(pool)) throw new Error(`Role ${safeEcho(role)}. ${invalidPoolMessage(pool)}`);
400
+ roles[role] = pool;
401
+ continue;
324
402
  } else if (!isValidModelSpec(model)) {
325
403
  throw new Error(invalidModelMessage(model));
326
404
  }
@@ -352,6 +430,7 @@ export function environmentLayer(env: NodeJS.ProcessEnv): ConfigLayer {
352
430
  export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
353
431
  const merged: ConfigFile = {};
354
432
  const sources: string[] = [];
433
+ let filterSource = "";
355
434
  for (const layer of layers) {
356
435
  let contributed = false;
357
436
  for (const [key, value] of Object.entries(layer.values)) {
@@ -368,6 +447,11 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
368
447
  }
369
448
  if (key === "models") {
370
449
  merged.models = { ...merged.models, ...validatedModels(value, layer.label) };
450
+ } else if (key === "modelFilter") {
451
+ const parsed = parseModelFilter(value);
452
+ if ("problem" in parsed) throw new Error(`${layer.label}: ${parsed.problem} Edit \`modelFilter\` in that file, then restart Pi.`);
453
+ merged.modelFilter = parsed.filter;
454
+ filterSource = layer.label;
371
455
  } else {
372
456
  (merged as Record<string, unknown>)[key] = value;
373
457
  }
@@ -390,17 +474,18 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
390
474
  allowBootstrap: merged.allowBootstrap ?? false,
391
475
  cacheDir: merged.cacheDir,
392
476
  models: merged.models ?? {},
477
+ modelFilter: merged.modelFilter ? { filter: merged.modelFilter, source: filterSource } : undefined,
393
478
  delegation: validateDelegation(merged.delegation, layers),
394
479
  sources,
395
480
  };
396
481
  }
397
482
 
398
483
  /** A bad `models` entry fails with the layer that holds it, so status can still be reached after a fix. */
399
- function validatedModels(value: unknown, label: string): Record<string, string> {
484
+ function validatedModels(value: unknown, label: string): Record<string, ModelValue> {
400
485
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
401
486
  throw new Error(`${label} sets \`models\` to something other than an object of role to provider/model.`);
402
487
  }
403
- const models: Record<string, string> = {};
488
+ const models: Record<string, ModelValue> = {};
404
489
  for (const [role, model] of Object.entries(value)) {
405
490
  if (!isValidRole(role)) {
406
491
  throw new Error(`${label}: ${invalidRoleMessage(role)} Edit that key in \`models\` in the named file, then restart Pi.`);
@@ -410,10 +495,13 @@ function validatedModels(value: unknown, label: string): Record<string, string>
410
495
  if (typeof model !== "string" || !isValidPanelValue(model)) {
411
496
  throw new Error(`${label}: role ${safeEcho(role)}. ${invalidPanelMessage(model)}`);
412
497
  }
498
+ } else if (Array.isArray(model)) {
499
+ if (!isValidPoolValue(model)) throw new Error(`${label}: role ${safeEcho(role)}. ${invalidPoolMessage(model)}`);
413
500
  } else if (typeof model !== "string" || !isValidModelSpec(model)) {
414
501
  throw new Error(`${label}: role ${safeEcho(role)}. ${invalidModelMessage(model)}`);
415
502
  }
416
- Object.defineProperty(models, role, { value: model, enumerable: true, writable: true, configurable: true });
503
+ const stored: ModelValue = Array.isArray(model) ? [...(model as string[])] : (model as string);
504
+ Object.defineProperty(models, role, { value: stored, enumerable: true, writable: true, configurable: true });
417
505
  }
418
506
  return models;
419
507
  }
package/src/filter.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { safeEcho } from "./echo.ts";
2
+ import { splitModelSpec } from "./config.ts";
3
+
4
+ /**
5
+ * Which models delegation may use. A pattern with a `/` matches the whole `provider/id`; a pattern
6
+ * without one matches the model id after the last `/`. `*` is the only wildcard. An empty or
7
+ * missing list does not restrict anything.
8
+ */
9
+ export interface ModelFilter {
10
+ readonly include?: readonly string[];
11
+ readonly exclude?: readonly string[];
12
+ }
13
+
14
+ /** Where the filter in force came from, for the status report. */
15
+ export type FilterOrigin =
16
+ | { readonly kind: "none" }
17
+ | { readonly kind: "session"; readonly filter: ModelFilter; readonly shadows?: string }
18
+ | { readonly kind: "saved"; readonly filter: ModelFilter; readonly source: string };
19
+
20
+ export const FILTER_ENTRY_TYPE = "pistack-model-filter";
21
+
22
+ const MAX_PATTERNS = 32;
23
+ const MAX_PATTERN_LENGTH = 128;
24
+ const PATTERN = /^[A-Za-z0-9._:/@~+*-]+$/;
25
+
26
+ export function filterPatternProblem(pattern: unknown): string | undefined {
27
+ if (typeof pattern !== "string" || pattern === "") return `Filter pattern ${safeEcho(pattern)} is not a non-empty string.`;
28
+ if (pattern.length > MAX_PATTERN_LENGTH || !PATTERN.test(pattern)) {
29
+ return `Filter pattern ${pattern.length > MAX_PATTERN_LENGTH ? `(${pattern.length} characters)` : safeEcho(pattern)} is invalid. Use ASCII letters, digits, and \`. _ - : / @ ~ +\`, with \`*\` as the only wildcard, at most ${MAX_PATTERN_LENGTH} characters, for example \`gpt-*\` or \`openai/*\`.`;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ /** Parses an untrusted filter value from a config file or a tool call. */
35
+ export function parseModelFilter(value: unknown): { readonly filter: ModelFilter } | { readonly problem: string } {
36
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
37
+ return { problem: "`modelFilter` must be an object with optional `include` and `exclude` lists of patterns." };
38
+ }
39
+ const keys = Object.keys(value);
40
+ const unknown = keys.filter((key) => key !== "include" && key !== "exclude");
41
+ if (unknown.length > 0) return { problem: `\`modelFilter\` takes only \`include\` and \`exclude\`, not ${unknown.map((key) => safeEcho(key)).join(", ")}.` };
42
+ const filter: { include?: string[]; exclude?: string[] } = {};
43
+ for (const key of ["include", "exclude"] as const) {
44
+ const list = (value as Record<string, unknown>)[key];
45
+ if (list === undefined) continue;
46
+ if (!Array.isArray(list) || list.length > MAX_PATTERNS) {
47
+ return { problem: `\`modelFilter.${key}\` must be a list of at most ${MAX_PATTERNS} patterns.` };
48
+ }
49
+ for (const pattern of list) {
50
+ const problem = filterPatternProblem(pattern);
51
+ if (problem) return { problem: `\`modelFilter.${key}\`: ${problem}` };
52
+ }
53
+ filter[key] = [...(list as string[])];
54
+ }
55
+ return { filter };
56
+ }
57
+
58
+ export function isRestricting(filter: ModelFilter | undefined): filter is ModelFilter {
59
+ return filter !== undefined && ((filter.include?.length ?? 0) > 0 || (filter.exclude?.length ?? 0) > 0);
60
+ }
61
+
62
+ function globMatches(pattern: string, value: string): boolean {
63
+ const glob = pattern.toLowerCase();
64
+ const text = value.toLowerCase();
65
+ let p = 0;
66
+ let v = 0;
67
+ let star = -1;
68
+ let retry = 0;
69
+ while (v < text.length) {
70
+ if (glob[p] === "*") {
71
+ star = p++;
72
+ retry = v;
73
+ } else if (glob[p] === text[v]) {
74
+ p++;
75
+ v++;
76
+ } else if (star !== -1) {
77
+ p = star + 1;
78
+ v = ++retry;
79
+ } else {
80
+ return false;
81
+ }
82
+ }
83
+ while (glob[p] === "*") p++;
84
+ return p === glob.length;
85
+ }
86
+
87
+ function patternMatches(pattern: string, model: string): boolean {
88
+ if (pattern.includes("/")) return globMatches(pattern, model);
89
+ return globMatches(pattern, model.slice(model.lastIndexOf("/") + 1));
90
+ }
91
+
92
+ /** `model` is `provider/id`, optionally with a thinking suffix, which is ignored. */
93
+ export function modelAllowed(filter: ModelFilter | undefined, model: string): boolean {
94
+ if (!isRestricting(filter)) return true;
95
+ const bare = splitModelSpec(model).model;
96
+ const include = filter.include ?? [];
97
+ if (include.length > 0 && !include.some((pattern) => patternMatches(pattern, bare))) return false;
98
+ return !(filter.exclude ?? []).some((pattern) => patternMatches(pattern, bare));
99
+ }
100
+
101
+ export function describeFilter(filter: ModelFilter): string {
102
+ const list = (patterns: readonly string[] | undefined) =>
103
+ patterns && patterns.length > 0 ? patterns.map((pattern) => `\`${pattern}\``).join(", ") : "none";
104
+ return `include ${list(filter.include)}; exclude ${list(filter.exclude)}`;
105
+ }
106
+
107
+ /** The session's filter from the last entry on the active branch: a filter, or a clear. */
108
+ export function sessionFilterFromEntries(entries: readonly unknown[]): ModelFilter | undefined {
109
+ let current: ModelFilter | undefined;
110
+ for (const candidate of entries) {
111
+ if (typeof candidate !== "object" || candidate === null) continue;
112
+ const entry = candidate as { type?: unknown; customType?: unknown; data?: unknown };
113
+ if (entry.type !== "custom" || entry.customType !== FILTER_ENTRY_TYPE) continue;
114
+ const data = entry.data as { cleared?: unknown; filter?: unknown } | undefined;
115
+ if (data?.cleared === true) {
116
+ current = undefined;
117
+ continue;
118
+ }
119
+ const parsed = parseModelFilter(data?.filter);
120
+ if ("filter" in parsed) current = parsed.filter;
121
+ }
122
+ return current;
123
+ }
124
+
125
+ /** The filter in force. An explicit session filter wins over the saved one, even an empty one. */
126
+ export function activeFilter(session: ModelFilter | undefined, saved: { readonly filter: ModelFilter; readonly source: string } | undefined): FilterOrigin {
127
+ if (session !== undefined) return { kind: "session", filter: session, shadows: saved?.source };
128
+ if (saved !== undefined) return { kind: "saved", filter: saved.filter, source: saved.source };
129
+ return { kind: "none" };
130
+ }
131
+
132
+ export function filterOf(origin: FilterOrigin): ModelFilter | undefined {
133
+ return origin.kind === "none" ? undefined : origin.filter;
134
+ }
135
+
136
+ export function describeOrigin(origin: FilterOrigin): string {
137
+ switch (origin.kind) {
138
+ case "none":
139
+ return "none, every model is allowed";
140
+ case "session":
141
+ return `${isRestricting(origin.filter) ? describeFilter(origin.filter) : "unrestricted"} (this session${origin.shadows ? `, overriding the saved filter in ${origin.shadows}` : ""})`;
142
+ case "saved":
143
+ return `${isRestricting(origin.filter) ? describeFilter(origin.filter) : "unrestricted"} (saved in ${origin.source})`;
144
+ }
145
+ }
146
+
147
+ /** One line for each delegation brief, so a child knows the restriction its parent works under. */
148
+ export function briefLine(filter: ModelFilter): string {
149
+ return `Model restriction for this task: use only models allowed by ${describeFilter(filter)}. A pattern with a \`/\` matches \`provider/model\`, one without matches the model id, and \`*\` matches anything. Give every agent you start an allowed model and this line, verbatim. If no allowed model fits, stop and report it.`;
150
+ }
package/src/gate.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { splitModelSpec } from "./config.ts";
2
+ import { SUBAGENT_TOOL, T3_DELEGATE_TOOL } from "./delegation.ts";
3
+ import { type ModelFilter, describeFilter, isRestricting, modelAllowed } from "./filter.ts";
4
+
5
+ export type GateDecision = { readonly kind: "allow" } | { readonly kind: "block"; readonly reason: string };
6
+
7
+ export interface GateContext {
8
+ /** The filter in force. Undefined or empty lets every call through. */
9
+ readonly filter?: ModelFilter;
10
+ /** `provider/id` of the session's model, which a child with no model runs on. */
11
+ readonly parentModel?: string;
12
+ /** Maps a requested model to its registry `provider/id`, or undefined when Pi does not know it. */
13
+ readonly resolveModel: (model: string) => string | undefined;
14
+ }
15
+
16
+ const ALLOW: GateDecision = { kind: "allow" };
17
+
18
+ /**
19
+ * pi-subagents actions that start or restart a child, or schedule one, outside the `model` the
20
+ * call names. The gate cannot see which model they run on, so it blocks them under a filter.
21
+ * Steering can also revive a child. Status, listing, and stopping do not launch children.
22
+ */
23
+ const LAUNCHING_ACTIONS = new Set([
24
+ "resume", "append-step", "debug.run", "refine", "project.open", "watchdog.configure", "steer",
25
+ "schedule.create", "schedule.run", "schedule.run-due", "schedule.resume",
26
+ ]);
27
+
28
+ /**
29
+ * Checks one delegation tool call against the session's model filter. Only `delegate_task` and
30
+ * `subagent` are checked, whoever starts the delegation. Every other tool passes.
31
+ */
32
+ export function gateDelegation(toolName: string, input: unknown, context: GateContext): GateDecision {
33
+ if (!isRestricting(context.filter)) return ALLOW;
34
+ const args = typeof input === "object" && input !== null ? (input as Record<string, unknown>) : {};
35
+ if (toolName === T3_DELEGATE_TOOL) return gateT3(args, context);
36
+ if (toolName === SUBAGENT_TOOL) return gateSubagent(args, context);
37
+ return ALLOW;
38
+ }
39
+
40
+ function block(reason: string, filter: ModelFilter): GateDecision {
41
+ return {
42
+ kind: "block",
43
+ reason: `Blocked by the pstack adapter's model filter (${describeFilter(filter)}). ${reason} Report this failed call to the user. Keep the active filter unchanged unless the user requests a change. This call was not retried.`,
44
+ };
45
+ }
46
+
47
+ function checkModel(requested: unknown, where: string, context: GateContext & { filter: ModelFilter }): GateDecision {
48
+ if (typeof requested !== "string" || requested.trim() === "") {
49
+ return block(`${where} gives a model that is not a non-empty string.`, context.filter);
50
+ }
51
+ const resolved = context.resolveModel(splitModelSpec(requested.trim()).model);
52
+ if (resolved === undefined) {
53
+ return block(`${where} asks for \`${requested.trim()}\`, which this Pi installation does not know, so the filter cannot allow it.`, context.filter);
54
+ }
55
+ if (!modelAllowed(context.filter, resolved)) return block(`${where} asks for \`${resolved}\`, which the filter excludes.`, context.filter);
56
+ return ALLOW;
57
+ }
58
+
59
+ function checkParent(where: string, context: GateContext & { filter: ModelFilter }): GateDecision {
60
+ if (context.parentModel === undefined) {
61
+ return block(`${where} names no model, so it runs on the parent session's model, which Pi does not report. Name an allowed model.`, context.filter);
62
+ }
63
+ if (!modelAllowed(context.filter, context.parentModel)) {
64
+ return block(`${where} names no model, so it runs on the parent session's model \`${context.parentModel}\`, which the filter excludes. Name an allowed model.`, context.filter);
65
+ }
66
+ return ALLOW;
67
+ }
68
+
69
+ /**
70
+ * T3's `delegate_task` starts one child per call. No `target`, or a target without a model, runs on
71
+ * the parent's model. A provider other than Pi names models outside Pi's registry, so the filter
72
+ * cannot match them.
73
+ */
74
+ function gateT3(args: Record<string, unknown>, context: GateContext): GateDecision {
75
+ const filter = context.filter as ModelFilter;
76
+ const scoped = { ...context, filter };
77
+ const target = args.target;
78
+ if (target === undefined || target === null) return checkParent("`delegate_task`", scoped);
79
+ if (typeof target !== "object" || Array.isArray(target)) return block("`delegate_task` has a `target` that is not an object.", filter);
80
+ const { model, providerInstanceId, driverKind, environmentId } = target as Record<string, unknown>;
81
+ if (environmentId !== undefined) {
82
+ return block("`delegate_task` targets a named environment, whose models this session cannot check. Omit `environmentId` to use the current environment.", filter);
83
+ }
84
+ const provider = [providerInstanceId, driverKind].find((value) => value !== undefined && value !== "pi");
85
+ if (provider !== undefined) {
86
+ return block(`\`delegate_task\` targets T3 provider \`${String(provider)}\`, whose models are not Pi registry ids, so the filter cannot check them. Use \`providerInstanceId: "pi"\`.`, filter);
87
+ }
88
+ if (model === undefined) return checkParent("`delegate_task`", scoped);
89
+ return checkModel(model, "`delegate_task`", scoped);
90
+ }
91
+
92
+ /**
93
+ * pi-subagents starts one child from `agent`, several from `tasks` or `chain`, or any number from a
94
+ * workflow script. An agent can carry its own default model, and an external runner or another
95
+ * machine picks its own, so under a filter every child must name an allowed model.
96
+ */
97
+ function gateSubagent(args: Record<string, unknown>, context: GateContext): GateDecision {
98
+ const filter = context.filter as ModelFilter;
99
+ const scoped = { ...context, filter };
100
+ const options = typeof args.options === "object" && args.options !== null ? (args.options as Record<string, unknown>) : {};
101
+ const action = args.action;
102
+ if (typeof action === "string" && action.trim() !== "") {
103
+ if (LAUNCHING_ACTIONS.has(action.trim())) {
104
+ return block(`\`subagent\` action \`${action.trim()}\` can start a child on a model the adapter cannot see.`, filter);
105
+ }
106
+ return ALLOW;
107
+ }
108
+ if (args.workflow !== undefined || options.script !== undefined || args.workflowScript !== undefined) {
109
+ return block("A `subagent` workflow picks its children's models inside the script, where the adapter cannot check them. Start each child with `agent` and an allowed `model` instead.", filter);
110
+ }
111
+ const machine = args.machine ?? options.machine;
112
+ if (machine !== undefined && machine !== "local") {
113
+ return block("A `subagent` child on another machine picks its own model, which the adapter cannot check.", filter);
114
+ }
115
+ const children: Array<{ readonly where: string; readonly model: unknown }> = [];
116
+ if (args.agent !== undefined) children.push({ where: "`subagent`", model: args.model ?? options.model });
117
+ if (Array.isArray(args.tasks)) {
118
+ args.tasks.forEach((task, index) => children.push({ where: `\`subagent\` tasks[${index}]`, model: (task as { model?: unknown } | null)?.model }));
119
+ }
120
+ if (Array.isArray(args.chain)) {
121
+ args.chain.forEach((step, index) => {
122
+ const entry = (step ?? {}) as { model?: unknown; parallel?: unknown; agent?: unknown };
123
+ if (Array.isArray(entry.parallel)) {
124
+ entry.parallel.forEach((task, inner) =>
125
+ children.push({ where: `\`subagent\` chain[${index}].parallel[${inner}]`, model: (task as { model?: unknown } | null)?.model }),
126
+ );
127
+ }
128
+ if (entry.agent !== undefined || !Array.isArray(entry.parallel)) children.push({ where: `\`subagent\` chain[${index}]`, model: entry.model });
129
+ });
130
+ }
131
+ for (const child of children) {
132
+ if (child.model === undefined) {
133
+ return block(`${child.where} names no model, so it would run on its agent's default model or the parent's, which the adapter cannot check. Pass an allowed \`model\`.`, filter);
134
+ }
135
+ const decision = checkModel(child.model, child.where, scoped);
136
+ if (decision.kind === "block") return decision;
137
+ }
138
+ return ALLOW;
139
+ }
package/src/generate.ts CHANGED
@@ -472,6 +472,29 @@ const NESTED_DELEGATION_RULE = `Nested delegation: every agent that delegates, a
472
472
  \`pstack_adapter\` status report, using \`general\` when no role fits. Never let one inherit your
473
473
  model or thinking, except a panel seat from an \`inherit\` entry in that report's \`panel roles\` table, and never raise a role above its configured level."`;
474
474
 
475
+ const MODEL_POLICY_SECTION = `Model pools and the model filter work the same on both backends.
476
+
477
+ - A role whose \`model roles\` row reads "pool, pick one of" lists alternatives. For each
478
+ delegation, pick ONE model from it by fit, and pass that model and its thinking by the backend
479
+ rules below. A pool is not a retry list and not a panel: start one child per task, and when a
480
+ delegation fails, report the failure instead of trying another model from the pool.
481
+ - A role whose row reads "BLOCKED" has no model the model filter allows. Do not delegate its work,
482
+ and do not move the work to \`general\` or to your own model. Tell the user which role is blocked
483
+ and why.
484
+ - A panel seat that reads "BLOCKED" does not run. Run the other seats, and say in your reply which
485
+ seat did not run and why. A seat that reads "one of" runs once, on one model you pick from it.
486
+ - When the \`model filter\` row of the status report names a filter, the adapter blocks any
487
+ \`delegate_task\` or \`subagent\` call in this session whose model the filter excludes, or whose
488
+ model it cannot tell. That covers a call with no model while your own model is excluded, a model
489
+ Pi does not know, a T3 provider other than \`pi\`, a \`subagent\` child without \`model\`, a
490
+ \`subagent\` workflow script, and a \`subagent\` action that starts, restarts, steers, or schedules
491
+ a child or configures a watchdog. A T3 target with a named environment is blocked too.
492
+ Report a blocked call instead of retrying it. Keep the active filter unchanged unless the user
493
+ requests a change. Wait for \`set_filter\` to finish before issuing delegation calls.
494
+ - Children do not inherit a session filter. Put the brief line that the \`check\` report gives into
495
+ every brief, verbatim, so each child keeps to the same models when it starts agents of its own.
496
+ A saved filter applies in a child where the adapter loads the same config files.`;
497
+
475
498
  function delegationSection(namespace: string, preferred: DelegationProvider): string {
476
499
  const sections = delegationOrder(preferred).map((backend) => (backend.id === "t3" ? t3Section(namespace) : subagentSection(namespace)));
477
500
  const order = delegationOrder(preferred).map((backend) => `\`${backend.tool}\``).join(", then ");
@@ -483,6 +506,8 @@ first usable tool in the configured order, ${order}. The adapter rejects a tool
483
506
  extension registered under T3's name, or one that lacks the parameters below, even when it is in
484
507
  your tools. Use one backend for a whole workflow.
485
508
 
509
+ ${MODEL_POLICY_SECTION}
510
+
486
511
  ${sections.join("\n\n")}`;
487
512
  }
488
513
 
@@ -535,7 +560,8 @@ function subagentSection(namespace: string): string {
535
560
  table in the \`pstack_adapter\` \`status\` report names a level for the role, append it as
536
561
  \`provider/model:<level>\`. A role whose thinking reads "backend default" gets no suffix, so
537
562
  pi-subagents applies its own default. An unconfigured role uses the \`general\` role, and omits
538
- \`model\` only when \`general\` is unconfigured too.
563
+ \`model\` only when \`general\` is unconfigured too. Under a model filter, always pass \`model\`,
564
+ using your own model for a role that inherits.
539
565
  - ${NESTED_DELEGATION_RULE}
540
566
  - Background: \`async: true\`.`;
541
567
  }
@@ -648,7 +674,7 @@ function adapterDescription(file: string, namespace: string): string {
648
674
  case "status.md":
649
675
  return `[${namespace}/native] Report the pstack adapter version, upstream version, configured ref, resolved commit, cache state, support tiers, and capability checks. Use for "${namespace} status" or before trusting a pstack workflow.`;
650
676
  case "setup.md":
651
- return `[${namespace}/native] Configure which Pi model each pstack role uses, and which upstream pstack revision the adapter reads. Use for "/skill:${namespace}-setup", "configure pstack models", or selecting another pstack revision.`;
677
+ return `[${namespace}/native] Configure which Pi model or pool of models each pstack role uses, which models delegation may use, and which upstream pstack revision the adapter reads. Use for "/skill:${namespace}-setup", "configure pstack models", restricting delegation to some models such as GPT only, or selecting another pstack revision.`;
652
678
  default:
653
679
  return `[${namespace}/native] Adapter-owned pstack workflow.`;
654
680
  }
package/src/pools.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { type AdapterConfig, type ModelSpec, panelEntries, poolEntries, splitModelSpec } from "./config.ts";
2
+ import { type ModelFilter, isRestricting, modelAllowed } from "./filter.ts";
3
+ import { GENERAL_ROLE, PANEL_DEFAULT_ROLES } from "./registry.ts";
4
+
5
+ /** What the model filter knows about the model of the agent that starts a delegation. */
6
+ export interface ResolutionContext {
7
+ readonly filter?: ModelFilter;
8
+ /** `provider/id` of the parent session's model, when Pi reports one. */
9
+ readonly parentModel?: string;
10
+ }
11
+
12
+ export interface PoolChoice {
13
+ readonly spec: ModelSpec;
14
+ readonly eligible: boolean;
15
+ }
16
+
17
+ /**
18
+ * How a role resolves. `from` names the role whose value applies, which is `general` for an
19
+ * unset role when `general` is set. A configured pool whose choices are all excluded is blocked:
20
+ * it never falls back to `general` or the parent.
21
+ */
22
+ export type RoleResolution =
23
+ | { readonly kind: "inherit"; readonly from: string }
24
+ | { readonly kind: "pool"; readonly from: string; readonly choices: readonly PoolChoice[] }
25
+ | { readonly kind: "blocked"; readonly from: string; readonly reason: string; readonly choices: readonly PoolChoice[] };
26
+
27
+ /** Why the parent's model cannot run under the filter, or undefined when it can. */
28
+ export function parentProblem(context: ResolutionContext): string | undefined {
29
+ if (!isRestricting(context.filter)) return undefined;
30
+ if (context.parentModel === undefined) return "the parent session's model is unknown, so the model filter cannot allow it";
31
+ if (!modelAllowed(context.filter, context.parentModel)) return `the parent session's model \`${context.parentModel}\` is excluded by the model filter`;
32
+ return undefined;
33
+ }
34
+
35
+ export function resolveRole(config: AdapterConfig, role: string, context: ResolutionContext): RoleResolution {
36
+ const own = config.models[role];
37
+ const from = own !== undefined ? role : config.models[GENERAL_ROLE] !== undefined ? GENERAL_ROLE : undefined;
38
+ if (from === undefined) {
39
+ const problem = parentProblem(context);
40
+ return problem ? { kind: "blocked", from: role, reason: `it inherits, and ${problem}`, choices: [] } : { kind: "inherit", from: role };
41
+ }
42
+ const value = config.models[from] ?? "";
43
+ const choices = poolEntries(value).map((entry) => {
44
+ const spec = splitModelSpec(entry);
45
+ return { spec, eligible: modelAllowed(context.filter, spec.model) };
46
+ });
47
+ if (choices.some((choice) => choice.eligible)) return { kind: "pool", from, choices };
48
+ return {
49
+ kind: "blocked",
50
+ from,
51
+ reason: `every model ${from === role ? "it lists" : `\`${from}\` lists`} is excluded by the model filter`,
52
+ choices,
53
+ };
54
+ }
55
+
56
+ /** One seat of a panel. Seats keep their meaning; a pool seat picks one eligible choice. */
57
+ export type PanelSeat =
58
+ | { readonly kind: "model"; readonly spec: ModelSpec; readonly from?: string }
59
+ | { readonly kind: "parent"; readonly from?: string }
60
+ | { readonly kind: "choice"; readonly choices: readonly ModelSpec[]; readonly from: string }
61
+ | { readonly kind: "blocked"; readonly reason: string; readonly from?: string };
62
+
63
+ export interface ResolvedPanel {
64
+ readonly role: string;
65
+ readonly configured: boolean;
66
+ readonly seats: readonly PanelSeat[];
67
+ }
68
+
69
+ export function resolvePanel(config: AdapterConfig, role: string, context: ResolutionContext = {}): ResolvedPanel {
70
+ const configured = config.models[role];
71
+ if (typeof configured === "string") {
72
+ return {
73
+ role,
74
+ configured: true,
75
+ seats: panelEntries(configured).map((entry): PanelSeat => {
76
+ if (entry.kind === "inherit") {
77
+ const problem = parentProblem(context);
78
+ return problem ? { kind: "blocked", reason: `this \`inherit\` seat is blocked, because ${problem}` } : { kind: "parent" };
79
+ }
80
+ return modelAllowed(context.filter, entry.spec.model)
81
+ ? { kind: "model", spec: entry.spec }
82
+ : { kind: "blocked", reason: `\`${entry.spec.model}\` is excluded by the model filter` };
83
+ }),
84
+ };
85
+ }
86
+ return { role, configured: false, seats: defaultSeats(config, context) };
87
+ }
88
+
89
+ /** The seats an unset panel gets, one per default role, each drawing one choice from that role's pool. */
90
+ export function defaultSeats(config: AdapterConfig, context: ResolutionContext = {}): PanelSeat[] {
91
+ return PANEL_DEFAULT_ROLES.map((name): PanelSeat => {
92
+ const resolved = resolveRole(config, name, context);
93
+ switch (resolved.kind) {
94
+ case "inherit":
95
+ return { kind: "parent", from: name };
96
+ case "blocked":
97
+ return { kind: "blocked", reason: resolved.reason, from: resolved.from };
98
+ case "pool": {
99
+ const eligible = resolved.choices.filter((choice) => choice.eligible).map((choice) => choice.spec);
100
+ return eligible.length === 1 ? { kind: "model", spec: eligible[0] as ModelSpec, from: resolved.from } : { kind: "choice", choices: eligible, from: resolved.from };
101
+ }
102
+ }
103
+ });
104
+ }