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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-30 12:03:08 +00:00
1 parent ae3d58c932
commit 39378ca6d1
20 files changed
+637 -18

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
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;
}
+1 -1
View File
@@ -12,7 +12,7 @@ export const CLAUDE_CODE_RUNTIME_FILES = [
'bin/gstack-claude-code', 'bin/gstack-models', 'bin/gstack-config', 'bin/gstack-state-root.sh',
'lib/claude-code.ts', 'lib/claude-code-windows-job.ts', 'lib/claude-bin.ts',
'lib/outside-review-result.ts', 'lib/gate-outcomes.ts', 'lib/outside-consent.ts', 'lib/state-root.ts',
'lib/model-catalog.ts', 'lib/model-policy.ts', 'lib/model-policy-cli.ts', 'lib/model-policy-notice.ts',
'lib/model-catalog.ts', 'lib/model-policy.ts', 'lib/model-policy-cli.ts', 'lib/model-policy-notice.ts', 'lib/agent-model.ts',
];
export interface RenameOptions {
+57 -5
View File
@@ -15,6 +15,7 @@ import {
type ModelSelection,
} from './model-policy';
import { readConfigKey, type StateRootEnv } from './state-root';
import { AgentModelError, renderAgentModel, resolveAgentModel, type AgentModelSelection } from './agent-model';
export interface ModelsCliResult {
code: 0 | 1 | 2;
@@ -33,6 +34,7 @@ export const MODELS_HELP = `Usage:
gstack-models resolve --role plan-review --provider anthropic|openai [--json]
gstack-models resolve --role implementation [--provider anthropic|openai] [--json]
gstack-models resolve --role eval-arms [--provider anthropic|openai] [--json]
gstack-models resolve --role agent [--request <model>] [--json]
gstack-models --help
Shows which model each gstack role uses and why. It reads config.yaml, the
@@ -57,6 +59,15 @@ Roles:
eval-arms The arms /eval-plan compares: the implementation-tier model of
each provider under the same precedence (plan E1). Pricing per
arm comes from lib/pricing.ts via gstack-eval-plan arms.
agent The model you want gstack's working agents (lanes, subagents,
reviewers) to run on: your preference, not a gstack policy.
Precedence: --request (one job, never saved), then agent_model
in config.yaml (this machine), then agent_model in the file
GSTACK_PREFERENCES_FILE names (on Capy, preferences.yaml in
the gstack skill folder), then host (keep the agent's own
model). Values: host, a family (opus, fable, sonnet, haiku,
astra, sol, luna, terra) or <provider>/<model>. A cross-family
outside voice and a model under test in an eval ignore it.
Settings (gstack-config set|unset <key>):
plan_review_tier frontier | smart | host (default frontier)
@@ -180,7 +191,7 @@ function renderSelections(role: ModelRole, config: ModelPolicyConfig, selections
return lines;
}
function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env: StateRootEnv): string {
function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env: StateRootEnv, platform: NodeJS.Platform): string {
const effort = codexEffortSetting(env);
const commands = modelPolicyCommands();
const configCommand = `'${CONFIG_BIN.replaceAll('\\', '/').replaceAll("'", "'\\''")}'`;
@@ -193,6 +204,8 @@ function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env
'',
`codex effort: ${effort.value ?? 'each gate\'s default'} (${effort.source === 'env' ? 'GSTACK_CODEX_EFFORT' : effort.source === 'config' ? `codex_effort in ${config.path}` : 'default; set codex_effort'})${effort.valid ? '' : ` - invalid; Codex calls stop before sending. Use ${CODEX_EFFORT_LEVELS.join(', ')}`}; /spec keeps medium`,
'',
...agentLines(env, platform),
'',
`catalog v${MODEL_CATALOG_VERSION} (sha256 ${modelCatalogSha256().slice(0, 12)})`,
];
for (const tier of MODEL_TIERS) {
@@ -233,6 +246,37 @@ function renderResolve(role: ModelRole, config: ModelPolicyConfig, selections: M
return lines.join('\n') + '\n';
}
function agentOrError(env: StateRootEnv, platform?: NodeJS.Platform, request?: string): AgentModelSelection | { error: ReturnType<AgentModelError['toJSON']> } {
try {
return resolveAgentModel({ env, platform, request });
} catch (error) {
if (!(error instanceof AgentModelError)) throw error;
return { error: error.toJSON() };
}
}
function renderAgentError(error: ReturnType<AgentModelError['toJSON']>): string[] {
return [`agent: ${error.problem}`, ` cause: ${error.cause}`, ...error.repair.map(step => ` fix: ${step}`), ` docs: ${error.docs}`];
}
/** The agent block of `list`: a bad agent_model is reported here and never fails the other roles. */
function agentLines(env: StateRootEnv, platform: NodeJS.Platform): string[] {
const agent = agentOrError(env, platform);
return 'error' in agent ? renderAgentError(agent.error) : renderAgentModel(agent);
}
function agentResult(env: StateRootEnv, platform: NodeJS.Platform, request: string | undefined, json: boolean): ModelsCliResult {
const agent = agentOrError(env, platform, request);
if ('error' in agent) {
return json
? { code: 1, stdout: `${JSON.stringify({ schemaVersion: 1, command: 'resolve', ok: false, offline: true, note: OFFLINE_NOTE, agent: null, errors: [agent.error] }, null, 2)}\n`, stderr: '' }
: { code: 1, stdout: '', stderr: renderAgentError(agent.error).map(l => `gstack-models: ${l.trimStart()}`).join('\n') + '\n' };
}
return json
? { code: 0, stdout: `${JSON.stringify({ schemaVersion: 1, command: 'resolve', ok: true, offline: true, note: OFFLINE_NOTE, agent, errors: [] }, null, 2)}\n`, stderr: '' }
: { code: 0, stdout: [...renderAgentModel(agent), `(${OFFLINE_NOTE})`].join('\n') + '\n', stderr: '' };
}
export function modelsMain(args: string[], env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform, cwd: string = process.cwd()): ModelsCliResult {
const json = args.includes('--json');
if (args.includes('--help') || args.includes('-h')) return { code: 0, stdout: MODELS_HELP, stderr: '' };
@@ -244,7 +288,7 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat
const arg = rest.shift()!;
if (arg === '--json') continue;
const [name, inline] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
if (command === 'list' || (name !== '--role' && name !== '--provider')) return usage(`unknown argument '${arg}' for ${command}`, json);
if (command === 'list' || (name !== '--role' && name !== '--provider' && name !== '--request')) return usage(`unknown argument '${arg}' for ${command}`, json);
if (options[name] !== undefined) return usage(`${name} given twice`, json);
const value = inline ?? rest.shift();
if (value === undefined || value === '' || value.startsWith('--')) return usage(`${name} needs a value`, json);
@@ -254,7 +298,12 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat
const providerArg = options['--provider'];
const provider = parseProvider(providerArg);
if (command === 'resolve') {
if (role !== 'plan-review' && role !== 'implementation' && role !== 'eval-arms') return usage(role === undefined ? 'resolve needs --role plan-review|implementation|eval-arms' : `unknown role '${role}': use plan-review, implementation or eval-arms`, json);
if (role !== 'plan-review' && role !== 'implementation' && role !== 'eval-arms' && role !== 'agent') return usage(role === undefined ? 'resolve needs --role plan-review|implementation|eval-arms|agent' : `unknown role '${role}': use plan-review, implementation, eval-arms or agent`, json);
if (role === 'agent') {
if (providerArg !== undefined) return usage('resolve --role agent takes no --provider; the preference names its provider', json);
return agentResult(env, platform, options['--request'], json);
}
if (options['--request'] !== undefined) return usage('--request applies only to --role agent', json);
if (providerArg !== undefined && !provider) return usage(`unknown provider '${providerArg}': use anthropic or openai`, json);
if (role === 'plan-review' && !provider) return usage('resolve --role plan-review needs --provider anthropic|openai (the opposing provider for your host)', json);
}
@@ -283,7 +332,10 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat
// eval-arms (plan E1): the implementation-tier resolution of each provider, one arm each.
if (role === 'eval-arms') for (const s of selections) (s as { role: string }).role = 'eval-arms';
const code = errors.length ? 1 : 0;
if (json) return { code, stdout: `${JSON.stringify(document(command, config, selections, errors, env), null, 2)}\n`, stderr: '' };
const stdout = command === 'list' ? renderList(config, selections, env) : role === 'eval-arms' ? renderEvalArms(config, selections) : renderResolve(role as ModelRole, config, selections);
if (json) {
const doc = document(command, config, selections, errors, env);
return { code, stdout: `${JSON.stringify(command === 'list' ? { ...doc, agent: agentOrError(env, platform) } : doc, null, 2)}\n`, stderr: '' };
}
const stdout = command === 'list' ? renderList(config, selections, env, platform) : role === 'eval-arms' ? renderEvalArms(config, selections) : renderResolve(role as ModelRole, config, selections);
return { code, stdout, stderr: errors.map(renderError).join('') };
}