pi-pstack-adapter 0.7.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
@@ -4,8 +4,24 @@ import { CERTIFIED } from "./certified.ts";
4
4
  import { safeEcho } from "./echo.ts";
5
5
  import { DEFAULT_NAMESPACE, validateNamespace } from "./namespace.ts";
6
6
  import { expandHome } from "./paths.ts";
7
+ import { MAX_PANEL_ENTRIES, isPanelRole } from "./registry.ts";
7
8
  import { DEFAULT_DELEGATION, DELEGATION_PROVIDERS, isDelegationProvider } from "./delegation.ts";
8
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
+ }
9
25
 
10
26
  export interface AdapterConfig {
11
27
  readonly namespace: string;
@@ -18,7 +34,9 @@ export interface AdapterConfig {
18
34
  readonly pinnedCommit?: string;
19
35
  readonly allowBootstrap: boolean;
20
36
  readonly cacheDir?: string;
21
- 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 };
22
40
  /** The delegation backend tried first. The other one is the fallback. */
23
41
  readonly delegation: DelegationProvider;
24
42
  readonly sources: readonly string[];
@@ -33,7 +51,8 @@ export interface ConfigFile {
33
51
  pinnedCommit?: string;
34
52
  allowBootstrap?: boolean;
35
53
  cacheDir?: string;
36
- models?: Record<string, string>;
54
+ models?: Record<string, ModelValue>;
55
+ modelFilter?: ModelFilter;
37
56
  delegation?: string;
38
57
  }
39
58
 
@@ -189,6 +208,89 @@ export function planModelAssignment(
189
208
  return { kind: "assign", spec: assigned };
190
209
  }
191
210
 
211
+ const MAX_MODEL_SPEC_LENGTH = 128;
212
+
213
+ /** A panel entry that runs on the parent session's model, like upstream's `inherit-parent`. */
214
+ export const INHERIT_ENTRY = "inherit";
215
+
216
+ export type PanelEntry = { readonly kind: "inherit" } | { readonly kind: "model"; readonly spec: ModelSpec };
217
+
218
+ /** Splits a panel value. Entries are separated by ASCII spaces, because a model id has none. */
219
+ export function splitPanelValue(value: string): string[] {
220
+ return value.split(/ +/).filter((entry) => entry !== "");
221
+ }
222
+
223
+ /** The longest value `set_model` can write: every entry at its limit, one space apart. */
224
+ const MAX_PANEL_VALUE_LENGTH = MAX_PANEL_ENTRIES * (MAX_MODEL_SPEC_LENGTH + 1) - 1;
225
+
226
+ export function panelEntries(value: string): PanelEntry[] {
227
+ return splitPanelValue(value).map((entry) =>
228
+ entry === INHERIT_ENTRY ? { kind: "inherit" } : { kind: "model", spec: splitModelSpec(entry) },
229
+ );
230
+ }
231
+
232
+ export function isValidPanelValue(value: string): boolean {
233
+ if (value.length > MAX_PANEL_VALUE_LENGTH) return false;
234
+ const entries = splitPanelValue(value);
235
+ return (
236
+ entries.length > 0 &&
237
+ entries.length <= MAX_PANEL_ENTRIES &&
238
+ entries.every((entry) => entry === INHERIT_ENTRY || isValidModelSpec(entry))
239
+ );
240
+ }
241
+
242
+ function invalidPanelMessage(value: unknown): string {
243
+ return `Invalid panel value ${typeof value === "string" ? `(${value.length} characters)` : safeEcho(value)}. Use 1 to ${MAX_PANEL_ENTRIES} entries separated by spaces, each \`provider/model\`, \`provider/model:thinking\`, or \`${INHERIT_ENTRY}\`.`;
244
+ }
245
+
246
+ export type PanelAssignment =
247
+ | { readonly kind: "inherit" }
248
+ | { readonly kind: "assign"; readonly value: string }
249
+ | { readonly kind: "rejected"; readonly message: string };
250
+
251
+ /**
252
+ * Validates a `set_model` request for a panel role. The value lists entries separated by commas or
253
+ * spaces. `thinking` applies to each model entry that has no `:level` suffix of its own. A value of
254
+ * just `inherit` clears the panel, so it falls back to its default roles.
255
+ */
256
+ export function planPanelAssignment(
257
+ value: string,
258
+ thinking: string | undefined,
259
+ available: readonly AvailableModel[],
260
+ ): PanelAssignment {
261
+ const requested = thinking?.trim().toLowerCase() || undefined;
262
+ if (requested !== undefined && !isThinkingLevel(requested)) {
263
+ return {
264
+ kind: "rejected",
265
+ message: `Unknown thinking level \`${safeEcho(thinking).replaceAll('"', "")}\`. Use one of ${THINKING_LEVELS.map((level) => `\`${level}\``).join(", ")}.`,
266
+ };
267
+ }
268
+ const entries = value.split(/[\s,]+/).filter((entry) => entry !== "");
269
+ const onlyInherit = entries.every((entry) => entry.toLowerCase() === INHERIT_ENTRY);
270
+ if (entries.length > 0 && onlyInherit && thinking?.trim()) {
271
+ return {
272
+ kind: "rejected",
273
+ message: "An `inherit` entry runs at the parent session's thinking, so `thinking` has nothing to apply to. Drop `thinking`, or add a `provider/model` entry.",
274
+ };
275
+ }
276
+ if (entries.length === 1 && entries[0]?.toLowerCase() === INHERIT_ENTRY) return { kind: "inherit" };
277
+ if (entries.length === 0 || entries.length > MAX_PANEL_ENTRIES) {
278
+ return { kind: "rejected", message: `A panel takes 1 to ${MAX_PANEL_ENTRIES} entries, one seat each, separated by commas or spaces.` };
279
+ }
280
+ const written: string[] = [];
281
+ for (const entry of entries) {
282
+ if (entry.toLowerCase() === INHERIT_ENTRY) {
283
+ written.push(INHERIT_ENTRY);
284
+ continue;
285
+ }
286
+ const own = available.some((candidate) => candidate.id === entry) ? undefined : splitModelSpec(entry).thinking;
287
+ const plan = planModelAssignment(entry, own === undefined ? thinking : undefined, available);
288
+ if (plan.kind === "rejected") return { kind: "rejected", message: `Entry \`${safeEcho(entry).replaceAll('"', "")}\`: ${plan.message}` };
289
+ if (plan.kind === "assign") written.push(joinModelSpec(plan.spec));
290
+ }
291
+ return { kind: "assign", value: written.join(" ") };
292
+ }
293
+
192
294
  /** Role names reach generated markdown and status tables, so they stay plain kebab-case. */
193
295
  const ROLE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
194
296
  /**
@@ -200,7 +302,6 @@ const ROLE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
200
302
  const PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
201
303
  const MARKUP_CHARACTERS = /[`|<>[\]()\\*]/;
202
304
 
203
- const MAX_MODEL_SPEC_LENGTH = 128;
204
305
 
205
306
  export function isValidModelSpec(model: string): boolean {
206
307
  const plain = (value: string) => PRINTABLE_ASCII.test(value) && !MARKUP_CHARACTERS.test(value) && !value.endsWith(":");
@@ -221,9 +322,64 @@ function invalidModelMessage(model: unknown): string {
221
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.`;
222
323
  }
223
324
 
224
- /** `role=provider/model[:thinking],role2=provider/model2` */
225
- export function parseModelRoles(value: string): Record<string, string> {
226
- 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> = {};
227
383
  for (const pair of value.split(",")) {
228
384
  const trimmed = pair.trim();
229
385
  if (trimmed === "") continue;
@@ -236,7 +392,16 @@ export function parseModelRoles(value: string): Record<string, string> {
236
392
  if (!isValidRole(role)) throw new Error(invalidRoleMessage(role));
237
393
  // An empty value is an unset variable that expanded to nothing, not a model.
238
394
  if (model === "") continue;
239
- if (!isValidModelSpec(model)) throw new Error(invalidModelMessage(model));
395
+ if (isPanelRole(role)) {
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;
402
+ } else if (!isValidModelSpec(model)) {
403
+ throw new Error(invalidModelMessage(model));
404
+ }
240
405
  roles[role] = model;
241
406
  }
242
407
  return roles;
@@ -265,6 +430,7 @@ export function environmentLayer(env: NodeJS.ProcessEnv): ConfigLayer {
265
430
  export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
266
431
  const merged: ConfigFile = {};
267
432
  const sources: string[] = [];
433
+ let filterSource = "";
268
434
  for (const layer of layers) {
269
435
  let contributed = false;
270
436
  for (const [key, value] of Object.entries(layer.values)) {
@@ -281,6 +447,11 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
281
447
  }
282
448
  if (key === "models") {
283
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;
284
455
  } else {
285
456
  (merged as Record<string, unknown>)[key] = value;
286
457
  }
@@ -303,26 +474,34 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
303
474
  allowBootstrap: merged.allowBootstrap ?? false,
304
475
  cacheDir: merged.cacheDir,
305
476
  models: merged.models ?? {},
477
+ modelFilter: merged.modelFilter ? { filter: merged.modelFilter, source: filterSource } : undefined,
306
478
  delegation: validateDelegation(merged.delegation, layers),
307
479
  sources,
308
480
  };
309
481
  }
310
482
 
311
483
  /** A bad `models` entry fails with the layer that holds it, so status can still be reached after a fix. */
312
- function validatedModels(value: unknown, label: string): Record<string, string> {
484
+ function validatedModels(value: unknown, label: string): Record<string, ModelValue> {
313
485
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
314
486
  throw new Error(`${label} sets \`models\` to something other than an object of role to provider/model.`);
315
487
  }
316
- const models: Record<string, string> = {};
488
+ const models: Record<string, ModelValue> = {};
317
489
  for (const [role, model] of Object.entries(value)) {
318
490
  if (!isValidRole(role)) {
319
491
  throw new Error(`${label}: ${invalidRoleMessage(role)} Edit that key in \`models\` in the named file, then restart Pi.`);
320
492
  }
321
493
  if (model === "") continue;
322
- if (typeof model !== "string" || !isValidModelSpec(model)) {
494
+ if (isPanelRole(role)) {
495
+ if (typeof model !== "string" || !isValidPanelValue(model)) {
496
+ throw new Error(`${label}: role ${safeEcho(role)}. ${invalidPanelMessage(model)}`);
497
+ }
498
+ } else if (Array.isArray(model)) {
499
+ if (!isValidPoolValue(model)) throw new Error(`${label}: role ${safeEcho(role)}. ${invalidPoolMessage(model)}`);
500
+ } else if (typeof model !== "string" || !isValidModelSpec(model)) {
323
501
  throw new Error(`${label}: role ${safeEcho(role)}. ${invalidModelMessage(model)}`);
324
502
  }
325
- 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 });
326
505
  }
327
506
  return models;
328
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
+ }