aegis-desktop 0.8.35 → 1.0.0

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/README.md CHANGED
@@ -101,6 +101,17 @@ the models that key unlocks appear as their own entries, one per provider.
101
101
  startup, and a provider with no key saved in Settings is resolved from it.
102
102
  3. Select the **Bring your own key** class and pick a model.
103
103
 
104
+ **A model the catalogue does not name yet.** Vendors ship models faster than any
105
+ client release, and the relay forwards whatever model string it is handed — so
106
+ Settings ends with an **Add model** row (provider + model id + *Add model*). It
107
+ writes the same `~/.aegiscode/models.json` the CLI's `/model-add` writes, so a
108
+ model added here appears in the terminal's `/models` immediately and one added
109
+ there appears in this picker on its next refresh; the row prints the file path,
110
+ which is also the bulk-edit path. The provider id is offered from the live
111
+ catalogue but you may type one, and a provider the relay takes no key for is
112
+ refused rather than stored (a declaration nothing can attach a key to would show
113
+ up in no picker at all). Each declared model gets a **Remove** row.
114
+
104
115
  **What it costs.** AEGIS pays your provider nothing on this lane, so there is no
105
116
  provider cost to take a margin on — instead your AEGIS account is charged a flat
106
117
  **handling fee** per 1k tokens, for the routing, prompt assembly, caching, tool
package/lib/deep-link.js CHANGED
@@ -14,6 +14,36 @@
14
14
  const PROTOCOL = 'aegis';
15
15
  const SCHEME_PREFIX = `${PROTOCOL}://`;
16
16
 
17
+ /**
18
+ * Ceilings on the two values a *remote* caller controls.
19
+ *
20
+ * Any web page or any other app on the machine can hand the OS an `aegis://`
21
+ * URL, so both fields are attacker-chosen input on a channel nobody authenticates
22
+ * (there is no "is this link from the user?" check, and there cannot be one for
23
+ * a custom scheme). Bounding them keeps a hostile link from parking megabytes of
24
+ * text in the composer, from filling the window with a wall of pre-filled prompt,
25
+ * or from using the deep link as a bulk data channel into a session that can run
26
+ * tools. The renderer pre-fills rather than auto-sends (renderer/app.js
27
+ * handleDeepLink), so the cost is bounded, but a pre-filled prompt is still the
28
+ * most direct route from "a page I visited" to "text the user is invited to send
29
+ * to a model with tools on".
30
+ *
31
+ * These are generous on purpose: a real hand-written link is a sentence or two.
32
+ */
33
+ const MAX_PROMPT_LENGTH = 2000;
34
+ /** Session ids are uuids (36 chars); the slack is for a future format, not a payload. */
35
+ const MAX_SESSION_ID_LENGTH = 128;
36
+ /**
37
+ * …and the charset is pinned too, because this id does not stay a string: the
38
+ * renderer's `open` handler hands it to openSession(), which loads the session
39
+ * by name. A hostile link could otherwise offer `../../…` or an absolute path
40
+ * and have the *renderer* ask main to open whatever that resolves to. Ids this
41
+ * app mints are `[0-9a-f-]`; anything outside a conservative id alphabet is not
42
+ * one of ours, so it is rejected rather than escaped or repaired.
43
+ */
44
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
45
+
46
+
17
47
  /** True for any string that looks like our protocol, cheap enough to filter
18
48
  * argv with before the full URL parse (which throws on most argv entries —
19
49
  * flags, file paths — so callers should not run it against every arg). */
@@ -44,11 +74,15 @@ function parseDeepLinkUrl(url) {
44
74
  if (action === 'open') {
45
75
  const sessionId = parsed.searchParams.get('session');
46
76
  if (!sessionId) return null;
77
+ if (sessionId.length > MAX_SESSION_ID_LENGTH) return null;
78
+ if (!SESSION_ID_PATTERN.test(sessionId)) return null;
47
79
  return { action: 'open', sessionId };
48
80
  }
49
81
 
50
82
  if (action === 'new') {
51
- return { action: 'new', prompt: parsed.searchParams.get('prompt') || '' };
83
+ const prompt = parsed.searchParams.get('prompt') || '';
84
+ if (prompt.length > MAX_PROMPT_LENGTH) return null;
85
+ return { action: 'new', prompt };
52
86
  }
53
87
 
54
88
  return null;
@@ -73,6 +73,83 @@ const MAX_ROUND_STOPS = 3;
73
73
  */
74
74
  const WRITE_TOOLS = new Set(['writeFile', 'editFile']);
75
75
 
76
+ // ── Auto mode and the round horizon ─────────────────────────────────────────
77
+ //
78
+ // The two halves of "auto mode" the upstream engine (aegiscodex-dev) defines
79
+ // are independent, and a host that wires only one of them is *not* running auto
80
+ // mode — it is running "no approval cards", which is half the feature and all
81
+ // the friction:
82
+ //
83
+ // 1. no approval keystroke per mutating tool call;
84
+ // 2. a turn that hits its round horizon is EXTENDED silently a few times
85
+ // before a human is asked.
86
+ //
87
+ // (1) is the posture (the desktop's Tool-approvals switch, the CLI's
88
+ // /automode); (2) needs the arithmetic below, and the plugin's Electron host
89
+ // had none of it — with auto mode ON, every horizon hit still raised the
90
+ // blocking round card, which is precisely the question auto mode exists to stop
91
+ // asking ("12 rounds — keep going?" answered the same way every time).
92
+ //
93
+ // Ported verbatim from the engine's src/autonomous.js (DEFAULT_AUTO_CONTINUES,
94
+ // autoContinueLimit, autoRoundsDefault, autoContinuePlan) rather than vendored,
95
+ // because this repo hosts no build of that ESM module — but with the SAME
96
+ // numbers and the SAME env knobs, so a turn behaves identically in either
97
+ // client. cli/src/chatflow.js carries the third copy, which is what the CLI
98
+ // presenter uses to answer the ask.
99
+
100
+ /** Automatic continuations an attended turn may take while auto mode is on. */
101
+ const DEFAULT_AUTO_CONTINUES = 3;
102
+
103
+ /**
104
+ * The auto-continue allowance, honouring AEGIS_AUTO_CONTINUE_MAX.
105
+ *
106
+ * Bounded and pure, and a garbage value falls back to the default rather than
107
+ * to 0: an unparseable env var must not silently switch auto mode off, because
108
+ * the visible symptom of that ("why is it asking me again?") points nowhere
109
+ * near the environment.
110
+ */
111
+ function autoContinueLimit(env = process.env) {
112
+ const n = Number.parseInt(env && env.AEGIS_AUTO_CONTINUE_MAX, 10);
113
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_AUTO_CONTINUES;
114
+ }
115
+
116
+ /**
117
+ * Values of AEGIS_AUTO_ROUNDS that mean "start in manual mode". Anything else —
118
+ * unset, empty, a typo like `flase` — leaves auto mode's default in place, so a
119
+ * misspelt opt-out cannot silently disable the mode the operator asked for.
120
+ * Same set, same reasoning, as the engine's AUTO_ROUNDS_OFF.
121
+ */
122
+ const AUTO_ROUNDS_OFF = new Set(['0', 'false', 'off', 'no']);
123
+
124
+ /** False only when AEGIS_AUTO_ROUNDS explicitly says so. */
125
+ function autoRoundsDefault(env = process.env) {
126
+ const raw = env && env.AEGIS_AUTO_ROUNDS;
127
+ if (raw === undefined || raw === null) return true;
128
+ const val = String(raw).trim().toLowerCase();
129
+ if (!val) return true;
130
+ return !AUTO_ROUNDS_OFF.has(val);
131
+ }
132
+
133
+ /**
134
+ * Decide what one round-horizon ask becomes while auto mode is on.
135
+ *
136
+ * Pure, because this is budget arithmetic on a keystroke-less path: the caller
137
+ * only renders the answer and must not be trusted to keep the count. `used` is
138
+ * the running tally the caller holds and echoes back, so an ask that IS shown
139
+ * to the human resets it — the human was asked, so the next block of automatic
140
+ * continuations starts clean.
141
+ *
142
+ * { auto: true, used: n } → answer the ask "yes" without a dialog
143
+ * { auto: false, used: n } → show the real ask (allowance spent, or off)
144
+ */
145
+ function autoContinuePlan({ on = false, used = 0, limit = DEFAULT_AUTO_CONTINUES } = {}) {
146
+ const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : DEFAULT_AUTO_CONTINUES;
147
+ const spent = Number.isFinite(used) && used > 0 ? Math.floor(used) : 0;
148
+ if (!on) return { auto: false, used: spent, cap };
149
+ if (spent >= cap) return { auto: false, used: 0, cap };
150
+ return { auto: true, used: spent + 1, cap };
151
+ }
152
+
76
153
  /** The path a file-writing tool call names, in either spelling the engine uses. */
77
154
  function writtenPath(tool) {
78
155
  const args = (tool && tool.args) || {};
@@ -906,6 +983,10 @@ module.exports = {
906
983
  isAutonomousRequest,
907
984
  maxRounds,
908
985
  withRoundHorizon,
986
+ DEFAULT_AUTO_CONTINUES,
987
+ autoContinueLimit,
988
+ autoRoundsDefault,
989
+ autoContinuePlan,
909
990
  resolveModel,
910
991
  ignoredEnvModel,
911
992
  resolveEffort,
@@ -305,6 +305,36 @@ function requireSharedByokCatalog() {
305
305
  throw last;
306
306
  }
307
307
 
308
+ /**
309
+ * The models the USER declares (`client/user-models.js`) — resolved exactly as
310
+ * the two modules above, and required here for the same reason: it is the ONE
311
+ * store the CLI's `/model-add` and this window's Settings row both write, so a
312
+ * model added in the terminal is a row in this picker and vice versa. A second
313
+ * implementation would be a second file, and "the two hosts show one account
314
+ * two different model lists" is the defect class this resolution rule exists
315
+ * to end.
316
+ *
317
+ * Unlike the two catalog modules this one touches the disk (it reads
318
+ * `$AEGISCODE_HOME/models.json`), which is why it is required through the
319
+ * staged-tree fallback like any other shared module — the packaged app has no
320
+ * `client/` beside it.
321
+ */
322
+ function requireSharedUserModels() {
323
+ const candidates = [
324
+ () => require('../../../client/user-models.js'),
325
+ () => require('../../vendor/user-models.js'),
326
+ ];
327
+ let last = null;
328
+ for (const load of candidates) {
329
+ try {
330
+ return load();
331
+ } catch (err) {
332
+ last = err;
333
+ }
334
+ }
335
+ throw last;
336
+ }
337
+
308
338
  /**
309
339
  * How long a turn waits for the working-tree lock before running anyway.
310
340
  *
@@ -578,6 +608,12 @@ const { offerableCatalog } = requireSharedBrain();
578
608
  * provider row the other does not.
579
609
  */
580
610
  const { withByokAdditions } = requireSharedByokCatalog();
611
+ const {
612
+ withUserModels,
613
+ load: loadUserModels,
614
+ addUserModel,
615
+ removeUserModel,
616
+ } = requireSharedUserModels();
581
617
 
582
618
  // ── Agent-loop helpers ──────────────────────────────────────────────────────
583
619
 
@@ -652,6 +688,91 @@ function isTruncated(res) {
652
688
  return reason === 'length' || reason === 'max_tokens';
653
689
  }
654
690
 
691
+ /**
692
+ * The hidden chain-of-thought a completion carried, read from every wire shape
693
+ * that reports one. DeepSeek returns `message.reasoning_content`, OpenAI's
694
+ * o-series/gpt-5 shape returns `message.reasoning`, and the Anthropic parser
695
+ * folds thinking into content blocks. All three are the same thing here: tokens
696
+ * the model spent out of the same budget as its answer.
697
+ */
698
+ function reasoningTextOf(res) {
699
+ if (!res) return '';
700
+ const choice = res.choices && res.choices[0];
701
+ const msg = (choice && choice.message) || {};
702
+ if (Array.isArray(msg.content)) {
703
+ const blocks = msg.content
704
+ .filter((b) => b && (b.type === 'thinking' || b.type === 'reasoning'))
705
+ .map((b) => b.thinking || b.text || '')
706
+ .join('');
707
+ if (blocks) return blocks;
708
+ }
709
+ const fromContent = msg.content && typeof msg.content === 'object' ? msg.content.reasoning_content : '';
710
+ const candidates = [
711
+ msg.reasoning_content,
712
+ msg.reasoning,
713
+ choice && choice.reasoning_content,
714
+ res.reasoning_content,
715
+ res.reasoning,
716
+ fromContent,
717
+ ];
718
+ for (const c of candidates) if (typeof c === 'string' && c) return c;
719
+ return '';
720
+ }
721
+
722
+ /**
723
+ * The empty turn `isTruncated()` cannot see, and the reason this error survived
724
+ * a truncation retry that was already in place: a reasoning model that spends
725
+ * the budget on hidden chain-of-thought and then finishes with NO visible text
726
+ * still reports `finish_reason: 'stop'` — the provider considers the call
727
+ * complete, because to it the model did stop, it just stopped while thinking.
728
+ * Measured live on byok/deepseek-v4-flash: `stop` + empty content + non-empty
729
+ * `reasoning_content`, which fell straight through both retry gates to
730
+ * emptyTurnError. Reasoning tokens and answer tokens share one budget, so the
731
+ * recovery is identical to a 'length' cut: buy room and ask once more.
732
+ */
733
+ function reasonedButSilent(res) {
734
+ return !assistantText(res) && reasoningTextOf(res) !== '';
735
+ }
736
+
737
+ /**
738
+ * The rung a reasoning-only retry is raised to. Every reasoning retry is paid
739
+ * for out of the same budget twice — the chain-of-thought is paid for again
740
+ * before a single visible word can be written — so a rung that merely doubled
741
+ * (the 8k floor -> 16k) still finished silent: the floor is what an unstated
742
+ * budget yields, and it is the rung the live failure had already exhausted.
743
+ */
744
+ const REASONING_RETRY_FLOOR = 32768;
745
+
746
+ /**
747
+ * The one budget decision every empty-turn retry makes, in one place: is the
748
+ * number we hold ours (a rung we derived, which a retry may raise) or the
749
+ * caller's (a deliberate cap the retry may only double)?
750
+ *
751
+ * It matters exactly here. The floor above is a floor on OUR rung; applying it
752
+ * to a stated cap would turn the flow lane's deliberate 1024-token ceiling into
753
+ * 32768 because the model happened to think out loud — the same "a guessed
754
+ * number goes on the wire as a ceiling" bug statedCapOf() exists to prevent.
755
+ */
756
+ function retryBudgetFor(opts, reasoningOnly) {
757
+ const doubled = doubledBudget(opts.maxTokens);
758
+ if (!reasoningOnly) return doubled;
759
+ // Which number is this? A budget that is one of EFFORT_TOKEN_BUDGET's own
760
+ // rungs (8192/16384/32768) is the rung this call was sized at, so a reasoning
761
+ // retry may raise it to the top of the ladder — that is the live failure this
762
+ // exists for: 16384 burned on hidden chain-of-thought, finish_reason 'stop',
763
+ // empty content. Any OTHER number belongs to someone who chose it (the flow
764
+ // lane's deliberate 1024, a caller's own 4096), and doubling is as far as a
765
+ // retry may go: promoting it by an order of magnitude would put a ceiling the
766
+ // user never asked for on the wire.
767
+ return ladderRung(opts.maxTokens) ? Math.max(doubled, REASONING_RETRY_FLOOR) : doubled;
768
+ }
769
+
770
+ /** Is this budget one of the effort ladder's own rungs (a size WE derived)? */
771
+ function ladderRung(maxTokens) {
772
+ const n = Number(maxTokens);
773
+ return Number.isFinite(n) && Object.values(EFFORT_TOKEN_BUDGET).includes(n);
774
+ }
775
+
655
776
  /**
656
777
  * Double a budget for the one-shot truncation retry. A budget the caller set
657
778
  * on purpose (the flow lane's deliberate 1024-token cap, say) is merely
@@ -701,6 +822,62 @@ const EMPTY_TURN_NUDGE =
701
822
  'No tools are available in this reply, so do not call any: respond in plain ' +
702
823
  'prose or markdown.';
703
824
 
825
+ /**
826
+ * The nudge for an empty round that was NOT really empty: the model spent it
827
+ * thinking. A reasoning model that burns its budget on hidden chain-of-thought
828
+ * and stops without writing a visible word (`finish_reason: 'stop'`, empty
829
+ * content, non-empty `reasoning_content`) is the normal shape of this failure on
830
+ * DeepSeek, and the generic nudge above does not describe it — "write your answer
831
+ * now" invites the same reasoning pass a second time, because nothing told the
832
+ * model that the thinking was the problem.
833
+ *
834
+ * Naming the failure IS the fix, and it is the upstream engine's own last resort
835
+ * (`src/backend.js`, the `thoughtOnly` recovery round). Buying budget is the
836
+ * other lever, already tried below; when that has failed the budget is not what
837
+ * is wrong, so the ask changes instead: answer WITHOUT thinking first.
838
+ */
839
+ const THOUGHT_ONLY_NUDGE =
840
+ 'Your previous round was spent entirely on hidden reasoning and produced no visible reply. ' +
841
+ 'Answer the user directly and briefly now. Do not reason, explain, or think first — write only ' +
842
+ 'the answer, no tools.';
843
+
844
+ /**
845
+ * The last ask of the recovery ladder, for a turn that stayed silent even after
846
+ * being told not to think first.
847
+ *
848
+ * A model that is going to reason anyway will reason whatever the nudge says, so
849
+ * the final round stops asking it not to: the ask becomes "say anything usable
850
+ * about what you already have", which a model that has demonstrably read the
851
+ * context can always satisfy. Producing a slightly thin write-up is strictly
852
+ * better than the alternative — the transcript keeps the answer, and the user
853
+ * can ask for detail on the one point that was skimped, in a turn where the
854
+ * material is already in context.
855
+ */
856
+ const FINAL_ANSWER_NUDGE =
857
+ 'Write your answer now, in plain prose, using only what is already in this conversation. ' +
858
+ 'If your information is incomplete, say what you have and what is missing — an incomplete ' +
859
+ 'answer is the expected outcome here and is better than none. No tools, no preface.';
860
+
861
+ /**
862
+ * How many EXTRA calls an empty write-up round is worth before the turn is
863
+ * declared failed.
864
+ *
865
+ * The upstream engine spends additional calls on a silent round rather than
866
+ * telling the reader to go and change a setting (`src/backend.js`'s thoughtOnly
867
+ * recovery), and this is the same trade, made explicit because the trade is the
868
+ * whole point of the number: an extra dispatch costs tokens, a failed turn costs
869
+ * the entire investigation the tool log on screen paid for. Two calls — one
870
+ * after the budget-retry above, one after the ask changes — is where the curve
871
+ * flattens: measured on byok/deepseek-v4-flash, a round that reasons and stays
872
+ * silent either answers on the first changed ask or is not going to answer at
873
+ * all, because the failure is the model's habit and not the wording.
874
+ *
875
+ * Crucially, every one of these rounds runs with `tools: []` on `synthesisOpts`:
876
+ * they re-ask for the write-up out of a context that already holds the findings,
877
+ * so "more calls" can never mean "do the investigation again".
878
+ */
879
+ const RECOVERY_ROUNDS = 2;
880
+
704
881
  /**
705
882
  * Last resort for a turn that is still empty after the synthesis pass.
706
883
  *
@@ -713,12 +890,29 @@ const EMPTY_TURN_NUDGE =
713
890
  * real tool log on screen, keeps the transcript honest, and names the cause
714
891
  * (renderer prints `Error: <message>`).
715
892
  */
716
- function emptyTurnError({ cls, model, maxTokens, finishReason }) {
893
+ function emptyTurnError({ cls, model, finishReason, reasoningChars, effort, recoveryRounds }) {
894
+ const calls = Number(recoveryRounds) || 0;
717
895
  const err = new Error(
718
- `The model returned no answer after ${cls}/${model} was asked to summarise its results ` +
719
- `(stop reason: ${finishReason || 'none'}, max_tokens: ${maxTokens || 'unstated'}). The token budget ` +
720
- 'was most likely consumed before any visible text — raise the Effort rung, which is ' +
721
- 'what sizes this call, or ask for a smaller piece of work.'
896
+ `The model returned no answer after ${cls}/${model} was asked to summarise its results` +
897
+ (calls
898
+ ? `, and ${calls} extra recovery call${calls === 1 ? '' : 's'} with a larger budget ` +
899
+ 'and a changed ask came back empty too'
900
+ : '') +
901
+ ` (stop reason: ${finishReason || 'none'}` +
902
+ `${effort ? `, effort: ${effort}` : ''}` +
903
+ `${reasoningChars ? `, hidden reasoning: ${reasoningChars} chars and no visible text` : ''}). ` +
904
+ (reasoningChars
905
+ ? // Deliberately NOT "raise the Effort rung". The budget was already taken
906
+ // out of the equation before this error could be reached: the recovery
907
+ // calls above ran at the top of the ladder, so pointing at the ceiling
908
+ // would send the reader to change the one setting that is already
909
+ // maxed out. The unchanged thing is the model's habit of answering
910
+ // inside its head, and that is what gets named.
911
+ 'Every one of those calls spent its output on hidden reasoning instead of writing a ' +
912
+ 'word, so this is the model rather than the budget: pick a model that answers without ' +
913
+ 'a thinking pass, or ask for a smaller piece of work.'
914
+ : 'No visible text came back from any of those calls. Send the request again, in smaller ' +
915
+ 'pieces, or from a different model.')
722
916
  );
723
917
  err.status = 502;
724
918
  return err;
@@ -1207,7 +1401,7 @@ function createLocalEngine({
1207
1401
  // of `providers` — the model list built just below, the renderer's
1208
1402
  // per-provider Settings rows, and the CLI's own `/byok` listing when it
1209
1403
  // runs on this engine — sees one list instead of three opinions.
1210
- providers = withByokAdditions((data && data.providers) || []);
1404
+ providers = withUserModels(withByokAdditions((data && data.providers) || []));
1211
1405
  // The handling fee AEGIS adds on top of the caller's vendor bill. It is
1212
1406
  // the server's own published rate (services/pricing.price_byok_call) and
1213
1407
  // is passed through untouched — never re-derived here, because a client
@@ -2032,8 +2226,9 @@ function createLocalEngine({
2032
2226
  // double up in the renderer's live view. When text *has* arrived the
2033
2227
  // turn is not empty — the answer is merely truncated — and a retry
2034
2228
  // would stream it a second time onto the same bubble.
2035
- if (!truncationRetried && !assistantText(res) && isTruncated(res)) {
2229
+ if (!truncationRetried && !assistantText(res) && (isTruncated(res) || reasonedButSilent(res))) {
2036
2230
  truncationRetried = true;
2231
+ const reasoningOnly = reasonedButSilent(res);
2037
2232
  // singlePass: this retry buys *budget*, not a second investigation.
2038
2233
  // The fan-out's workers would re-run the whole task from scratch for
2039
2234
  // it — 3 extra reasoning passes + a synthesis — which is the opposite
@@ -2046,7 +2241,11 @@ function createLocalEngine({
2046
2241
  // bought by round 1, and buying it again here would charge a
2047
2242
  // second one for the same context.
2048
2243
  recallDeep: false,
2049
- maxTokens: doubledBudget(opts.maxTokens),
2244
+ // A reasoning-only completion gets the high rung AND high effort:
2245
+ // the model is about to re-pay for the same chain-of-thought, and
2246
+ // on DeepSeek that pair is what actually moves the ceiling.
2247
+ effort: reasoningOnly ? 'high' : opts.effort,
2248
+ maxTokens: retryBudgetFor(opts, reasoningOnly),
2050
2249
  // Doubling applies to the pooled path only when the caller stated a
2051
2250
  // number. With none stated, the server's effort ladder IS the
2052
2251
  // budget, and sending doubledBudget's 8192 floor would *lower* it
@@ -2073,14 +2272,20 @@ function createLocalEngine({
2073
2272
  // no gathered context to rescue, so a bare completion is just that.
2074
2273
  synthesisDone = true;
2075
2274
  foldPromptIntoHistory();
2076
- history.push({ role: 'user', content: EMPTY_TURN_NUDGE });
2275
+ // The nudge is chosen by what the empty round actually WAS, not by the
2276
+ // fact that it was empty. A round that spent its budget on hidden
2277
+ // reasoning and stopped without writing a word needs to be told not to
2278
+ // think; the generic wording invites the same silent pass a second
2279
+ // time, which is precisely how a recoverable turn became
2280
+ // `emptyTurnError` on byok/deepseek-v4-flash (2026-10-11).
2281
+ history.push({ role: 'user', content: reasonedButSilent(res) ? THOUGHT_ONLY_NUDGE : EMPTY_TURN_NUDGE });
2077
2282
  // singlePass: same reasoning as the truncation retry above, and it is
2078
2283
  // the same comment's literal promise ("force the summary out of the
2079
2284
  // context it already holds"). Escalating a write-up back into the
2080
2285
  // worker fan-out asked three fresh workers to redo an investigation
2081
2286
  // whose findings are already in `history`, at 4x the cost, to produce
2082
2287
  // a paragraph the model had all the material for.
2083
- res = await dispatch(cls, {
2288
+ const synthesisOpts = {
2084
2289
  ...opts,
2085
2290
  singlePass: true,
2086
2291
  // Same as the truncation retry above: this pass writes up findings
@@ -2089,7 +2294,13 @@ function createLocalEngine({
2089
2294
  messages: history,
2090
2295
  prompt: '',
2091
2296
  tools: [],
2092
- });
2297
+ };
2298
+ res = await dispatch(cls, synthesisOpts);
2299
+ // The ceiling and the rung of the last dispatch that came back empty,
2300
+ // so the failure below reports the call that actually failed instead of
2301
+ // the original one it was retried away from.
2302
+ let lastBudget = synthesisOpts.maxTokens;
2303
+ let lastEffort = synthesisOpts.effort;
2093
2304
  addUsage(res);
2094
2305
  // Same guard again: a cancel landing during the synthesis pass
2095
2306
  // resolves empty too, and without this check it fell through to
@@ -2097,12 +2308,108 @@ function createLocalEngine({
2097
2308
  // "no answer" failure over what was actually just the user hitting
2098
2309
  // Cancel.
2099
2310
  if (signal.aborted) return withTurnUsage(res);
2311
+ // The SAME truncation retry the main round gets, one level down: the
2312
+ // write-up pass is itself a fresh dispatch billed against `opts.maxTokens`,
2313
+ // and a reasoning model can burn that whole budget on hidden
2314
+ // chain-of-thought a SECOND time while composing the summary — history
2315
+ // is bigger than round 1's, so if anything this pass is more likely to
2316
+ // need the room. Without this, "the synthesis pass also got
2317
+ // truncated" and "the model is simply done" were indistinguishable —
2318
+ // both threw the same emptyTurnError — even though the first is
2319
+ // recoverable with the exact one-line fix already proven above.
2320
+ if (!assistantText(res) && (isTruncated(res) || reasonedButSilent(res))) {
2321
+ const reasoningOnly = reasonedButSilent(res);
2322
+ lastBudget = retryBudgetFor(synthesisOpts, reasoningOnly);
2323
+ lastEffort = reasoningOnly ? 'high' : synthesisOpts.effort;
2324
+ res = await dispatch(cls, {
2325
+ ...synthesisOpts,
2326
+ effort: lastEffort,
2327
+ maxTokens: lastBudget,
2328
+ statedMaxTokens: synthesisOpts.statedMaxTokens
2329
+ ? doubledBudget(synthesisOpts.statedMaxTokens)
2330
+ : undefined,
2331
+ });
2332
+ addUsage(res);
2333
+ if (signal.aborted) return withTurnUsage(res);
2334
+ }
2335
+ // ── The recovery ladder: extra calls, not a verdict ────────────────
2336
+ // Everything above bought a bigger ceiling; this changes the ASK —
2337
+ // and repeats it — because a round that reasoned and then stayed
2338
+ // silent has already been handed more room and still did not write a
2339
+ // word.
2340
+ //
2341
+ // This is the upstream engine's own trade (`src/backend.js`, the
2342
+ // `thoughtOnly` recovery round): spend another call rather than hand
2343
+ // the reader a red "backend error: … raise the Effort rung" over work
2344
+ // that is finished and already sitting in `history`. Telling someone
2345
+ // to go and raise a setting is not a recovery, and the ceiling it
2346
+ // points at was raised by the retry above — so the ladder spends
2347
+ // calls, with a variant of the ask each time, and only reports
2348
+ // failure when every one of them comes back empty as well.
2349
+ //
2350
+ // Bounded by construction: `RECOVERY_ROUNDS` caps the calls,
2351
+ // `synthesisDone` is already true, and each nudge JOINS `history`
2352
+ // rather than replacing it — every round here runs with `tools: []`,
2353
+ // so a round is a re-ask out of context the model already holds and
2354
+ // can never become a fresh investigation.
2355
+ //
2356
+ // The ceiling lift below is computed ONCE, not per round. A ladder
2357
+ // that doubled the budget each time would turn "two more calls" into
2358
+ // "two more calls at double the price" for a model whose problem is
2359
+ // demonstrably not the price, and Math.max keeps it monotone: for a
2360
+ // rung this is the top of the ladder, and a number a previous retry
2361
+ // already raised past it (a caller's doubled cap) is never lowered.
2362
+ const ladderBudget = (() => {
2363
+ const owned = Number(lastBudget) || 0;
2364
+ // No number in hand — the caller stated none and the model is not a
2365
+ // reasoning model, so `reasoningBudget()` sends no `max_tokens` at
2366
+ // all and the provider's own limit governs. Inventing one here
2367
+ // (retryBudgetFor's 8192 floor) would put a ceiling on the wire that
2368
+ // nobody asked for, which is the very defect this file's budget
2369
+ // comments were written about. The ladder's extra call is then the
2370
+ // same price as the call it repeats, and only the ask changes.
2371
+ if (!owned) return lastBudget;
2372
+ const ask = { maxTokens: lastBudget };
2373
+ if (synthesisOpts.statedMaxTokens) ask.statedMaxTokens = synthesisOpts.statedMaxTokens;
2374
+ return Math.max(owned, retryBudgetFor(ask, true));
2375
+ })();
2376
+ let recoveryRounds = 0;
2377
+ while (!assistantText(res) && recoveryRounds < RECOVERY_ROUNDS && !signal.aborted) {
2378
+ const reasoningOnly = reasonedButSilent(res);
2379
+ recoveryRounds += 1;
2380
+ history.push({
2381
+ role: 'user',
2382
+ content: reasoningOnly
2383
+ ? recoveryRounds === 1
2384
+ ? THOUGHT_ONLY_NUDGE
2385
+ : FINAL_ANSWER_NUDGE
2386
+ : EMPTY_TURN_NUDGE,
2387
+ });
2388
+ if (reasoningOnly) lastEffort = 'high';
2389
+ lastBudget = ladderBudget;
2390
+ res = await dispatch(cls, {
2391
+ ...synthesisOpts,
2392
+ messages: history,
2393
+ effort: lastEffort,
2394
+ maxTokens: lastBudget,
2395
+ statedMaxTokens: synthesisOpts.statedMaxTokens
2396
+ ? doubledBudget(synthesisOpts.statedMaxTokens)
2397
+ : undefined,
2398
+ });
2399
+ addUsage(res);
2400
+ if (signal.aborted) return withTurnUsage(res);
2401
+ }
2100
2402
  if (!assistantText(res)) {
2101
2403
  throw emptyTurnError({
2102
2404
  cls,
2103
2405
  model,
2104
- maxTokens: opts.maxTokens,
2406
+ // What the LAST call looked like, and how many extra calls the
2407
+ // ladder already spent: the report describes the call that failed
2408
+ // rather than the first one it was retried away from.
2409
+ effort: lastEffort,
2410
+ recoveryRounds,
2105
2411
  finishReason: finishReasonOf(res),
2412
+ reasoningChars: reasoningTextOf(res).length,
2106
2413
  });
2107
2414
  }
2108
2415
  return withTurnUsage(res);
@@ -2260,6 +2567,21 @@ function createLocalEngine({
2260
2567
  clearSessionApprovals,
2261
2568
  settings,
2262
2569
  unloadLocalModels,
2570
+ // The user's own model declarations (`$AEGISCODE_HOME/models.json`), read,
2571
+ // added and removed through ONE implementation because THREE faces write
2572
+ // them: the desktop's Settings row (via main.js's model dispatch), the
2573
+ // CLI's `/model-add`, and the user's own editor. `userModels()` returns
2574
+ // `{ dir, file, map, invalid }` so a host can SHOW where the file is and
2575
+ // name the rows it refused (`invalid`), instead of a declaration vanishing
2576
+ // into a picker that looks like the feature did nothing.
2577
+ //
2578
+ // Add/remove THROW on a malformed provider or model id (that is the point —
2579
+ // an id the relay would forward to a vendor 404s on the user's first turn,
2580
+ // where a rejected input 404s never), so every caller surfaces the message
2581
+ // rather than storing it.
2582
+ userModels: () => loadUserModels(),
2583
+ addUserModel: (provider, model) => addUserModel(provider, model),
2584
+ removeUserModel: (provider, model) => removeUserModel(provider, model),
2263
2585
  };
2264
2586
  }
2265
2587