Files
gstack/lib/agent-model.ts
Garry Tanandcapy-ai[bot] 39378ca6d1 v1.91.332.0 feat(capy): remember a user's model preference (agent_model); every Capy session reads factory.md (#3634)
* feat(capy): agent_model preference via gstack-models resolve --role agent; capy/skill/gstack/factory.md

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(agent-model): pre-landing review fixes

- a broken preferences.yaml no longer blocks --request or config.yaml
- CRLF, a byte-order mark and a trailing comment still read the value
- never echo raw control or bidi bytes from an unvalidated source
- cap the bytes actually read, not only the stat size
- family names and host are case-insensitive in gstack-config too
- list passes the platform through to the agent block
- a wrapper inside the checkout names no preferences file
- docs: --check never writes the skill folder

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(changelog): restore CHANGELOG.md history that 1bec403 truncated

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: agent_model and factory.md in capy-install, model-policy, troubleshooting and capy/README

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(capy): the Captain archives its own child threads by its plan for each (GST-211, R39)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.332.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-30 12:03:08 +00:00

171 lines
9.4 KiB
TypeScript

import * as fs from 'node:fs';
import * as path from 'node:path';
import { readConfigKeyWithRoot, type StateRootEnv } from './state-root';
/**
* The agent role: the model a user wants gstack's working agents (lanes,
* subagents, reviewers) to run on. It is a preference the user sets, never a
* policy gstack ships, and it is read apart from the strict model policy so a
* bad value here never stops plan-review, implementation or eval-arms picks.
* Precedence: a one-job request, then agent_model in config.yaml (this
* machine), then agent_model in the file GSTACK_PREFERENCES_FILE names (the
* Capy skill folder's preferences.yaml, kept across machines), then host.
*/
export const AGENT_MODEL_KEY = 'agent_model' as const;
export const PREFERENCES_FILE_MAX_BYTES = 4096;
const DOCS = 'https://github.com/garrytan/gstack/blob/main/docs/model-policy.md#your-model-preference';
/** Family names a preference may use. When gstack's tier catalog lists a family, its entry here is the same model (test/agent-model.test.ts). */
export const MODEL_FAMILIES = Object.freeze([
{ family: 'opus', provider: 'anthropic', model: 'claude-opus-5-5' },
{ family: 'fable', provider: 'anthropic', model: 'claude-fable-5-1' },
{ family: 'sonnet', provider: 'anthropic', model: 'claude-sonnet-5-5' },
{ family: 'haiku', provider: 'anthropic', model: 'claude-haiku-5-5' },
{ family: 'astra', provider: 'openai', model: 'gpt-6-astra' },
{ family: 'sol', provider: 'openai', model: 'gpt-6.1-sol' },
{ family: 'luna', provider: 'openai', model: 'gpt-6-luna' },
{ family: 'terra', provider: 'openai', model: 'gpt-6.1-terra' },
].map(f => Object.freeze(f)));
export const AGENT_MODEL_EXEMPTIONS = Object.freeze([
'a cross-family outside voice (gstack-models resolve --role plan-review)',
'a model under test in an eval (gstack-models resolve --role eval-arms, /eval-plan arms)',
]);
const PROVIDER_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
export type AgentModelSourceKind = 'request' | 'config' | 'preferences' | 'default';
export interface AgentModelSelection {
role: 'agent';
status: 'selected' | 'host';
provider: string | null;
model: string | null;
/** Provider-qualified id, the form a Capy model field takes; null in host mode (omit the model field). */
id: string | null;
family: string | null;
value: string;
source: { kind: AgentModelSourceKind; path: string | null };
sources: { config: string | null; configPath: string | null; preferences: string | null; preferencesPath: string | null };
warnings: string[];
exemptions: readonly string[];
}
export class AgentModelError extends Error {
constructor(readonly problem: string, readonly cause: string, readonly repair: string[]) {
super(problem);
}
toJSON() {
return { reason: 'invalid_agent_model', problem: this.problem, cause: this.cause, key: AGENT_MODEL_KEY, repair: this.repair, docs: DOCS };
}
}
function printable(value: string): string {
return value.replace(/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ' ');
}
function sanitize(value: string): string {
return printable(value).slice(0, 200);
}
type Parsed = { kind: 'host' } | { kind: 'model'; provider: string; model: string; family: string | null };
/** host | a family name | <provider>/<model>. Syntax only; bin/gstack-config holds the same grammar. */
export function parseAgentModel(raw: string): Parsed | null {
const value = raw.trim();
if (value.toLowerCase() === 'host') return { kind: 'host' };
const family = MODEL_FAMILIES.find(f => f.family === value.toLowerCase());
if (family) return { kind: 'model', provider: family.provider, model: family.model, family: family.family };
const slash = value.indexOf('/');
if (slash < 0) return null;
const provider = value.slice(0, slash);
const model = value.slice(slash + 1);
if (!PROVIDER_PATTERN.test(provider) || !MODEL_PATTERN.test(model)) return null;
return { kind: 'model', provider, model, family: null };
}
function invalid(value: string, where: string, repair: string[]): AgentModelError {
return new AgentModelError(
`agent_model '${sanitize(value)}' in ${where} is not a model gstack can use`,
`use host, a family (${MODEL_FAMILIES.map(f => f.family).join(', ')}) or a provider-qualified id such as anthropic/claude-opus-5-5`,
repair,
);
}
/** Read agent_model from a preferences file: `key: value` lines, # comments, at most 4 KB. Unknown keys warn; a duplicate agent_model is an error. */
export function readPreferencesFile(file: string): { value: string | null; warnings: string[] } {
let text: string;
try {
const stat = fs.statSync(file);
if (!stat.isFile()) return { value: null, warnings: [`${file} is not a file; ignored`] };
const bytes = stat.size > PREFERENCES_FILE_MAX_BYTES ? null : fs.readFileSync(file);
if (!bytes || bytes.length > PREFERENCES_FILE_MAX_BYTES) return { value: null, warnings: [`${file} is over ${PREFERENCES_FILE_MAX_BYTES} bytes; ignored`] };
text = bytes.toString('utf-8').replace(/^\uFEFF/, '');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return { value: null, warnings: code === 'ENOENT' ? [] : [`${file} is unreadable (${code ?? 'unknown error'}); ignored`] };
}
const warnings: string[] = [];
let value: string | null = null;
text.split(/\r?\n/).forEach((line, i) => {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) return;
const m = /^([a-z][a-z0-9_]*):(.*)$/.exec(line);
if (!m) { warnings.push(`${file}:${i + 1} is not a 'key: value' line; ignored`); return; }
if (m[1] !== AGENT_MODEL_KEY) { warnings.push(`${file}:${i + 1} sets '${sanitize(m[1])}', which gstack does not read from preferences; ignored`); return; }
if (value !== null) throw new AgentModelError(`${file} sets agent_model twice`, 'keep one agent_model line', [`edit ${file}`]);
value = m[2].replace(/\s#.*$/, '').trim().replace(/^(["'])(.*)\1$/, '$2');
});
return { value: value === '' ? null : value, warnings };
}
export function resolveAgentModel(opts: { env?: StateRootEnv; platform?: NodeJS.Platform; request?: string } = {}): AgentModelSelection {
const env = opts.env ?? process.env;
const reading = readConfigKeyWithRoot('agent_model', env, opts.platform ?? process.platform);
const configPath = reading.root ? path.join(reading.root, 'config.yaml') : null;
const preferencesPath = env.GSTACK_PREFERENCES_FILE?.trim() || null;
let prefs: { value: string | null; warnings: string[] } = { value: null, warnings: [] };
try {
if (preferencesPath) prefs = readPreferencesFile(preferencesPath);
} catch (error) {
if (!(error instanceof AgentModelError) || (opts.request === undefined && reading.value === null)) throw error;
prefs = { value: null, warnings: [`${error.problem}; ignored because a higher-priority source is set`] };
}
const candidates: Array<{ kind: AgentModelSourceKind; value: string | null; path: string | null; repair: string[] }> = [
{ kind: 'request', value: opts.request ?? null, path: null, repair: ['pass --request host, a family or <provider>/<model>'] },
{ kind: 'config', value: reading.value, path: configPath, repair: ['gstack-config set agent_model <host|family|provider/model>', 'gstack-config unset agent_model'] },
{ kind: 'preferences', value: prefs.value, path: preferencesPath, repair: [`edit agent_model in ${preferencesPath}`] },
];
const winner = candidates.find(c => c.value !== null);
const base = {
role: 'agent' as const,
sources: { config: reading.value, configPath, preferences: prefs.value, preferencesPath },
warnings: prefs.warnings,
exemptions: AGENT_MODEL_EXEMPTIONS,
};
if (!winner) {
return { ...base, status: 'host', provider: null, model: null, id: null, family: null, value: 'host', source: { kind: 'default', path: null } };
}
const parsed = parseAgentModel(winner.value!);
if (!parsed) throw invalid(winner.value!, winner.kind === 'request' ? 'this request' : winner.path ?? winner.kind, winner.repair);
const source = { kind: winner.kind, path: winner.path };
if (parsed.kind === 'host') return { ...base, status: 'host', provider: null, model: null, id: null, family: null, value: 'host', source };
return { ...base, status: 'selected', provider: parsed.provider, model: parsed.model, id: `${parsed.provider}/${parsed.model}`, family: parsed.family, value: winner.value!.trim(), source };
}
export function renderAgentModel(s: AgentModelSelection): string[] {
const from = s.source.kind === 'default' ? 'no preference set'
: s.source.kind === 'request' ? 'requested for this job, not saved'
: `from agent_model in ${s.source.path}, ${s.source.kind === 'config' ? 'this machine' : 'the preferences file'}`;
const lines = [s.status === 'host'
? `agent: host - keep the running agent's own model and omit the model field (${from})`
: `agent: ${s.id}${s.family ? ` (${s.family})` : ''} - ${from}`].map(printable);
lines.push(printable(` config.yaml agent_model: ${s.sources.config ?? 'unset'}; preferences agent_model: ${s.sources.preferences ?? 'unset'}${s.sources.preferencesPath ? ` (${s.sources.preferencesPath})` : ' (GSTACK_PREFERENCES_FILE unset)'}`));
lines.push(` exempt: ${s.exemptions.join('; ')}`);
lines.push(' change it on this machine: gstack-config set agent_model <host|family|provider/model>; keep it across Capy machines: agent_model in the skill folder\'s preferences.yaml');
for (const w of s.warnings) lines.push(` warning: ${sanitize(w)}`);
return lines;
}