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/CHANGELOG.md +7 -0
- package/README.md +66 -1
- package/VERSION +1 -1
- package/adapter-skills/setup.md +40 -8
- package/index.ts +197 -20
- package/package.json +1 -1
- package/src/config.ts +96 -8
- package/src/filter.ts +150 -0
- package/src/gate.ts +139 -0
- package/src/generate.ts +28 -2
- package/src/pools.ts +104 -0
- package/src/status.ts +132 -69
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,
|
|
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,
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|