Skip to content
29 changes: 17 additions & 12 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ import { registerSpawnTool } from "./spawn/index.js";
import { registerModelGroupsCommand } from "./model-groups/command.js";
import { resolveSpawnModelRoute, SpawnRouteError } from "./model-groups/router.js";
import { registerModelGroupAutocomplete } from "./model-groups/autocomplete.js";
import { getEffectiveModelGroupNames } from "./model-groups/router.js";
import { getEffectiveModelGroups, getEffectiveModelGroupNames } from "./model-groups/router.js";
import { MODEL_GROUP_MODALITIES, type ResolvedModelGroup, type ModelGroupsAccess } from "./model-groups/types.js";
import { loadModelGroups, summarizeBootValidation, validateModelGroups } from "./model-groups/store.js";
import { escapeDisplayLabel } from "./model-groups/display.js";
import type { ModelGroupsAccess } from "./model-groups/types.js";
import { presentConstraintPrompt } from "./model-groups/constraints/presentation.js";
import { productionConstraintRegistry } from "./model-groups/constraints/registry.js";
import {
cacheLookupCommand,
cacheLookupCommandExplicitModel,
Expand All @@ -100,6 +102,9 @@ import {
updateIndicators,
} from "./tui.js";
import { applyReadonlyBashGuard } from "./readonly-bash.js";

const MODEL_GROUP_MODALITY_PROSE = MODEL_GROUP_MODALITIES.join(", ").replace(/, ([^,]+)$/, ", or $1");

// ── Helpers ────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -461,13 +466,13 @@ function refreshModelGroupsState(state: AgenticodingState, ctx: ExtensionContext
return state.modelGroups.validation;
}

function modelGroupsPromptSection(names: string[]): string | undefined {
if (names.length === 0) return undefined;
function modelGroupsPromptSection(groups: ResolvedModelGroup[]): string | undefined {
if (groups.length === 0) return undefined;
const labels = groups.map((group) => `${escapeDisplayLabel(group.name)} (${(group.evaluations ? presentConstraintPrompt(group.evaluations, productionConstraintRegistry).filter(Boolean).join(", ") : group.modalities?.effective.join(", ")) || "no common modalities"})`);
return `\n## Model Groups for spawn\n` +
`Available Model Groups: ${names.join(", ")}\n` +
`When the operator asks to spawn with one of these groups, or mentions #group-name, call spawn with group set to the exact group name only when the mapping is known and confident. ` +
`If no known/confident group is requested, omit group and inherit the parent model/thinking. ` +
`The group list is names-only; do not assume provider/model membership, thinking levels, auth status, validation details, or storage paths from it.`;
`Available Model Groups: ${labels.join(", ")}\n` +
`When the operator asks to spawn with one of these groups, or mentions #group-name, call spawn with group set to the exact group name only when the mapping is known and confident. If a delegated task requires ${MODEL_GROUP_MODALITY_PROSE} capability, pass those requirements as constraints. If no known/confident group is requested, omit group and inherit the parent model/thinking. ` +
`The group list exposes only names and effective modalities; do not assume provider/model membership, thinking levels, auth status, validation details, or storage paths from it.`;
}

export default function (pi: ExtensionAPI): void {
Expand Down Expand Up @@ -756,7 +761,7 @@ export default function (pi: ExtensionAPI): void {
);
}

const modelGroupSection = modelGroupsPromptSection(getEffectiveModelGroupNames(state.modelGroups.groups));
const modelGroupSection = modelGroupsPromptSection(getEffectiveModelGroups(state.modelGroups.groups));
if (modelGroupSection) {
parts.push(modelGroupSection);
}
Expand Down Expand Up @@ -920,9 +925,9 @@ export default function (pi: ExtensionAPI): void {
const backupNote = issue.backupFailed ? `; backup failed${backupPath ? ` (${backupPath})` : ""}, original file left untouched` : "";
ctx.ui.notify(`Model Groups config ${issue.kind} in ${issue.scope} scope (${sourcePath}); using empty config for that scope${backupNote}; ${detail}`, "warning");
}
const { unavailableCount, overrideCount } = summarizeBootValidation(validation.groups);
if (unavailableCount > 0 || overrideCount > 0) {
ctx.ui.notify(`Model Groups boot validation: ${unavailableCount} unavailable model references · ${overrideCount} project overrides`, "warning");
const { unavailableCount, overrideCount, emptyModalityCount, staleModalityOverrideCount } = summarizeBootValidation(validation.groups);
if (unavailableCount > 0 || overrideCount > 0 || emptyModalityCount > 0 || staleModalityOverrideCount > 0) {
ctx.ui.notify(`Model Groups boot validation: ${unavailableCount} unavailable model references · ${overrideCount} project overrides · ${emptyModalityCount} groups with no common modalities · ${staleModalityOverrideCount} stale modality overrides`, "warning");
}
}

Expand Down
45 changes: 45 additions & 0 deletions model-groups/constraints/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ConstraintRegistry } from "./registry.js";
import type { AnyConstraintDescriptor, ConstraintEvaluation, ConstraintMemberResolution, ConstraintViolation, ErasedConstraintEvaluation } from "./types.js";

function evaluateDescriptor(descriptor: AnyConstraintDescriptor, resolution: ConstraintMemberResolution, override: unknown): ErasedConstraintEvaluation {
const members = resolution.members.map(({ ref, model }) => ({ ref, ...(model ? { fact: descriptor.modelFact(model) } : {}) }));
const aggregate = descriptor.aggregate({ members });
const reconciled = descriptor.reconcile({ aggregate, override });
return { key: descriptor.key, aggregate, effective: reconciled.effective, diagnostics: reconciled.diagnostics };
}

/** Pure evaluator: resolution is supplied by the host and no registry APIs are reachable here. */
export function evaluateConstraints(
resolution: ConstraintMemberResolution,
overrides: Readonly<Record<string, unknown>>,
registry: ConstraintRegistry,
): readonly ErasedConstraintEvaluation[] {
return registry.descriptors.map((descriptor) => evaluateDescriptor(descriptor, resolution, overrides[descriptor.key]));
}

export function evaluateConstraint<Aggregate, Effective>(
descriptor: AnyConstraintDescriptor,
resolution: ConstraintMemberResolution,
override: unknown,
): ConstraintEvaluation<Aggregate, Effective> {
return evaluateDescriptor(descriptor, resolution, override) as ConstraintEvaluation<Aggregate, Effective>;
}

export function evaluateGroupRequirement(
descriptor: AnyConstraintDescriptor,
evaluation: ErasedConstraintEvaluation,
requirement: unknown,
): ConstraintViolation | undefined {
const satisfaction = descriptor.groupSatisfies({ aggregate: evaluation.aggregate, effective: evaluation.effective, requirement });
return satisfaction.satisfied ? undefined : { key: descriptor.key, scope: "group", satisfaction };
}

export function evaluateModelRequirement(
descriptor: AnyConstraintDescriptor,
model: ConstraintMemberResolution["members"][number]["model"],
requirement: unknown,
): ConstraintViolation | undefined {
if (!model) return { key: descriptor.key, scope: "model", satisfaction: { satisfied: false, missing: "unresolved" } };
const satisfaction = descriptor.modelSatisfies({ fact: descriptor.modelFact(model), requirement });
return satisfaction.satisfied ? undefined : { key: descriptor.key, scope: "model", satisfaction };
}
92 changes: 92 additions & 0 deletions model-groups/constraints/modalities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Type } from "typebox";
import type { Api, Model } from "@earendil-works/pi-ai";
import { MODEL_GROUP_MODALITIES, type ModelGroupModalities, type ModelGroupModality } from "../types.js";
import type { ConstraintCodec, ConstraintDescriptor, ConstraintDiagnostic, ConstraintEvaluation, ConstraintSatisfaction, ConstraintViolation } from "./types.js";

function ordered(values: Iterable<ModelGroupModality>): ModelGroupModality[] {
const set = new Set(values);
return MODEL_GROUP_MODALITIES.filter((value) => set.has(value));
}

function modalityCodec(): ConstraintCodec<ModelGroupModality[]> {
return {
decode(value, path) {
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !MODEL_GROUP_MODALITIES.includes(item as ModelGroupModality)) || new Set(value).size !== value.length) return { ok: false, message: `${path} must be a unique modality vocabulary array` };
return { ok: true, value: ordered(value as ModelGroupModality[]) };
},
encode: (value) => [...value],
equals: (left, right) => left.length === right.length && left.every((value, index) => value === right[index]),
schema: Type.Array(Type.Union(MODEL_GROUP_MODALITIES.map((value) => Type.Literal(value))), { uniqueItems: true }),
};
}

function satisfaction(missing: ModelGroupModality[]): ConstraintSatisfaction {
return missing.length ? { satisfied: false, missing } : { satisfied: true };
}

export function getModalitiesModelFact(model: Model<Api>): ModelGroupModality[] {
return ordered([...(Array.isArray(model.input) ? model.input as ModelGroupModality[] : []), ...(model.reasoning === true ? ["reasoning" as const] : [])]);
}

export const modalitiesConstraint: ConstraintDescriptor<"modalities", ModelGroupModality[], ModelGroupModalities, ModelGroupModality[], ModelGroupModality[], ModelGroupModality[]> = {
key: "modalities",
order: 0,
modelFact: getModalitiesModelFact,
aggregate({ members }) {
const sets = members.map(({ fact }) => new Set(fact ?? []));
const supported = ordered(sets.flatMap((set) => [...set]));
const common = members.length === 0 || members.some(({ fact }) => fact === undefined)
? []
: ordered(MODEL_GROUP_MODALITIES.filter((modality) => sets.every((set) => set.has(modality))));
return { common, supported, effective: common };
},
reconcile({ aggregate, override }) {
const effective = override === undefined ? aggregate.common : ordered(override.filter((modality) => aggregate.supported.includes(modality)));
const missing = override === undefined ? [] : ordered(override.filter((modality) => !aggregate.supported.includes(modality)));
const diagnostics: ConstraintDiagnostic[] = [
...(aggregate.common.length === 0 ? [{ key: "modalities", code: "empty-common" }] : []),
...(missing.length ? [{ key: "modalities", code: "unsupported-override", details: missing }] : []),
];
return { effective, diagnostics };
},
groupSatisfies({ effective, requirement }) { return satisfaction(ordered(requirement.filter((modality) => !effective.includes(modality)))); },
modelSatisfies({ fact, requirement }) { return satisfaction(ordered(requirement.filter((modality) => !fact.includes(modality)))); },
persistence: { override: modalityCodec(), clone: (value) => [...value] },
requirement: {
decode(value, path) {
if (!value || typeof value !== "object" || Array.isArray(value) || !("required" in value)) return { ok: false, message: `${path} must be an object with required modalities` };
return modalityCodec().decode((value as { required: unknown }).required, `${path}.required`);
},
encode: (value) => ({ required: [...value] }),
equals: (left, right) => modalityCodec().equals(left, right),
schema: Type.Object({ required: modalityCodec().schema }),
},
editor: { kind: "multi-select", label: "Modalities", choices: (evaluation) => evaluation.aggregate.supported, automatic: (evaluation) => `Automatic (common: ${evaluation.aggregate.common.join(", ") || "none"})`, format: (value) => `Override: ${value.join(", ") || "none"}`, allowAutomatic: true },
present: {
group: (evaluation) => evaluation.effective.join(", "),
prompt: (evaluation) => evaluation.effective.join(", "),
diagnostic: (diagnostic) => diagnostic.code === "empty-common"
? "⚠ no common modalities"
: `⚠ stale modality override: ${((diagnostic.details as ModelGroupModality[] | undefined) ?? []).join(", ")}`,
violation: (violation: ConstraintViolation) => violation.key,
},
};

export function deriveModalitiesEvaluation(
members: readonly { ref: { provider: string; modelId: string }; model?: Model<Api> }[],
override: ModelGroupModality[] | undefined,
): ConstraintEvaluation<ModelGroupModalities, ModelGroupModality[]> {
const aggregate = modalitiesConstraint.aggregate({ members: members.map(({ ref, model }) => ({ ref, ...(model ? { fact: modalitiesConstraint.modelFact(model) } : {}) })) });
const reconciled = modalitiesConstraint.reconcile({ aggregate, override });
return { key: modalitiesConstraint.key, aggregate, effective: reconciled.effective, diagnostics: reconciled.diagnostics };
}

export function assertModalitiesOverrideSupported(evaluation: ConstraintEvaluation<ModelGroupModalities, ModelGroupModality[]>, override: ModelGroupModality[] | undefined): void {
if (override === undefined) return;
const missing = evaluation.diagnostics.find((diagnostic) => diagnostic.code === "unsupported-override")?.details as ModelGroupModality[] | undefined;
if (missing?.length) throw new Error(`Model group modality override includes unsupported modalities: ${missing.join(", ")}.`);
}

export function getMissingModalitiesFromModel(model: Model<Api>, required: readonly ModelGroupModality[]): ModelGroupModality[] {
return ordered(required.filter((modality) => !getModalitiesModelFact(model).includes(modality)));
}
64 changes: 64 additions & 0 deletions model-groups/constraints/presentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ConstraintRegistry } from "./registry.js";
import type { AnyConstraintDescriptor, ConstraintDiagnostic, ConstraintEditorSpec, ConstraintEvaluation, ConstraintViolation, ErasedConstraintEvaluation } from "./types.js";

export interface ConstraintDiagnosticRecord extends ConstraintDiagnostic {
text: string;
}

export type ConstraintEditorRow =
| { kind: "automatic"; label: string }
| { kind: "choice"; label: string; value: readonly string[] }
| { kind: "number"; label: string; value: number | null; unit: string; min: number; step: number };

export function presentConstraintGroups(evaluations: readonly ErasedConstraintEvaluation[], registry: ConstraintRegistry): string[] {
return evaluations.flatMap((evaluation) => {
const descriptor = registry.get(evaluation.key);
return descriptor ? [descriptor.present.group(evaluation)] : [];
});
}

export function presentConstraintPrompt(evaluations: readonly ErasedConstraintEvaluation[], registry: ConstraintRegistry): string[] {
return evaluations.flatMap((evaluation) => {
const descriptor = registry.get(evaluation.key);
return descriptor ? [descriptor.present.prompt(evaluation)] : [];
});
}

export function presentConstraintDiagnosticRecords(evaluations: readonly ErasedConstraintEvaluation[], registry: ConstraintRegistry): ConstraintDiagnosticRecord[] {
return evaluations.flatMap((evaluation) => evaluation.diagnostics.flatMap((diagnostic) => {
const descriptor = registry.get(diagnostic.key);
return descriptor ? [{ ...diagnostic, text: descriptor.present.diagnostic(diagnostic) }] : [];
}));
}

export function presentConstraintDiagnostics(diagnostics: readonly ConstraintDiagnostic[], registry: ConstraintRegistry): string[] {
return diagnostics.flatMap((diagnostic) => {
const descriptor = registry.get(diagnostic.key);
return descriptor ? [descriptor.present.diagnostic(diagnostic)] : [];
});
}

export function constraintEditorRows(
descriptor: AnyConstraintDescriptor,
evaluation: ErasedConstraintEvaluation,
override?: unknown,
): readonly ConstraintEditorRow[] {
const editor = descriptor.editor as ConstraintEditorSpec<unknown, unknown, unknown>;
if (editor.kind === "multi-select") {
const choices = [...new Set([...editor.choices(evaluation as ConstraintEvaluation<unknown, unknown>), ...(Array.isArray(override) ? override.filter((value): value is string => typeof value === "string") : [])])];
const rows: ConstraintEditorRow[] = [{ kind: "automatic", label: editor.automatic(evaluation as ConstraintEvaluation<unknown, unknown>) }];
for (let mask = 0; mask < 2 ** choices.length; mask++) {
const value = choices.filter((_, index) => (mask & (1 << index)) !== 0);
rows.push({ kind: "choice", label: editor.format(value), value });
}
return rows;
}
return [
{ kind: "automatic", label: editor.automatic(evaluation as ConstraintEvaluation<unknown, unknown>) },
{ kind: "number", label: editor.label, value: editor.value(evaluation as ConstraintEvaluation<unknown, unknown>), unit: editor.unit, min: editor.min, step: editor.step },
];
}

export function presentConstraintViolation(violation: ConstraintViolation, registry: ConstraintRegistry): string | undefined {
return registry.get(violation.key)?.present.violation(violation);
}
21 changes: 21 additions & 0 deletions model-groups/constraints/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { modalitiesConstraint } from "./modalities.js";
import type { AnyConstraintDescriptor } from "./types.js";

export interface ConstraintRegistry {
readonly descriptors: readonly AnyConstraintDescriptor[];
get(key: string): AnyConstraintDescriptor | undefined;
}

export function createConstraintRegistry(descriptors: readonly AnyConstraintDescriptor[]): ConstraintRegistry {
const ordered = [...descriptors].sort((left, right) => left.order - right.order || left.key.localeCompare(right.key));
const keys = new Set<string>();
for (const descriptor of ordered) {
if (keys.has(descriptor.key)) throw new Error(`Duplicate model-group constraint key: ${descriptor.key}.`);
keys.add(descriptor.key);
}
return { descriptors: ordered, get: (key) => ordered.find((descriptor) => descriptor.key === key) };
}

// Internal, fixed production catalog. Tests inject a registry with createConstraintRegistry.
const productionDescriptors = [modalitiesConstraint] as const;
export const productionConstraintRegistry = createConstraintRegistry(productionDescriptors);
12 changes: 12 additions & 0 deletions model-groups/constraints/resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
import type { Api, Model } from "@earendil-works/pi-ai";
import type { ModelGroupModel } from "../types.js";
import type { ConstraintMemberResolution } from "./types.js";

/** Host adapter: group algebra deliberately receives this snapshot, not a registry. */
export function resolveConstraintMembers(
members: readonly ModelGroupModel[],
modelRegistry: Pick<ModelRegistry, "find">,
): ConstraintMemberResolution {
return { members: members.map((ref) => ({ ref, model: modelRegistry.find(ref.provider, ref.modelId) as Model<Api> | undefined })) };
}
Loading
Loading