mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
171 lines
9.4 KiB
TypeScript
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;
|
|
}
|