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 +11 -0
- package/lib/deep-link.js +35 -1
- package/lib/local/autonomous.js +81 -0
- package/lib/local/engine.js +334 -12
- package/main.js +224 -6
- package/package.json +1 -1
- package/preload.js +12 -0
- package/renderer/app.js +233 -0
- package/renderer/style.css +14 -0
- package/renderer/usage.js +68 -1
- package/vendor/aegis.js +22 -0
- package/vendor/byok-catalog.js +43 -8
- package/vendor/user-models.js +378 -0
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
|
-
|
|
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;
|
package/lib/local/autonomous.js
CHANGED
|
@@ -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,
|
package/lib/local/engine.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|