mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
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>
This commit is contained in:
1 parent
2f2083fe67
commit
1bec403bba
7 files changed
+66
-16069
No files matched your search
+20
-9
@@ -62,8 +62,12 @@ export class AgentModelError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
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 value.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 200);
|
||||
return printable(value).slice(0, 200);
|
||||
}
|
||||
|
||||
type Parsed = { kind: 'host' } | { kind: 'model'; provider: string; model: string; family: string | null };
|
||||
@@ -71,7 +75,7 @@ type Parsed = { kind: 'host' } | { kind: 'model'; provider: string; model: strin
|
||||
/** 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 === 'host') return { kind: 'host' };
|
||||
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('/');
|
||||
@@ -96,22 +100,23 @@ export function readPreferencesFile(file: string): { value: string | null; warni
|
||||
try {
|
||||
const stat = fs.statSync(file);
|
||||
if (!stat.isFile()) return { value: null, warnings: [`${file} is not a file; ignored`] };
|
||||
if (stat.size > PREFERENCES_FILE_MAX_BYTES) return { value: null, warnings: [`${file} is over ${PREFERENCES_FILE_MAX_BYTES} bytes; ignored`] };
|
||||
text = fs.readFileSync(file, 'utf-8');
|
||||
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('\n').forEach((line, i) => {
|
||||
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].trim().replace(/^(["'])(.*)\1$/, '$2');
|
||||
value = m[2].replace(/\s#.*$/, '').trim().replace(/^(["'])(.*)\1$/, '$2');
|
||||
});
|
||||
return { value: value === '' ? null : value, warnings };
|
||||
}
|
||||
@@ -121,7 +126,13 @@ export function resolveAgentModel(opts: { env?: StateRootEnv; platform?: NodeJS.
|
||||
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;
|
||||
const prefs = preferencesPath ? readPreferencesFile(preferencesPath) : { value: null, warnings: [] };
|
||||
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'] },
|
||||
@@ -150,8 +161,8 @@ export function renderAgentModel(s: AgentModelSelection): string[] {
|
||||
: `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}`];
|
||||
lines.push(` 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)'}`);
|
||||
: `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)}`);
|
||||
|
||||
@@ -191,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("'", "'\\''")}'`;
|
||||
@@ -204,7 +204,7 @@ 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),
|
||||
...agentLines(env, platform),
|
||||
'',
|
||||
`catalog v${MODEL_CATALOG_VERSION} (sha256 ${modelCatalogSha256().slice(0, 12)})`,
|
||||
];
|
||||
@@ -260,8 +260,8 @@ function renderAgentError(error: ReturnType<AgentModelError['toJSON']>): string[
|
||||
}
|
||||
|
||||
/** The agent block of `list`: a bad agent_model is reported here and never fails the other roles. */
|
||||
function agentLines(env: StateRootEnv): string[] {
|
||||
const agent = agentOrError(env);
|
||||
function agentLines(env: StateRootEnv, platform: NodeJS.Platform): string[] {
|
||||
const agent = agentOrError(env, platform);
|
||||
return 'error' in agent ? renderAgentError(agent.error) : renderAgentModel(agent);
|
||||
}
|
||||
|
||||
@@ -334,8 +334,8 @@ export function modelsMain(args: string[], env: StateRootEnv = process.env, plat
|
||||
const code = errors.length ? 1 : 0;
|
||||
if (json) {
|
||||
const doc = document(command, config, selections, errors, env);
|
||||
return { code, stdout: `${JSON.stringify(command === 'list' ? { ...doc, agent: agentOrError(env) } : doc, null, 2)}\n`, stderr: '' };
|
||||
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) : role === 'eval-arms' ? renderEvalArms(config, selections) : renderResolve(role as ModelRole, config, selections);
|
||||
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