@songsid/agend 2.1.4-beta.12 → 2.1.4-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,6 +36,25 @@ export declare function selectLruEvictions(warm: string[], cap: number, opts: {
36
36
  isIdle: (name: string) => boolean;
37
37
  lastInboundAt: (name: string) => number;
38
38
  }): string[];
39
+ /**
40
+ * Answer shape for `list_models`. `scope` reports where the LIST came from —
41
+ * "instance" only when it was read through that instance's own backend config,
42
+ * "global" for the account/CLI catalog — so a caller can tell an authoritative
43
+ * per-instance list from a best-effort account-wide one.
44
+ */
45
+ export interface ModelCatalog {
46
+ backend: string;
47
+ scope: "instance" | "global";
48
+ /** Set whenever an instance was asked about, even if the list is global. */
49
+ instance?: string;
50
+ current_model: string | null;
51
+ models: import("./backend/types.js").ModelOption[];
52
+ /** "cache" = startup probe cache, "live" = probed now, "fallback" = none available. */
53
+ source: "cache" | "live" | "fallback";
54
+ probed_at?: string;
55
+ /** Caveat the caller should read before trusting the list. */
56
+ note?: string;
57
+ }
39
58
  export interface DeliveryOptions {
40
59
  /** Explicitly identify agent-to-agent delivery when metadata is unavailable. */
41
60
  isCrossInstance?: boolean;
@@ -471,9 +490,13 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
471
490
  private startTopicCleanupPoller;
472
491
  /**
473
492
  * Patch only values changed in the effective config into the original YAML
474
- * document. Unknown keys, explicit overrides and comments remain untouched.
493
+ * document. Unknown keys and comments remain untouched; redundant
494
+ * non-identity instance leaves are canonicalized to inheritance afterward.
475
495
  */
476
496
  saveFleetConfig(explicitPatches?: RawConfigPatch[]): void;
497
+ /** One-time upgrade migration; invalid YAML is never rewritten. */
498
+ private slimFleetConfigAtStartup;
499
+ private writeFleetConfigBackup;
477
500
  private patchFleetDocument;
478
501
  removeInstance(name: string): Promise<void>;
479
502
  startStatuslineWatcher(name: string): void;
@@ -844,6 +867,34 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
844
867
  private probeCliEnvs;
845
868
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
846
869
  private getModelOptions;
870
+ /**
871
+ * Model catalog behind the `list_models` tool.
872
+ *
873
+ * The two scopes are not cosmetic. "global" is the account/CLI catalog served
874
+ * from the startup probe cache; "instance" is resolved through that instance's
875
+ * OWN backend config, and for a Codex instance on a custom provider that is a
876
+ * different catalog entirely — `listModels()` reads models_cache.json out of
877
+ * the instance's private CODEX_HOME. Answering such an instance with the
878
+ * account list would name models its CLI rejects, which is exactly the
879
+ * mistake this tool exists to prevent.
880
+ *
881
+ * `scope` always describes where the returned LIST came from, not what was
882
+ * asked for: an instance query that falls back to the account catalog reports
883
+ * scope "global" and says so in `note`, rather than implying instance-level
884
+ * accuracy it does not have.
885
+ *
886
+ * Never throws — a model listing is an aid, and failing it must not fail a turn.
887
+ */
888
+ listModelCatalog(opts?: {
889
+ backend?: string;
890
+ instanceName?: string;
891
+ }): Promise<ModelCatalog>;
892
+ /** The custom provider an instance overrides its backend with, if any. */
893
+ private customProviderFor;
894
+ /** Ask a backend for its catalog using ONE instance's real config. Never throws. */
895
+ private instanceScopedModels;
896
+ /** Account-wide catalog: probe cache first, live probe on miss. */
897
+ private globalModelCatalog;
847
898
  /** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
848
899
  /** Label an effort choice, marking the one currently configured. */
849
900
  private effortChoiceLabel;
@@ -9,7 +9,7 @@ import { formatUpdateProgress } from "./update-progress.js";
9
9
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
10
10
  import { readFleetMemory } from "./process-memory.js";
11
11
  import { ReplyDeduper } from "./reply-dedup.js";
12
- import { isScalar, parseDocument } from "yaml";
12
+ import { isMap, isScalar, parseDocument } from "yaml";
13
13
  const __filename = fileURLToPath(import.meta.url);
14
14
  const __dirname = dirname(__filename);
15
15
  /** Fallback access policy for a channel with no `access:` block — open (no gate). */
@@ -54,6 +54,7 @@ import { releaseProcessFleetLock } from "./fleet-lock.js";
54
54
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
55
55
  import { loadOrCreateWebToken, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
56
56
  import { RestartProgress } from "./restart-progress.js";
57
+ import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
57
58
  import { getTmuxSession } from "./config.js";
58
59
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
59
60
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
@@ -1453,6 +1454,7 @@ export class FleetManager {
1453
1454
  // Rotate fleet.log if oversized (before any logging)
1454
1455
  rotateLogIfNeeded(join(this.dataDir, "fleet.log"));
1455
1456
  const fleet = this.loadConfig(configPath);
1457
+ this.slimFleetConfigAtStartup();
1456
1458
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
1457
1459
  const savedUpdateProgress = readUpdateProgress(this.dataDir);
1458
1460
  const pendingUpdateProgress = savedUpdateProgress
@@ -4229,7 +4231,8 @@ export class FleetManager {
4229
4231
  }
4230
4232
  /**
4231
4233
  * Patch only values changed in the effective config into the original YAML
4232
- * document. Unknown keys, explicit overrides and comments remain untouched.
4234
+ * document. Unknown keys and comments remain untouched; redundant
4235
+ * non-identity instance leaves are canonicalized to inheritance afterward.
4233
4236
  */
4234
4237
  saveFleetConfig(explicitPatches = []) {
4235
4238
  if (!this.fleetConfig || !this.configPath)
@@ -4245,9 +4248,9 @@ export class FleetManager {
4245
4248
  }
4246
4249
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
4247
4250
  this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
4248
- // Settings edits are expressed against the raw config. Persist them even
4249
- // when the chosen override equals the inherited effective value, a case
4250
- // the before/after runtime diff cannot observe.
4251
+ // Settings edits are expressed against the raw config, so apply them before
4252
+ // canonicalization. A non-identity value equal to its inherited default is
4253
+ // intentionally stored as inheritance rather than an explicit duplicate.
4251
4254
  for (const patch of explicitPatches) {
4252
4255
  if (patch.remove) {
4253
4256
  // YAML's deleteIn throws when an inherited nested key has no raw parent
@@ -4262,7 +4265,24 @@ export class FleetManager {
4262
4265
  this.patchFleetDocument(this.rawFleetDocument, patch.path, before, patch.value);
4263
4266
  }
4264
4267
  }
4268
+ const rawAfterPatches = this.rawFleetDocument.toJS();
4269
+ const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
4270
+ for (const path of redundantPaths) {
4271
+ if (this.rawFleetDocument.hasIn(path))
4272
+ this.rawFleetDocument.deleteIn(path);
4273
+ // Avoid leaving empty operational maps such as `terminal: {}` while
4274
+ // preserving the instance mapping itself and all surrounding comments.
4275
+ for (let depth = path.length - 1; depth > 2; depth--) {
4276
+ const parentPath = path.slice(0, depth);
4277
+ const parent = this.rawFleetDocument.getIn(parentPath, true);
4278
+ if (!isMap(parent) || parent.items.length > 0)
4279
+ break;
4280
+ this.rawFleetDocument.deleteIn(parentPath);
4281
+ }
4282
+ }
4265
4283
  const output = String(this.rawFleetDocument);
4284
+ if (redundantPaths.length > 0)
4285
+ this.writeFleetConfigBackup(source);
4266
4286
  const tempPath = `${this.configPath}.tmp-${process.pid}`;
4267
4287
  writeFileSync(tempPath, output, "utf-8");
4268
4288
  if (existsSync(this.configPath))
@@ -4270,7 +4290,36 @@ export class FleetManager {
4270
4290
  renameSync(tempPath, this.configPath);
4271
4291
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
4272
4292
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
4273
- this.logger.info({ path: this.configPath }, "Saved fleet config (lossless patch)");
4293
+ this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
4294
+ }
4295
+ /** One-time upgrade migration; invalid YAML is never rewritten. */
4296
+ slimFleetConfigAtStartup() {
4297
+ const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
4298
+ if (redundantPaths.length === 0)
4299
+ return;
4300
+ const validation = validateFleetConfig(this.rawFleetConfig);
4301
+ if (!validation.valid) {
4302
+ this.logger.warn({ errors: validation.errors, redundantDefaults: redundantPaths.length }, "Skipping fleet.yaml default slimming because the raw config is invalid");
4303
+ return;
4304
+ }
4305
+ try {
4306
+ this.saveFleetConfig();
4307
+ this.logger.info({ strippedDefaults: redundantPaths.length, backup: `${this.configPath}.bak` }, "Slimmed redundant instance defaults in fleet.yaml");
4308
+ }
4309
+ catch (err) {
4310
+ // A migration must not turn a previously bootable fleet into an outage.
4311
+ this.logger.warn({ err }, "Could not slim fleet.yaml; continuing with the original config");
4312
+ }
4313
+ }
4314
+ writeFleetConfigBackup(source) {
4315
+ if (!this.configPath)
4316
+ return;
4317
+ const backupPath = `${this.configPath}.bak`;
4318
+ const tempPath = `${backupPath}.tmp-${process.pid}`;
4319
+ writeFileSync(tempPath, source, "utf-8");
4320
+ if (existsSync(this.configPath))
4321
+ chmodSync(tempPath, statSync(this.configPath).mode);
4322
+ renameSync(tempPath, backupPath);
4274
4323
  }
4275
4324
  patchFleetDocument(document, path, before, after) {
4276
4325
  if (Object.is(before, after))
@@ -4322,7 +4371,12 @@ export class FleetManager {
4322
4371
  currentNode.value = after;
4323
4372
  }
4324
4373
  else {
4325
- document.setIn(path, after);
4374
+ // Use a YAML collection node for newly-added objects. Passing the raw
4375
+ // object creates a scalar wrapper: it serializes, but nested hasIn /
4376
+ // deleteIn cannot traverse it (notably when slimming create_instance).
4377
+ document.setIn(path, after !== null && typeof after === "object"
4378
+ ? document.createNode(after)
4379
+ : after);
4326
4380
  }
4327
4381
  }
4328
4382
  }
@@ -6403,6 +6457,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6403
6457
  return null;
6404
6458
  const probed = await be.probeCLIEnv({ workingDirectory: "", instanceDir: join(getAgendHome(), "cli-env"), instanceName: `probe-${backend}`, mcpServers: {} });
6405
6459
  const env = { backend, probedAt: Date.now(), ...probed };
6460
+ // An empty result must never overwrite a catalog we already have. Some
6461
+ // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
6462
+ // returns [] — and writing that would blank the list for the whole 24h
6463
+ // TTL, long after the CLI recovered. Observed live: a good 11-model
6464
+ // antigravity cache replaced by an empty one. Keep the known models and
6465
+ // let the fresher currentModel/version through.
6466
+ if (!env.models?.length) {
6467
+ const previous = this.readCliEnv(backend);
6468
+ if (previous?.models?.length)
6469
+ env.models = previous.models;
6470
+ }
6406
6471
  const path = this.cliEnvPath(backend);
6407
6472
  mkdirSync(dirname(path), { recursive: true });
6408
6473
  writeFileSync(path, JSON.stringify(env, null, 2));
@@ -6441,6 +6506,108 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
6441
6506
  const env = await this.probeBackend(backendName);
6442
6507
  return env?.models ?? [];
6443
6508
  }
6509
+ /**
6510
+ * Model catalog behind the `list_models` tool.
6511
+ *
6512
+ * The two scopes are not cosmetic. "global" is the account/CLI catalog served
6513
+ * from the startup probe cache; "instance" is resolved through that instance's
6514
+ * OWN backend config, and for a Codex instance on a custom provider that is a
6515
+ * different catalog entirely — `listModels()` reads models_cache.json out of
6516
+ * the instance's private CODEX_HOME. Answering such an instance with the
6517
+ * account list would name models its CLI rejects, which is exactly the
6518
+ * mistake this tool exists to prevent.
6519
+ *
6520
+ * `scope` always describes where the returned LIST came from, not what was
6521
+ * asked for: an instance query that falls back to the account catalog reports
6522
+ * scope "global" and says so in `note`, rather than implying instance-level
6523
+ * accuracy it does not have.
6524
+ *
6525
+ * Never throws — a model listing is an aid, and failing it must not fail a turn.
6526
+ */
6527
+ async listModelCatalog(opts = {}) {
6528
+ const { instanceName } = opts;
6529
+ if (instanceName) {
6530
+ const backend = this.backendNameForInstance(instanceName);
6531
+ const resolved = this.resolveInstanceModel(instanceName);
6532
+ const currentModel = resolved.source === "unresolved" ? null : resolved.model;
6533
+ const provider = this.customProviderFor(instanceName, backend);
6534
+ const scoped = await this.instanceScopedModels(instanceName, backend);
6535
+ if (scoped.length) {
6536
+ return {
6537
+ backend, scope: "instance", instance: instanceName,
6538
+ current_model: currentModel, models: scoped, source: "live",
6539
+ ...(provider ? { note: `Catalog read through this instance's ${backend} provider "${provider}" — it may differ from the account catalog.` } : {}),
6540
+ };
6541
+ }
6542
+ // No instance-local catalog (never launched, or the backend has no
6543
+ // per-instance list). The account catalog is the best available answer,
6544
+ // but it is labelled honestly rather than dressed up as instance scope.
6545
+ const global = await this.globalModelCatalog(backend);
6546
+ return {
6547
+ ...global, instance: instanceName, current_model: currentModel,
6548
+ note: provider
6549
+ ? `No instance-local catalog yet; showing the account catalog, which may NOT match this instance's ${backend} provider "${provider}".`
6550
+ : "No instance-local catalog yet; showing the account catalog.",
6551
+ };
6552
+ }
6553
+ return this.globalModelCatalog(opts.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code");
6554
+ }
6555
+ /** The custom provider an instance overrides its backend with, if any. */
6556
+ customProviderFor(instanceName, backend) {
6557
+ const opts = this.fleetConfig?.instances?.[instanceName]?.backend_options?.[backend]
6558
+ ?? this.fleetConfig?.defaults?.backend_options?.[backend];
6559
+ const provider = opts?.provider;
6560
+ return typeof provider === "string" && provider.trim() ? provider.trim() : null;
6561
+ }
6562
+ /** Ask a backend for its catalog using ONE instance's real config. Never throws. */
6563
+ async instanceScopedModels(instanceName, backend) {
6564
+ try {
6565
+ const inst = this.fleetConfig?.instances?.[instanceName];
6566
+ const instanceDir = this.getInstanceDir(instanceName);
6567
+ const be = createBackend(backend, instanceDir);
6568
+ if (!be.listModels)
6569
+ return [];
6570
+ return await be.listModels({
6571
+ workingDirectory: inst?.working_directory ?? "",
6572
+ instanceDir,
6573
+ instanceName,
6574
+ mcpServers: {},
6575
+ model: inst?.model,
6576
+ backendOptions: inst?.backend_options?.[backend] ?? this.fleetConfig?.defaults?.backend_options?.[backend],
6577
+ }) ?? [];
6578
+ }
6579
+ catch {
6580
+ // listModels is documented never to throw, but a backend constructor can
6581
+ // (missing binary). A catalog is an aid; degrade to the account list.
6582
+ return [];
6583
+ }
6584
+ }
6585
+ /** Account-wide catalog: probe cache first, live probe on miss. */
6586
+ async globalModelCatalog(backend) {
6587
+ const cached = this.readCliEnv(backend);
6588
+ if (cached?.models?.length) {
6589
+ return {
6590
+ backend, scope: "global", current_model: cached.currentModel ?? null,
6591
+ models: cached.models, source: "cache",
6592
+ probed_at: new Date(cached.probedAt).toISOString(),
6593
+ };
6594
+ }
6595
+ const env = await this.probeBackend(backend);
6596
+ if (env?.models?.length) {
6597
+ return {
6598
+ backend, scope: "global", current_model: env.currentModel ?? null,
6599
+ models: env.models, source: "live",
6600
+ probed_at: new Date(env.probedAt).toISOString(),
6601
+ };
6602
+ }
6603
+ // Reported rather than thrown: "we could not enumerate" is a useful answer,
6604
+ // and the caller can still set a model by name (AgEnD passes it through).
6605
+ return {
6606
+ backend, scope: "global", current_model: env?.currentModel ?? null,
6607
+ models: [], source: "fallback",
6608
+ note: `Could not enumerate models for ${backend} (CLI missing, not logged in, or it offers no list). Model names are passed through to the CLI, so a known-good name still works.`,
6609
+ };
6610
+ }
6444
6611
  /** `/model` slash handler (admin only). No arg → DC menu; `/model <name>` → apply directly. */
6445
6612
  /** Label an effort choice, marking the one currently configured. */
6446
6613
  effortChoiceLabel(level, current) {