@aragon-agent/cli 0.5.3 → 0.5.4

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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,35 @@ were written.
9
9
 
10
10
  ### Added
11
11
 
12
+ - **Output token limits that cannot themselves fail a run.** Every model and
13
+ every provider now defaults to 64000 output tokens, and that number is
14
+ configurable from the TUI, from a flag, from `ARAGON_MAX_TOKENS`, and by hand
15
+ in `~/.aragon-agent/config.json`. Leave it alone and it stays 64000.
16
+
17
+ What is new is that the number is an *ambition*. `gpt-4o` caps at 16384 and
18
+ `gemini-1.5-pro` at 8192; sending 64000 to either is an HTTP 400 that ends the
19
+ turn with a raw provider string in the transcript. The cap is now clamped down
20
+ to whatever the target model actually accepts, from a per-model table plus
21
+ whatever the provider's own model list reports. For a model nothing knows — a
22
+ proxy, a `--base-url` endpoint, something that shipped last week — the request
23
+ goes out at 64000, and if the provider objects, `aragon` reads the real ceiling
24
+ out of the error text, repairs the request, sends it exactly once more, and
25
+ remembers the answer for the rest of the session. One warning line; the run
26
+ continues.
27
+
28
+ Two combinations that were a guaranteed 400 on every request are fixed with
29
+ it: `--thinking xhigh` asks for a 65536-token budget while Anthropic requires
30
+ `max_tokens` to be strictly greater than it, and OpenAI's reasoning family
31
+ (`o1`, `o3`, `gpt-5`) rejects the `max_tokens` parameter name outright in
32
+ favour of `max_completion_tokens`.
33
+
34
+ New surfaces: `/max-tokens [<n>|auto|default]`, which with no argument reports
35
+ the setting *and* the effective cap for the live model; an `Effective:` line
36
+ under the settings screen's `Max tokens` field, so a value the model will not
37
+ honour is visible before you save it; `--max-tokens auto`; and
38
+ `aragon config set maxTokens <n|auto|default>`, which echoes the value that was
39
+ actually stored rather than the one you typed.
40
+
12
41
  - **Team subagents.** The agent can now hand independent parts of a job to up to
13
42
  five short-lived subagents that run in parallel, then read one combined report
14
43
  of what each of them found. It decides for itself whether to delegate, so a
@@ -71,6 +100,14 @@ were written.
71
100
 
72
101
  ### Changed
73
102
 
103
+ - **`"maxTokens": null` in `config.json` now means AUTO** — use each model's own
104
+ ceiling, never above 64000. It previously resolved to 64000 by accident (the
105
+ layered resolver skipped `null`), and there was no way to express AUTO through
106
+ any supported write path. If you have a `null` there today, the only difference
107
+ is a downward clamp on models whose real ceiling is below 64000 — the direction
108
+ that prevents failures rather than causing them. Absent still means 64000, and
109
+ an explicit number still means that number.
110
+
74
111
  - **`config.json` holds settings again.** The prompts you type are no longer
75
112
  stored in it. They move to `~/.aragon-agent/prompt-history.jsonl` — one JSON
76
113
  object per line, `0600` on POSIX — and the submit counter and the "notice
package/README.md CHANGED
@@ -60,7 +60,7 @@ aragon --help | -h Print help
60
60
  | `--base-url <url>` | Override the provider base URL (OpenAI-compatible endpoints) |
61
61
  | `--api-key <key>` | One-shot key override (not persisted) |
62
62
  | `--thinking <level>` | `off\|minimal\|low\|medium\|high\|xhigh` |
63
- | `--max-tokens <n>` | Output token cap |
63
+ | `--max-tokens <n\|auto>` | Output token cap: a number, or `auto` for the model's own ceiling (default `64000`) |
64
64
  | `--cwd <dir>` | Working directory for tools |
65
65
  | `--confirm` | Confirm each mutating tool call |
66
66
  | `--tool-timeout <ms>` | Per-tool executor ceiling (default 180000) |
@@ -91,6 +91,7 @@ Exit codes: `0` success · `1` agent/runtime error · `2` config/usage error ·
91
91
  | `/model` | Open the model picker |
92
92
  | `/settings` | Open the settings screen |
93
93
  | `/thinking <level>` | Set the thinking level |
94
+ | `/max-tokens [<n>\|auto\|default]` | Output token cap; with no argument, reports the setting **and** the effective cap for the live model |
94
95
  | `/tools` | List active tools |
95
96
  | `/clear` | Clear the visible transcript |
96
97
  | `/reset` | New conversation |
@@ -674,7 +675,9 @@ env / `.env` → CLI flags**.
674
675
 
675
676
  - **Env / `.env`**: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
676
677
  `GOOGLE_API_KEY` / `GEMINI_API_KEY`, plus `ARAGON_PROVIDER`, `ARAGON_MODEL`,
677
- `ARAGON_BASE_URL`, `ARAGON_THINKING`, `ARAGON_MAX_TOKENS`, `ARAGON_THEME`,
678
+ `ARAGON_BASE_URL`, `ARAGON_THINKING`,
679
+ `ARAGON_MAX_TOKENS` (a number, or `auto` / `0` for the per-model ceiling),
680
+ `ARAGON_THEME`,
678
681
  `ARAGON_FULLSCREEN`, `ARAGON_PLAN` (`1` starts in plan mode),
679
682
  `ARAGON_TEAM` (`0` disables team subagents), `ARAGON_TEAM_MAX` (fan-out width),
680
683
  `ARAGON_SKILLS` (`0` disables), `ARAGON_SKILLS_PATH`,
@@ -692,6 +695,7 @@ env / `.env` → CLI flags**.
692
695
 
693
696
  | Config key | Default | Meaning |
694
697
  | --- | --- | --- |
698
+ | `maxTokens` | `64000` | Output token cap, clamped to `[256, 200000]`. `null` means **auto** — use each model's own ceiling, never above 64000. Absent means the default. See [Output token limits](#output-token-limits). |
695
699
  | `fullscreen` | `true` | Use the full-screen TUI (still subject to the automatic downgrades). Setting it to `false` opts out permanently; leaving it `true` is *not* a force — only `--fullscreen` / `ARAGON_FULLSCREEN=1` override the heuristics. |
696
700
  | `exitTranscript` | `true` | Replay a plain-text session summary after exiting (full-screen only). |
697
701
  | `transcriptWindow` | `300` | How many trailing entries the full-screen viewport renders, clamped to `[50, 2000]`. The full history stays in memory and in `/save`. |
@@ -727,6 +731,65 @@ Byte, not character: `ToolExecutor` truncates at 100 000 **bytes**, so a
727
731
  character-based budget would be roughly triple its nominal size for CJK text
728
732
  and would get cut mid-tag.
729
733
 
734
+ ### Output token limits
735
+
736
+ Every model and every provider defaults to **64000** output tokens. You never
737
+ have to configure this, and if you never touch it the number stays 64000.
738
+
739
+ The number you set is an **ambition, not a promise**: it is clamped down to
740
+ whatever the target model actually accepts. `gpt-4o` caps at 16384 and
741
+ `gemini-1.5-pro` at 8192, and sending 64000 to either is an HTTP 400 that ends
742
+ the turn — so `aragon` sends what the model will take, silently. When the model
743
+ is one it has never seen (a proxy, an endpoint behind `--base-url`, a model that
744
+ shipped last week), it sends 64000, and if the provider objects it reads the real
745
+ ceiling out of the provider's own error, repairs the request, sends it again, and
746
+ remembers the answer for the rest of the session. You see one warning line and
747
+ the run continues.
748
+
749
+ Four ways to change it, highest priority first:
750
+
751
+ ```bash
752
+ aragon --max-tokens 32000 # this run only
753
+ aragon --max-tokens auto # this run only, per-model ceiling
754
+ ARAGON_MAX_TOKENS=32000 aragon # env layer, between the flag and the file
755
+ aragon config set maxTokens 32000 # persisted; prints "Set maxTokens = 32000"
756
+ aragon config set maxTokens auto # persisted as null
757
+ aragon config set maxTokens default # back to 64000
758
+ aragon config get maxTokens # "64000" | "auto"
759
+ ```
760
+
761
+ Inside the TUI, `/max-tokens` reports the current setting **and** the effective
762
+ cap for the live model, and `/max-tokens <n|auto|default>` changes it. The
763
+ settings screen (`/settings`) has the same field with an `Effective:` line under
764
+ it, so a value the model will not honour is visible before you save it.
765
+
766
+ And by hand, in `~/.aragon-agent/config.json` (`aragon config path` prints the
767
+ exact location; `aragon config edit` opens it, and `/reload` picks up an edit
768
+ made in another window):
769
+
770
+ ```jsonc
771
+ {
772
+ "version": 1,
773
+ "provider": "anthropic",
774
+ "model": "claude-sonnet-4-5-20250929",
775
+
776
+ // Output token cap.
777
+ // 64000 (or any 256..200000 integer) — an explicit cap, clamped down to
778
+ // the target model's real ceiling when that ceiling is lower.
779
+ // null — auto: use each model's own ceiling, never above 64000.
780
+ // absent — the product default, 64000.
781
+ "maxTokens": 64000
782
+ }
783
+ ```
784
+
785
+ Out-of-range numbers are clamped on the way in **and** on the way out, so a
786
+ hand-edited `900000` becomes `200000` on disk rather than reverting to the
787
+ default on every launch.
788
+
789
+ Extended thinking is reconciled with the cap automatically: `--thinking xhigh`
790
+ asks for a 65536-token budget, Anthropic requires `max_tokens` to be strictly
791
+ greater than it, and the two are resolved together rather than left to collide.
792
+
730
793
  These are additive: a config file written by an earlier version loads
731
794
  unchanged, with no migration and no version bump.
732
795
 
package/dist/cli.js CHANGED
@@ -27,7 +27,7 @@ import { LOG_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, applyLogConfigSet, applyTeam
27
27
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
28
28
  import { getLogger } from './logging/logger.js';
29
29
  import { runLogsCommand } from './logging/cli-commands.js';
30
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampTranscriptWindow, coerceMaxTokens, coercePositiveInt, isAdapterProvider, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SKILLS_CONFIG, } from './config/schema.js';
30
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SKILLS_CONFIG, } from './config/schema.js';
31
31
  import { makeGetApiKey } from './config/load.js';
32
32
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
33
33
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -397,6 +397,29 @@ const CONFIG_SET_KEYS = new Set([
397
397
  // still printing `Set team.enabled = false`.
398
398
  ...TEAM_CONFIG_SET_KEYS,
399
399
  ]);
400
+ /**
401
+ * `aragon config set maxTokens <n|auto|default>`.
402
+ *
403
+ * REJECTS nonsense rather than clamping it, unlike `theme` and `thinkingLevel`.
404
+ * Those two clamp because their bad values are out-of-VOCABULARY; here a
405
+ * non-numeric argument is almost always a typo for a number the user meant, and
406
+ * storing 64000 for `3200O` would quietly discard their intent. An out-of-RANGE
407
+ * number is still clamped, in both directions, by the store's single gate.
408
+ */
409
+ function runConfigSetMaxTokens(value) {
410
+ const raw = value.trim().toLowerCase();
411
+ const parsed = raw === 'default'
412
+ ? { kind: 'value', value: DEFAULT_MAX_TOKENS, clamped: false }
413
+ : parseMaxTokensInput(value);
414
+ if (parsed.kind === 'invalid') {
415
+ process.stderr.write(`Invalid maxTokens "${value}". Use a number, "auto", or "default".\n`);
416
+ process.exitCode = 2;
417
+ return;
418
+ }
419
+ const stored = parsed.kind === 'auto' ? null : parsed.value;
420
+ const merged = updatePersistedConfig({ maxTokens: stored });
421
+ process.stdout.write(`Set maxTokens = ${merged.maxTokens ?? 'auto'}\n`);
422
+ }
400
423
  function runConfigSet(key, value) {
401
424
  if (!CONFIG_SET_KEYS.has(key)) {
402
425
  process.stderr.write(`Unknown config key "${key}". Known keys: ${[...CONFIG_SET_KEYS].join(', ')}.\n`);
@@ -410,6 +433,13 @@ function runConfigSet(key, value) {
410
433
  process.stdout.write(`Set ${key} = ${value}\n`);
411
434
  return;
412
435
  }
436
+ // Handled ahead of the switch because it needs to echo the STORED value, not
437
+ // the typed one: `config set maxTokens 900000` must not report a number that
438
+ // was never written. Same reason `team.*` has its own branch.
439
+ if (key === 'maxTokens') {
440
+ runConfigSetMaxTokens(value);
441
+ return;
442
+ }
413
443
  const teamPatch = applyTeamConfigSet(key, value);
414
444
  if (teamPatch) {
415
445
  // The written value is echoed from the CLAMPED result rather than from the
@@ -434,9 +464,6 @@ function runConfigSet(key, value) {
434
464
  case 'thinkingLevel':
435
465
  patch.thinkingLevel = clampThinkingLevel(value, 'off');
436
466
  break;
437
- case 'maxTokens':
438
- patch.maxTokens = coerceMaxTokens(value) ?? null;
439
- break;
440
467
  case 'theme':
441
468
  patch.theme = clampTheme(value, 'auto');
442
469
  break;
@@ -540,7 +567,7 @@ function buildProgram() {
540
567
  .option('--base-url <url>', 'Override the provider base URL')
541
568
  .option('--api-key <key>', 'One-shot API key override (not persisted)')
542
569
  .option('--thinking <level>', 'off|minimal|low|medium|high|xhigh')
543
- .option('--max-tokens <n>', 'Output token cap')
570
+ .option('--max-tokens <n|auto>', 'Output token cap: a number, or "auto" for the model ceiling (default 64000)')
544
571
  .option('--cwd <dir>', 'Working directory for tools')
545
572
  .option('--confirm', 'Confirm each mutating tool call')
546
573
  .option('--tool-timeout <ms>', 'Per-tool executor ceiling (default 180000)')
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Built-in slash commands (spec §5.2):
3
- * /help /model /settings /thinking /tools /clear /reset /cwd /save /resume
4
- * /copy /exit (/quit).
3
+ * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
+ * /resume /copy /exit (/quit).
5
5
  */
6
6
  import { CommandRegistry } from './registry.js';
7
7
  export declare function registerBuiltinCommands(registry: CommandRegistry): void;
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Built-in slash commands (spec §5.2):
3
- * /help /model /settings /thinking /tools /clear /reset /cwd /save /resume
4
- * /copy /exit (/quit).
3
+ * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
+ * /resume /copy /exit (/quit).
5
5
  */
6
6
  import { spawn } from 'node:child_process';
7
7
  import { existsSync, statSync } from 'node:fs';
8
8
  import { isAbsolute, resolve } from 'node:path';
9
9
  import process from 'node:process';
10
- import { clampThinkingLevel, clampTheme, isThemeName, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
10
+ import { resolveOutputTokens } from '@aragon-agent/core';
11
+ import { clampThinkingLevel, clampTheme, isThemeName, parseMaxTokensInput, DEFAULT_MAX_TOKENS, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
11
12
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
12
13
  import { loadConfig } from '../config/load.js';
13
14
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
@@ -43,6 +44,53 @@ const COMMANDS = [
43
44
  ctx.toast('info', `Thinking level set to "${clamped}".`);
44
45
  },
45
46
  },
47
+ {
48
+ name: 'max-tokens',
49
+ description: 'Show or set the output token cap (<n> | auto | default)',
50
+ /**
51
+ * Exists next to the settings screen for the reason `/theme` and `/plan` do:
52
+ * the slash palette is the discoverable surface, and this is one keystroke
53
+ * instead of five arrow presses.
54
+ *
55
+ * The no-argument branch reports the EFFECTIVE cap, not just the setting.
56
+ * "I set 64000" and "this model will produce 8192" are different facts, and
57
+ * only the second one explains a truncated answer.
58
+ */
59
+ run: (ctx) => {
60
+ const arg = ctx.args.trim();
61
+ const cfg = ctx.controller.getConfig();
62
+ if (!arg) {
63
+ const resolution = resolveOutputTokens({
64
+ providerId: cfg.provider,
65
+ modelId: cfg.model,
66
+ ...(cfg.maxTokens !== undefined ? { requested: cfg.maxTokens } : {}),
67
+ });
68
+ const setting = cfg.maxTokens === undefined ? 'auto' : String(cfg.maxTokens);
69
+ const why = resolution.clampedBy === 'ceiling' ? ` (${cfg.model} ceiling)` : '';
70
+ ctx.notify('info', `Max tokens: ${setting}. Effective for ${cfg.provider}:${cfg.model}: ` +
71
+ `${resolution.value}${why}.\nUse /max-tokens <n|auto|default>.`);
72
+ return;
73
+ }
74
+ const parsed = arg.toLowerCase() === 'default'
75
+ ? { kind: 'value', value: DEFAULT_MAX_TOKENS, clamped: false }
76
+ : parseMaxTokensInput(arg);
77
+ if (parsed.kind === 'invalid') {
78
+ // Change NOTHING on a typo: silently resolving it to a default would
79
+ // discard the number the user meant to type.
80
+ ctx.notify('warn', `Invalid value "${arg}" - use /max-tokens <n|auto|default>.`);
81
+ return;
82
+ }
83
+ if (parsed.kind === 'auto') {
84
+ ctx.controller.setMaxTokens(undefined);
85
+ ctx.persistConfig({ maxTokens: null });
86
+ ctx.toast('success', 'Max tokens: auto (per-model ceiling).');
87
+ return;
88
+ }
89
+ ctx.controller.setMaxTokens(parsed.value);
90
+ ctx.persistConfig({ maxTokens: parsed.value });
91
+ ctx.toast('success', `Max tokens set to ${parsed.value}.`);
92
+ },
93
+ },
46
94
  {
47
95
  name: 'theme',
48
96
  description: 'Switch the color theme (auto|warm|cool|light)',
@@ -135,6 +135,20 @@ function maskConfig(config) {
135
135
  }
136
136
  return { ...config, apiKeys };
137
137
  }
138
+ /**
139
+ * The human-readable label for a value whose raw form would mislead, or
140
+ * `undefined` to leave the caller's own formatting alone.
141
+ *
142
+ * `maxTokens: null` is AUTO, not "unset". Printing a bare `null` reads as a
143
+ * missing setting and sends the user looking for a bug that is not there.
144
+ * `get` and `list` format everything else differently (unquoted vs. JSON), so
145
+ * this deliberately overrides one case rather than replacing both renderers.
146
+ */
147
+ function displayOverride(key, value) {
148
+ if (key === 'maxTokens' && value === null)
149
+ return 'auto';
150
+ return undefined;
151
+ }
138
152
  /** Resolve a flat or one-level-dotted key against the persisted config. */
139
153
  function readKey(config, key) {
140
154
  const [head, tail] = key.split('.', 2);
@@ -153,10 +167,14 @@ export function runConfigGet(key) {
153
167
  process.exitCode = 2;
154
168
  return;
155
169
  }
156
- process.stdout.write(`${typeof value === 'string' ? value : JSON.stringify(value)}\n`);
170
+ const shown = displayOverride(key, value)
171
+ ?? (typeof value === 'string' ? value : JSON.stringify(value));
172
+ process.stdout.write(`${shown}\n`);
157
173
  }
158
174
  export function runConfigList(opts = {}) {
159
175
  const masked = maskConfig(loadPersistedConfig());
176
+ // `--json` stays RAW on purpose: it mirrors the file, and a consumer parsing
177
+ // it must see the `null` that is actually on disk.
160
178
  if (opts.json) {
161
179
  process.stdout.write(`${JSON.stringify(masked, null, 2)}\n`);
162
180
  return;
@@ -168,7 +186,7 @@ export function runConfigList(opts = {}) {
168
186
  }
169
187
  continue;
170
188
  }
171
- process.stdout.write(`${key} = ${JSON.stringify(value)}\n`);
189
+ process.stdout.write(`${key} = ${displayOverride(key, value) ?? JSON.stringify(value)}\n`);
172
190
  }
173
191
  }
174
192
  // ---------------------------------------------------------------------------
@@ -5,7 +5,8 @@
5
5
  * - Provider secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY /
6
6
  * GEMINI_API_KEY.
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
- * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
8
+ * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
9
+ * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
9
10
  * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
11
  *
11
12
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
@@ -5,7 +5,8 @@
5
5
  * - Provider secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY /
6
6
  * GEMINI_API_KEY.
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
- * ARAGON_MAX_TOKENS, ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
8
+ * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
9
+ * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
9
10
  * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
11
  *
11
12
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
@@ -18,7 +19,7 @@ import process from 'node:process';
18
19
  import dotenv from 'dotenv';
19
20
  import { registerSecretsFrom } from '../logging/secret-registry.js';
20
21
  import { clampLogLevel } from '../logging/levels.js';
21
- import { clampTheme, clampThinkingLevel, coerceMaxTokens, } from './schema.js';
22
+ import { clampMaxTokens, clampTheme, clampThinkingLevel, isAutoToken, } from './schema.js';
22
23
  /**
23
24
  * Load a project `.env` from the given directory into `process.env` (existing
24
25
  * process env wins — dotenv never overrides already-set variables).
@@ -58,9 +59,20 @@ export function readEnvConfig() {
58
59
  if (process.env.ARAGON_THINKING) {
59
60
  partial.thinkingLevel = clampThinkingLevel(process.env.ARAGON_THINKING.trim(), 'off');
60
61
  }
61
- if (process.env.ARAGON_MAX_TOKENS) {
62
- const mt = coerceMaxTokens(process.env.ARAGON_MAX_TOKENS);
63
- partial.maxTokens = mt ?? null;
62
+ // `auto` / `0` mean AUTO (`null`); a number is clamped to the accepted range.
63
+ // Anything else is LEFT ABSENT rather than written as `null`: a typo here used
64
+ // to become AUTO and silently discard the number in the config file under it,
65
+ // which is the opposite of what the user asked for.
66
+ const maxTokens = process.env.ARAGON_MAX_TOKENS?.trim();
67
+ if (maxTokens !== undefined && maxTokens.length > 0) {
68
+ if (isAutoToken(maxTokens)) {
69
+ partial.maxTokens = null;
70
+ }
71
+ else {
72
+ const clamped = clampMaxTokens(maxTokens);
73
+ if (clamped !== undefined)
74
+ partial.maxTokens = clamped;
75
+ }
64
76
  }
65
77
  if (process.env.ARAGON_THEME) {
66
78
  partial.theme = clampTheme(process.env.ARAGON_THEME.trim(), 'auto');
@@ -6,7 +6,7 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTeamConfig, clampTheme, clampThinkingLevel, clampTranscriptWindow, coerceMaxTokens, coercePositiveInt, isSkillsToolPolicyMode, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTeamConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
12
  import { getConfigPath } from './app-paths.js';
@@ -118,6 +118,44 @@ function pick(...vals) {
118
118
  }
119
119
  return undefined;
120
120
  }
121
+ function hasOwn(obj, key) {
122
+ return Object.prototype.hasOwnProperty.call(obj, key);
123
+ }
124
+ /**
125
+ * Resolve the output cap: flag › env › file › default, where an EXPLICIT `null`
126
+ * at any layer means AUTO (`undefined` — let the resolver pick per model).
127
+ *
128
+ * THIS CANNOT USE `pick()`, and that is the whole point. `pick` skips `null`, so
129
+ * a config file holding `"maxTokens": null` silently resolves to 64000 and AUTO
130
+ * is unreachable through every supported write path. Detecting it needs an
131
+ * own-property test, not a truthiness test — `null` is exactly the value we are
132
+ * looking for.
133
+ *
134
+ * An unparseable layer falls through to the next one rather than becoming AUTO:
135
+ * a typo in `ARAGON_MAX_TOKENS` must not silently discard the number in the
136
+ * config file underneath it.
137
+ */
138
+ function resolveMaxTokens(flags, env, file) {
139
+ const layers = [
140
+ { name: '--max-tokens', value: flags.maxTokens },
141
+ { name: 'ARAGON_MAX_TOKENS', value: hasOwn(env, 'maxTokens') ? env.maxTokens : undefined },
142
+ { name: 'config.json maxTokens', value: hasOwn(file, 'maxTokens') ? file.maxTokens : undefined },
143
+ ];
144
+ for (const layer of layers) {
145
+ if (layer.value === undefined)
146
+ continue; // this layer said nothing
147
+ if (layer.value === null || isAutoToken(layer.value))
148
+ return undefined; // AUTO
149
+ const n = clampMaxTokens(layer.value);
150
+ if (n !== undefined)
151
+ return n;
152
+ getLogger().warn('config', 'max_tokens_unusable', {
153
+ source: layer.name,
154
+ value: String(layer.value),
155
+ });
156
+ }
157
+ return DEFAULT_CONFIG.maxTokens ?? undefined;
158
+ }
121
159
  /**
122
160
  * Resolve the effective config by merging all layers, then derive the runtime
123
161
  * (non-persisted) fields and enforce the timeout invariant.
@@ -142,7 +180,7 @@ export function loadConfig(flags = {}) {
142
180
  const model = pick(flags.model, env.partial.model, file.model) ?? DEFAULT_CONFIG.model;
143
181
  const baseUrl = pick(flags.baseUrl, env.partial.baseUrl ?? undefined, file.baseUrl ?? undefined);
144
182
  const thinkingLevel = clampThinkingLevel(pick(flags.thinking, env.partial.thinkingLevel, file.thinkingLevel), DEFAULT_CONFIG.thinkingLevel);
145
- const maxTokens = coerceMaxTokens(pick(flags.maxTokens, env.partial.maxTokens, file.maxTokens));
183
+ const maxTokens = resolveMaxTokens(flags, env.partial, file);
146
184
  // `clampTheme` is also where the v0.3.0 `dark` name is migrated to `cool`,
147
185
  // so reading a legacy config file needs no extra step here (§4.5 / R-4).
148
186
  const theme = clampTheme(pick(flags.theme, env.partial.theme, file.theme), DEFAULT_CONFIG.theme);
@@ -274,6 +312,9 @@ function recordResolvedConfig(config) {
274
312
  model: config.model,
275
313
  baseUrl: config.baseUrl,
276
314
  thinkingLevel: config.thinkingLevel,
315
+ // Four layers feed this one and none of them used to leave a trace, so
316
+ // "why is my output truncated" was unanswerable after the fact.
317
+ maxTokens: config.maxTokens ?? 'auto',
277
318
  theme: config.theme,
278
319
  fullscreen: config.fullscreen,
279
320
  toolTimeoutMs: config.toolTimeoutMs,
@@ -5,7 +5,7 @@
5
5
  * See spec §3.5 (resolution pipeline), §6.1 (config file), and §3.8 (timeout
6
6
  * invariant).
7
7
  */
8
- import type { ThinkingLevel } from '@aragon-agent/core';
8
+ import { type ThinkingLevel } from '@aragon-agent/core';
9
9
  import { type LogLevelName } from '../logging/levels.js';
10
10
  /**
11
11
  * `ModelRegistry` seeds builtin lists for five providers, but `initProviders()`
@@ -245,6 +245,18 @@ export interface PersistedConfig {
245
245
  model: string;
246
246
  baseUrl: string | null;
247
247
  thinkingLevel: ThinkingLevel;
248
+ /**
249
+ * Output token cap.
250
+ *
251
+ * a number — an explicit cap, clamped to `[256, 200000]` on the way in AND
252
+ * on the way out, then clamped again at request time down to the
253
+ * target model's real ceiling when that ceiling is lower.
254
+ * `null` — AUTO: use each model's own ceiling, never above 64000.
255
+ * absent — the product default, 64000.
256
+ *
257
+ * `null` MEANS AUTO. It used to resolve to 64000 by accident (`pick()` skips
258
+ * `null`), which left the user no way to express "let the model decide".
259
+ */
248
260
  maxTokens: number | null;
249
261
  theme: ThemeName;
250
262
  /** Replace spinners with a static glyph for calmer, low-motion output. */
@@ -324,6 +336,7 @@ export interface CliConfig {
324
336
  model: string;
325
337
  baseUrl?: string;
326
338
  thinkingLevel: ThinkingLevel;
339
+ /** The resolved cap, or `undefined` for AUTO (per-model ceiling, ≤ 64000). */
327
340
  maxTokens?: number;
328
341
  theme: ThemeName;
329
342
  reducedMotion: boolean;
@@ -406,8 +419,57 @@ export interface CliConfig {
406
419
  /** One-shot `--api-key` override; applies to the active provider only. */
407
420
  apiKeyOverride?: string;
408
421
  }
409
- /** Coerce an arbitrary value into a positive integer, or `undefined`. */
422
+ /**
423
+ * Coerce an arbitrary value into a positive integer, or `undefined`.
424
+ *
425
+ * Kept because callers outside this feature still use it, and deliberately
426
+ * WITHOUT the range clamp so its behaviour for in-range values is byte-identical
427
+ * to what it always was. New code wants `clampMaxTokens`.
428
+ */
410
429
  export declare function coerceMaxTokens(v: unknown): number | undefined;
430
+ /**
431
+ * The accepted range for an explicit output cap.
432
+ *
433
+ * The floor is the engine's: below it a turn cannot produce a usable tool call.
434
+ * The ceiling is a sanity bound on a hand-edited file, not a model limit — the
435
+ * real per-model ceiling is applied later, by `resolveOutputTokens`.
436
+ */
437
+ export declare const MAX_TOKENS_RANGE: {
438
+ min: number;
439
+ max: number;
440
+ };
441
+ /** `auto`, an empty string and `0` all mean AUTO — "let the model decide". */
442
+ export declare function isAutoToken(v: unknown): boolean;
443
+ /**
444
+ * THE single gate for every `maxTokens` read AND write, in the shape
445
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` already use.
446
+ *
447
+ * Both directions on purpose: hardening only the read path leaves a bad value on
448
+ * disk that reverts to a default on every launch, which presents to the user as
449
+ * "my setting won't stick".
450
+ *
451
+ * Returns `undefined` for AUTO and for anything unusable — the caller decides
452
+ * which of the two it was, because they mean different things.
453
+ */
454
+ export declare function clampMaxTokens(v: unknown): number | undefined;
455
+ /**
456
+ * Parse text typed into the settings screen, a slash command, or `config set`.
457
+ *
458
+ * Distinguishes AUTO from "the user typed nonsense", which `clampMaxTokens`
459
+ * cannot: both come back as `undefined` there, and treating a typo as AUTO would
460
+ * silently discard the number the user meant to set.
461
+ */
462
+ export declare function parseMaxTokensInput(raw: string): {
463
+ kind: 'auto';
464
+ } | {
465
+ kind: 'value';
466
+ value: number;
467
+ clamped: boolean;
468
+ } | {
469
+ kind: 'invalid';
470
+ };
471
+ /** The product default, re-exported so the CLI never spells the number itself. */
472
+ export declare const DEFAULT_MAX_TOKENS = 64000;
411
473
  /** Coerce a value into a positive integer with a fallback (for timeouts). */
412
474
  export declare function coercePositiveInt(v: unknown, fallback: number): number;
413
475
  /**
@@ -6,6 +6,7 @@
6
6
  * invariant).
7
7
  */
8
8
  import { isAbsolute } from 'node:path';
9
+ import { ABSOLUTE_MAX_OUTPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS, } from '@aragon-agent/core';
9
10
  import { clampLogLevel } from '../logging/levels.js';
10
11
  import { TEAM_LIMITS } from '../team/limits.js';
11
12
  // ---------------------------------------------------------------------------
@@ -317,7 +318,9 @@ export const DEFAULT_CONFIG = {
317
318
  model: DEFAULT_MODEL,
318
319
  baseUrl: null,
319
320
  thinkingLevel: 'off',
320
- maxTokens: null,
321
+ // The engine owns this number; spelling it here again is how the CLI and the
322
+ // provider adapters end up disagreeing about what "the default" is.
323
+ maxTokens: DEFAULT_MAX_OUTPUT_TOKENS,
321
324
  theme: 'auto',
322
325
  reducedMotion: false,
323
326
  fullscreen: true,
@@ -376,7 +379,7 @@ export function stripLegacyStateKeys(raw) {
376
379
  // Coercion / validation helpers
377
380
  // ---------------------------------------------------------------------------
378
381
  /** Coerce an arbitrary value into a positive integer, or `undefined`. */
379
- export function coerceMaxTokens(v) {
382
+ function coerceTokenCount(v) {
380
383
  if (v === null || v === undefined || v === '')
381
384
  return undefined;
382
385
  const n = typeof v === 'number' ? v : Number.parseInt(String(v), 10);
@@ -384,6 +387,74 @@ export function coerceMaxTokens(v) {
384
387
  return undefined;
385
388
  return Math.floor(n);
386
389
  }
390
+ /**
391
+ * Coerce an arbitrary value into a positive integer, or `undefined`.
392
+ *
393
+ * Kept because callers outside this feature still use it, and deliberately
394
+ * WITHOUT the range clamp so its behaviour for in-range values is byte-identical
395
+ * to what it always was. New code wants `clampMaxTokens`.
396
+ */
397
+ export function coerceMaxTokens(v) {
398
+ return coerceTokenCount(v);
399
+ }
400
+ /**
401
+ * The accepted range for an explicit output cap.
402
+ *
403
+ * The floor is the engine's: below it a turn cannot produce a usable tool call.
404
+ * The ceiling is a sanity bound on a hand-edited file, not a model limit — the
405
+ * real per-model ceiling is applied later, by `resolveOutputTokens`.
406
+ */
407
+ export const MAX_TOKENS_RANGE = {
408
+ min: MIN_MAX_OUTPUT_TOKENS,
409
+ max: ABSOLUTE_MAX_OUTPUT_TOKENS,
410
+ };
411
+ /** `auto`, an empty string and `0` all mean AUTO — "let the model decide". */
412
+ export function isAutoToken(v) {
413
+ if (v === 0)
414
+ return true;
415
+ if (typeof v !== 'string')
416
+ return false;
417
+ const trimmed = v.trim().toLowerCase();
418
+ return trimmed === '' || trimmed === 'auto' || trimmed === '0';
419
+ }
420
+ /**
421
+ * THE single gate for every `maxTokens` read AND write, in the shape
422
+ * `clampSkillsConfig` / `clampLogConfig` / `clampTeamConfig` already use.
423
+ *
424
+ * Both directions on purpose: hardening only the read path leaves a bad value on
425
+ * disk that reverts to a default on every launch, which presents to the user as
426
+ * "my setting won't stick".
427
+ *
428
+ * Returns `undefined` for AUTO and for anything unusable — the caller decides
429
+ * which of the two it was, because they mean different things.
430
+ */
431
+ export function clampMaxTokens(v) {
432
+ const n = coerceTokenCount(v);
433
+ if (n === undefined)
434
+ return undefined;
435
+ return Math.min(MAX_TOKENS_RANGE.max, Math.max(MAX_TOKENS_RANGE.min, n));
436
+ }
437
+ /**
438
+ * Parse text typed into the settings screen, a slash command, or `config set`.
439
+ *
440
+ * Distinguishes AUTO from "the user typed nonsense", which `clampMaxTokens`
441
+ * cannot: both come back as `undefined` there, and treating a typo as AUTO would
442
+ * silently discard the number the user meant to set.
443
+ */
444
+ export function parseMaxTokensInput(raw) {
445
+ const trimmed = String(raw ?? '').trim();
446
+ if (isAutoToken(trimmed))
447
+ return { kind: 'auto' };
448
+ if (!/^\d+$/.test(trimmed))
449
+ return { kind: 'invalid' };
450
+ const parsed = Number.parseInt(trimmed, 10);
451
+ if (!Number.isFinite(parsed) || parsed <= 0)
452
+ return { kind: 'invalid' };
453
+ const value = Math.min(MAX_TOKENS_RANGE.max, Math.max(MAX_TOKENS_RANGE.min, parsed));
454
+ return { kind: 'value', value, clamped: value !== parsed };
455
+ }
456
+ /** The product default, re-exported so the CLI never spells the number itself. */
457
+ export const DEFAULT_MAX_TOKENS = DEFAULT_MAX_OUTPUT_TOKENS;
387
458
  /** Coerce a value into a positive integer with a fallback (for timeouts). */
388
459
  export function coercePositiveInt(v, fallback) {
389
460
  if (v === null || v === undefined || v === '')
@@ -14,7 +14,23 @@ import { dirname } from 'node:path';
14
14
  import process from 'node:process';
15
15
  import { getConfigPath, getSessionsDir } from './app-paths.js';
16
16
  import { registerSecretsFrom } from '../logging/secret-registry.js';
17
- import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampSkillsConfig, clampTeamConfig, stripLegacyStateKeys, } from './schema.js';
17
+ import { CONFIG_VERSION, DEFAULT_CONFIG, clampLogConfig, clampMaxTokens, clampSkillsConfig, clampTeamConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
18
+ /**
19
+ * Normalize `maxTokens` for both directions of the config file.
20
+ *
21
+ * `null` MEANS AUTO and must survive untouched; a number is clamped to the
22
+ * accepted range; anything else on a hand-edited file (a string, a float, a
23
+ * negative) becomes AUTO rather than a silent default. Same discipline as
24
+ * `clampSkillsConfig` — read AND write pass through one gate, or a bad value on
25
+ * disk reverts on every launch and presents as "my setting won't stick".
26
+ */
27
+ function normalizeMaxTokens(v) {
28
+ if (v === undefined)
29
+ return DEFAULT_CONFIG.maxTokens;
30
+ if (v === null || isAutoToken(v))
31
+ return null;
32
+ return clampMaxTokens(v) ?? null;
33
+ }
18
34
  // Re-exported so the many existing importers of `store.js` keep working; the
19
35
  // definitions live in `app-paths.ts` because they are pure path arithmetic.
20
36
  export { getConfigPath, getSessionsDir };
@@ -92,6 +108,12 @@ export function loadPersistedConfig() {
92
108
  ...DEFAULT_CONFIG,
93
109
  ...partial,
94
110
  version: CONFIG_VERSION,
111
+ // Own-property aware, because `null` is the value we need to preserve: a
112
+ // spread cannot tell "the key is absent" (⇒ the default) from "the key is
113
+ // null" (⇒ AUTO), and those are different settings.
114
+ maxTokens: Object.prototype.hasOwnProperty.call(partial, 'maxTokens')
115
+ ? normalizeMaxTokens(partial.maxTokens)
116
+ : DEFAULT_CONFIG.maxTokens,
95
117
  apiKeys: { ...DEFAULT_CONFIG.apiKeys, ...(partial.apiKeys ?? {}) },
96
118
  // `skills` is the second nested object in this file and needs the same
97
119
  // treatment `apiKeys` gets — see the note on updatePersistedConfig below.
@@ -131,6 +153,11 @@ export function updatePersistedConfig(patch) {
131
153
  ...current,
132
154
  ...patch,
133
155
  version: CONFIG_VERSION,
156
+ // The write half of the gate: without it `config set maxTokens 900000` puts
157
+ // a number on disk that the read path silently rewrites on every launch.
158
+ maxTokens: Object.prototype.hasOwnProperty.call(patch, 'maxTokens')
159
+ ? normalizeMaxTokens(patch.maxTokens)
160
+ : current.maxTokens,
134
161
  apiKeys: { ...current.apiKeys, ...(patch.apiKeys ?? {}) },
135
162
  skills: clampSkillsConfig({ ...current.skills, ...(patch.skills ?? {}) }),
136
163
  // Without this line, changing the log level from the settings screen would
package/dist/ui/App.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import React from 'react';
8
8
  import type { AgentController } from '../agent/controller.js';
9
9
  import type { ConfirmRequest } from '../tools/index.js';
10
- import { type Overlay } from '../agent/reducer.js';
10
+ import { type Overlay, type ToastLevel } from '../agent/reducer.js';
11
11
  import { type RenderMode } from './layout/frame.js';
12
12
  import { type FrameGeometry } from './layout/regions.js';
13
13
  import type { MouseSource } from '../input/stdin-mouse-filter.js';
@@ -15,6 +15,27 @@ import type { HumanInputBridge } from '../tools/human-input.js';
15
15
  export interface ConfirmBridge {
16
16
  handler: ((req: ConfirmRequest) => Promise<boolean>) | null;
17
17
  }
18
+ /** What the settings screen's `Max tokens` field resolved to. */
19
+ export interface SettingsMaxTokens {
20
+ /** Pushed into the live controller. `undefined` is AUTO. */
21
+ applied: number | undefined;
22
+ /** Written to the config file, or `'skip'` to leave the stored value alone. */
23
+ persist: number | null | 'skip';
24
+ /** Replaces the generic "Settings saved." acknowledgement when present. */
25
+ toast?: [ToastLevel, string];
26
+ }
27
+ /**
28
+ * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
29
+ * discard the rest of the form.
30
+ *
31
+ * The three outcomes are genuinely different and must stay distinguishable:
32
+ * `auto` is a setting, a clamped number is a setting the user should be told
33
+ * about, and nonsense is not a setting at all — it keeps the previous value and
34
+ * says so, rather than silently resolving to a default the user never chose.
35
+ *
36
+ * Exported for the tests; there is no other caller.
37
+ */
38
+ export declare function resolveSettingsMaxTokens(raw: string, previous: number | undefined): SettingsMaxTokens;
18
39
  export interface AppProps {
19
40
  controller: AgentController;
20
41
  version: string;
package/dist/ui/App.js CHANGED
@@ -11,7 +11,7 @@ import { Box, Text, useApp, useInput, useStdout } from 'ink';
11
11
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
12
12
  import { mergeDeltas } from '../agent/coalesce.js';
13
13
  import { computeCost } from '../agent/usage.js';
14
- import { coerceMaxTokens, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
14
+ import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
15
15
  import { updatePersistedConfig } from '../config/store.js';
16
16
  import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
17
17
  import { bumpSubmitCount, setMouseNoticeSeen } from '../config/ui-state.js';
@@ -50,6 +50,41 @@ import { CommandRegistry, runSlashInput } from '../commands/registry.js';
50
50
  import { registerBuiltinCommands } from '../commands/builtins.js';
51
51
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
52
52
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
53
+ /**
54
+ * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
55
+ * discard the rest of the form.
56
+ *
57
+ * The three outcomes are genuinely different and must stay distinguishable:
58
+ * `auto` is a setting, a clamped number is a setting the user should be told
59
+ * about, and nonsense is not a setting at all — it keeps the previous value and
60
+ * says so, rather than silently resolving to a default the user never chose.
61
+ *
62
+ * Exported for the tests; there is no other caller.
63
+ */
64
+ export function resolveSettingsMaxTokens(raw, previous) {
65
+ const parsed = parseMaxTokensInput(raw);
66
+ if (parsed.kind === 'auto') {
67
+ return {
68
+ applied: undefined,
69
+ persist: null,
70
+ toast: ['info', 'Max tokens: auto (per-model ceiling).'],
71
+ };
72
+ }
73
+ if (parsed.kind === 'value') {
74
+ return {
75
+ applied: parsed.value,
76
+ persist: parsed.value,
77
+ ...(parsed.clamped
78
+ ? { toast: ['warn', `Max tokens clamped to ${parsed.value}.`] }
79
+ : {}),
80
+ };
81
+ }
82
+ return {
83
+ applied: previous,
84
+ persist: 'skip',
85
+ toast: ['warn', 'Max tokens must be a number or "auto".'],
86
+ };
87
+ }
53
88
  /** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
54
89
  const COALESCE_MS = 33;
55
90
  export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }) {
@@ -518,10 +553,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
518
553
  submitMessage(raw.startsWith('//') ? raw.slice(1) : raw);
519
554
  };
520
555
  const handleSettingsSave = (values) => {
521
- const maxTokens = coerceMaxTokens(values.maxTokens);
556
+ // An unusable cap must NOT discard the rest of the form: the same Enter
557
+ // press is often carrying a freshly pasted API key, and losing that to a
558
+ // typo in an unrelated field is the worst outcome available here.
559
+ const cap = resolveSettingsMaxTokens(values.maxTokens, controller.getConfig().maxTokens);
522
560
  controller.setModel(values.provider, values.model, values.baseUrl || undefined);
523
561
  controller.setThinkingLevel(values.thinkingLevel);
524
- controller.setMaxTokens(maxTokens);
562
+ controller.setMaxTokens(cap.applied);
525
563
  const key = values.apiKey.trim();
526
564
  if (key.length > 0) {
527
565
  // Registration site 4 (§4.4.3), and the one that matters most: this is how
@@ -536,7 +574,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
536
574
  model: values.model,
537
575
  baseUrl: values.baseUrl.trim() ? values.baseUrl.trim() : null,
538
576
  thinkingLevel: values.thinkingLevel,
539
- maxTokens: maxTokens ?? null,
577
+ // Omitted entirely when the field was unusable, so the previous value
578
+ // survives instead of being overwritten with a guess.
579
+ ...(cap.persist !== 'skip' ? { maxTokens: cap.persist } : {}),
540
580
  };
541
581
  if (key.length > 0)
542
582
  patch.apiKeys = { [values.provider]: key };
@@ -547,7 +587,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
547
587
  persistConfig(patch);
548
588
  getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
549
589
  dispatch({ type: 'setOverlay', overlay: null });
550
- toast('success', 'Settings saved.');
590
+ toast(cap.toast?.[0] ?? 'success', cap.toast?.[1] ?? 'Settings saved.');
551
591
  };
552
592
  const handleModelSelect = (provider, model) => {
553
593
  controller.setModel(provider, model);
@@ -767,7 +807,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
767
807
  model: cfg.model,
768
808
  baseUrl: cfg.baseUrl ?? '',
769
809
  thinkingLevel: cfg.thinkingLevel,
770
- maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : '',
810
+ // `auto`, not an empty string: an empty field reads as "unset", which
811
+ // is indistinguishable from AUTO and is not a state this can be in.
812
+ maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : 'auto',
771
813
  apiKey: '',
772
814
  logLevel: cfg.log.level,
773
815
  }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
@@ -25,6 +25,7 @@ const COMMANDS = [
25
25
  ['/plan [on|off|status]', 'Toggle plan mode (same as Shift+Tab)'],
26
26
  ['/theme <name>', 'auto | warm | cool | light'],
27
27
  ['/thinking <level>', 'Set thinking level'],
28
+ ['/max-tokens [n|auto]', 'Output token cap; no argument reports the effective one'],
28
29
  ['/team [on|off|max n]', 'Team subagents: status, switch, fan-out width'],
29
30
  ['/tools', 'List active tools'],
30
31
  ['/clear', 'Clear the visible transcript'],
@@ -8,7 +8,7 @@
8
8
  * otherwise.
9
9
  */
10
10
  import React from 'react';
11
- import type { ThinkingLevel } from '@aragon-agent/core';
11
+ import { type ThinkingLevel } from '@aragon-agent/core';
12
12
  import { type LogLevelName } from '../../logging/levels.js';
13
13
  import type { Theme } from '../theme.js';
14
14
  import type { TermCapabilities } from '../capabilities.js';
@@ -40,5 +40,16 @@ interface SettingsScreenProps {
40
40
  caps: TermCapabilities;
41
41
  onSave: (values: SettingsValues) => void;
42
42
  }
43
+ /**
44
+ * The cap this setting will ACTUALLY produce against the selected model.
45
+ *
46
+ * Exported for the tests, and computed on every render rather than in an effect:
47
+ * `resolveOutputTokens` is pure, allocation-light and does no I/O — the ceiling
48
+ * lookup is a `Map.get` plus at most seven regex tests.
49
+ *
50
+ * Without this row the settings screen can only show what the user typed, and
51
+ * "I set 64000 but answers still stop early" has no answer anywhere in the UI.
52
+ */
53
+ export declare function effectiveCapLine(values: Pick<SettingsValues, 'provider' | 'model' | 'maxTokens'>): string;
43
54
  export declare function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onSave, }: SettingsScreenProps): React.ReactElement;
44
55
  export {};
@@ -10,7 +10,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  */
11
11
  import { useState } from 'react';
12
12
  import { Text, useInput } from 'ink';
13
- import { ADAPTER_PROVIDERS, THINKING_LEVELS, maskSecret } from '../../config/schema.js';
13
+ import { DEFAULT_MAX_OUTPUT_TOKENS, resolveOutputTokens, } from '@aragon-agent/core';
14
+ import { ADAPTER_PROVIDERS, THINKING_LEVELS, maskSecret, parseMaxTokensInput, } from '../../config/schema.js';
14
15
  import { LOG_LEVEL_NAMES } from '../../logging/levels.js';
15
16
  import { pickGlyphs } from '../glyphs.js';
16
17
  import { OverlayFrame } from '../layout/OverlayFrame.js';
@@ -23,6 +24,34 @@ const FIELDS = [
23
24
  { key: 'apiKey', label: 'API key', kind: 'secret' },
24
25
  { key: 'logLevel', label: 'Log level', kind: 'enum', options: LOG_LEVEL_NAMES },
25
26
  ];
27
+ /**
28
+ * The cap this setting will ACTUALLY produce against the selected model.
29
+ *
30
+ * Exported for the tests, and computed on every render rather than in an effect:
31
+ * `resolveOutputTokens` is pure, allocation-light and does no I/O — the ceiling
32
+ * lookup is a `Map.get` plus at most seven regex tests.
33
+ *
34
+ * Without this row the settings screen can only show what the user typed, and
35
+ * "I set 64000 but answers still stop early" has no answer anywhere in the UI.
36
+ */
37
+ export function effectiveCapLine(values) {
38
+ const parsed = parseMaxTokensInput(values.maxTokens);
39
+ if (parsed.kind === 'invalid')
40
+ return 'Effective: - (not a number; use a value or "auto")';
41
+ const requested = parsed.kind === 'value' ? parsed.value : undefined;
42
+ const resolution = resolveOutputTokens({
43
+ providerId: values.provider,
44
+ modelId: values.model,
45
+ ...(requested !== undefined ? { requested } : {}),
46
+ });
47
+ if (resolution.clampedBy === 'ceiling') {
48
+ const ambition = requested ?? DEFAULT_MAX_OUTPUT_TOKENS;
49
+ return `Effective: ${resolution.value} (clamped from ${ambition} by the ${values.model} ceiling)`;
50
+ }
51
+ if (requested === undefined)
52
+ return `Effective: ${resolution.value} (auto)`;
53
+ return `Effective: ${resolution.value}`;
54
+ }
26
55
  export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onSave, }) {
27
56
  const [values, setValues] = useState(initial);
28
57
  const [index, setIndex] = useState(0);
@@ -99,6 +128,10 @@ export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset,
99
128
  }
100
129
  if (f.kind === 'enum')
101
130
  return raw;
131
+ // An empty cap field is AUTO, not "unset". `(none)` would read as a missing
132
+ // setting, which is the one thing this field can never be.
133
+ if (f.key === 'maxTokens' && raw.length === 0)
134
+ return 'auto';
102
135
  return raw.length > 0 ? raw : selected ? '(type to set)' : '(none)';
103
136
  };
104
137
  // One element per row, each `wrap="truncate"` — the contract for OverlayFrame's
@@ -108,6 +141,10 @@ export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset,
108
141
  const selected = i === index;
109
142
  return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: selected ? theme.accent : theme.muted, children: selected ? `${glyphs.caret} ` : ' ' }), _jsx(Text, { color: selected ? theme.assistant : theme.muted, children: (f.key === 'apiKey' ? `API key (${values.provider})` : f.label).padEnd(18) }), _jsx(Text, { color: selected ? theme.primary : theme.muted, children: displayValue(f, selected) }), f.kind === 'enum' && selected && (_jsxs(Text, { color: theme.muted, children: [' (', glyphs.arrowLeft, "/", glyphs.arrowRight, ')'] }))] }, f.key));
110
143
  });
144
+ // One element, `wrap="truncate"`, pushed like every other row — OverlayFrame's
145
+ // controlled mode slices by ELEMENT, so a row that wraps desynchronises the
146
+ // position indicator.
147
+ rows.push(_jsx(Text, { wrap: "truncate", color: theme.muted, children: effectiveCapLine(values) }, "__effective"));
111
148
  rows.push(_jsx(Text, { wrap: "truncate", color: theme.muted, children: "Enter saves. Keys persist to the config file (0600)." }, "__save"));
112
149
  return (_jsx(OverlayFrame, { title: "Settings", hint: `${glyphs.arrowUp}${glyphs.arrowDown} field ${glyphs.midDot} ${glyphs.arrowLeft}${glyphs.arrowRight} change ${glyphs.midDot} Enter save ${glyphs.midDot} Esc close`, maxRows: maxRows, cols: cols, rows: rows, scrollOffset: scrollOffset, onScrollClamp: onScrollClamp, theme: theme, caps: caps }));
113
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -51,7 +51,7 @@
51
51
  "prepublishOnly": "npm run build"
52
52
  },
53
53
  "dependencies": {
54
- "@aragon-agent/core": "^0.2.3",
54
+ "@aragon-agent/core": "^0.2.4",
55
55
  "chalk": "^5.6.2",
56
56
  "cli-highlight": "^2.0.0",
57
57
  "commander": "^12.1.0",