mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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:
1 parent
ae3d58c932
commit
39378ca6d1
20 files changed
+637
-18
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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('') };
|
||||
}
|
||||
Reference in new issue
Block a user