aegis-desktop 0.4.2 → 0.4.3

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.
@@ -96,8 +96,17 @@ const EFFORT_TOKEN_BUDGET = { low: 8192, medium: 16384, high: 32768 };
96
96
  * that is a multi-thousand-token reasoning pass per worker — easily past a
97
97
  * minute. Timing out there aborts a perfectly healthy autonomous turn
98
98
  * mid-flight, after the server has already run and billed every worker.
99
+ *
100
+ * 15 minutes deliberately outlasts the server's OWN ceiling for that window
101
+ * (aegis1 services/pool_brain.py: NEXUS_BRAIN_WORKER_TIMEOUT, default 600s),
102
+ * because aborting first leaves the server running and billing a fan-out
103
+ * nobody will ever see. Raising that env var past ~14 minutes means raising
104
+ * this constant too; the shared client applies the same budget from the
105
+ * server's X-AEGIS-Brain response header (see client/aegis.js idleBudgetFor),
106
+ * which is what covers a fan-out the caller did not flag — test/
107
+ * autonomous-mode.test.mjs pins both halves.
99
108
  */
100
- const AUTONOMOUS_IDLE_TIMEOUT_MS = 5 * 60_000;
109
+ const AUTONOMOUS_IDLE_TIMEOUT_MS = 15 * 60_000;
101
110
 
102
111
  /**
103
112
  * Only ever raises a too-low budget for a DeepSeek reasoning model — never
@@ -513,6 +522,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
513
522
  /** One transport round for the chosen class. */
514
523
  async function dispatch(cls, opts) {
515
524
  if (cls === 'aegis') {
525
+ // `undefined` = "leave the model id's own default alone" (a real user
526
+ // turn on the pooled class: the selected Nexus id is what decides, so
527
+ // today's behaviour is unchanged). `false` = an explicit single provider
528
+ // call, for a pass that is a continuation rather than a new
529
+ // investigation. `true` = the autonomous fan-out.
530
+ const brainFlag = opts.singlePass ? false : opts.autonomous ? true : undefined;
516
531
  return aegis.chatCompletion({
517
532
  prompt: opts.prompt,
518
533
  system: opts.system,
@@ -542,13 +557,30 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
542
557
  extra: {
543
558
  aegis_memory: true,
544
559
  session: opts.sessionId,
545
- ...(opts.autonomous ? { brain: true } : {}),
546
- // Only meaningful (and only sent) alongside `brain` aegis1
560
+ // The fan-out is opt-in per dispatch. `brain` is sent EXPLICITLY
561
+ // whenever this dispatch is not the autonomous one, because the
562
+ // model id this class sends (``nexus-brain``) enables the pooled
563
+ // brain on its own: without the flag a continuation pass — the
564
+ // doubled-budget retry, or the "write up what you already found"
565
+ // re-dispatch — silently re-ran the whole workers+1 fan-out for a
566
+ // pass whose documented cost is a single request. aegis1
567
+ // services/pool_brain.py parse_brain_request honours the opt-out.
568
+ ...(brainFlag === undefined ? {} : { brain: brainFlag }),
569
+ // An opted-out pass is also told WHICH band to run on. A single call
570
+ // on a brain model id infers its band from the id and lands on
571
+ // "fast" (the cheapest id in the pool); "brain" is the band the
572
+ // workers themselves run on (cheapest model that can think). Same
573
+ // model as the fan-out, one sample instead of four — otherwise
574
+ // dropping the fan-out would have quietly changed the model too.
575
+ ...(brainFlag === false ? { mode: 'brain' } : {}),
576
+ // Only meaningful (and only sent) alongside a running fan-out — aegis1
547
577
  // services/pool_brain.py parse_brain_request reads `effort`/
548
578
  // `workers` straight off the body and clamps them itself
549
579
  // (EFFORT_LEVELS / MAX_WORKERS), so no client-side validation here.
550
- ...(opts.autonomous && opts.effort ? { effort: opts.effort } : {}),
551
- ...(opts.autonomous && opts.workers ? { workers: opts.workers } : {}),
580
+ // Keyed on the effective brain flag, not on `autonomous`: an opted-out
581
+ // single pass carries no fan-out tuning it cannot use.
582
+ ...(brainFlag === true && opts.effort ? { effort: opts.effort } : {}),
583
+ ...(brainFlag === true && opts.workers ? { workers: opts.workers } : {}),
552
584
  // The pool forwards `tools` to the provider and returns tool_calls
553
585
  // (aegis1 app.py:7765 → provider, pool_brain synthesis keeps them).
554
586
  ...(opts.tools.length ? { tools: opts.tools } : {}),
@@ -763,7 +795,16 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
763
795
  // would stream it a second time onto the same bubble.
764
796
  if (!truncationRetried && !assistantText(res) && isTruncated(res)) {
765
797
  truncationRetried = true;
766
- res = await dispatch(cls, { ...opts, maxTokens: doubledBudget(opts.maxTokens) });
798
+ // singlePass: this retry buys *budget*, not a second investigation.
799
+ // The fan-out's workers would re-run the whole task from scratch for
800
+ // it — 3 extra reasoning passes + a synthesis — which is the opposite
801
+ // of what "one doubled request" means (and what the note above
802
+ // promises). The pass itself is unchanged apart from that.
803
+ res = await dispatch(cls, {
804
+ ...opts,
805
+ singlePass: true,
806
+ maxTokens: doubledBudget(opts.maxTokens),
807
+ });
767
808
  addUsage(res);
768
809
  }
769
810
 
@@ -778,7 +819,19 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
778
819
  synthesisDone = true;
779
820
  foldPromptIntoHistory();
780
821
  history.push({ role: 'user', content: EMPTY_TURN_NUDGE });
781
- res = await dispatch(cls, { ...opts, messages: history, prompt: '', tools: [] });
822
+ // singlePass: same reasoning as the truncation retry above, and it is
823
+ // the same comment's literal promise ("force the summary out of the
824
+ // context it already holds"). Escalating a write-up back into the
825
+ // worker fan-out asked three fresh workers to redo an investigation
826
+ // whose findings are already in `history`, at 4x the cost, to produce
827
+ // a paragraph the model had all the material for.
828
+ res = await dispatch(cls, {
829
+ ...opts,
830
+ singlePass: true,
831
+ messages: history,
832
+ prompt: '',
833
+ tools: [],
834
+ });
782
835
  addUsage(res);
783
836
  if (!assistantText(res)) {
784
837
  throw emptyTurnError({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.4.2",
4
+ "version": "0.4.3",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
package/vendor/aegis.js CHANGED
@@ -466,12 +466,15 @@ function createClient(opts = {}) {
466
466
  // close), reader.read() below waits forever and the whole desktop host
467
467
  // hangs with no way out but force-quit. Cap the gap between chunks —
468
468
  // not the whole response — so a slow-but-alive generation is untouched.
469
- const SSE_IDLE_TIMEOUT_MS = 60_000;
470
- // A pooled brain call may set its own, larger budget: the fan-out yields a
471
- // header chunk and then stays silent until the FIRST worker pass returns,
472
- // which is a full reasoning-model call and can outlast the 60s default.
473
- const idleMs =
474
- Number(idleTimeoutMs) > 0 ? Number(idleTimeoutMs) : SSE_IDLE_TIMEOUT_MS;
469
+ //
470
+ // The budget is per *response*, not per call site: a pooled brain call
471
+ // announces its worker fan-out in the X-AEGIS-Brain response header, and a
472
+ // fan-out is legitimately silent until its first worker returns. Keying the
473
+ // longer budget on a request the caller remembered to flag left the
474
+ // default Nexus turn (brain model id, checkbox off) dying at 60s — with
475
+ // the server already past its own fan-out deadline and every worker
476
+ // billed. See idleBudgetFor().
477
+ const idleMs = idleBudgetFor(res, idleTimeoutMs);
475
478
  async function readWithIdleTimeout() {
476
479
  let timer;
477
480
  const timeout = new Promise((_, reject) => {
@@ -752,7 +755,49 @@ function createClient(opts = {}) {
752
755
  };
753
756
  }
754
757
 
755
- const api = { createClient, envVar, randomUUID, DEFAULT_API_BASE, CLIENT_VERSION };
758
+ /** Gap between SSE chunks that counts as "the stream is dead", in ms. */
759
+ const SSE_IDLE_TIMEOUT_MS = 60_000;
760
+
761
+ /**
762
+ * Gap allowed while a pooled brain call runs its worker fan-out, in ms.
763
+ *
764
+ * The fan-out yields a header chunk and then says nothing until its FIRST
765
+ * worker pass returns — and each worker is a full reasoning-model call at
766
+ * roughly 1/(workers+1) of the effort budget, so the silent window is minutes,
767
+ * not seconds. The server bounds that window itself
768
+ * (``services/pool_brain.py`` ``NEXUS_BRAIN_WORKER_TIMEOUT``, default 600s) and
769
+ * announces it per-response with the ``X-AEGIS-Brain`` header, so this budget
770
+ * has to outlast the *server's* deadline: aborting first kills a healthy turn
771
+ * the server is still running and billing.
772
+ *
773
+ * Raising the server's env var above ~14 minutes requires raising this too.
774
+ * desktop/lib/local/engine.js mirrors the same value for the explicit
775
+ * "work autonomously" path (AUTONOMOUS_IDLE_TIMEOUT_MS); the desktop test
776
+ * test/autonomous-mode.test.mjs fails if either drops below the server default.
777
+ */
778
+ const BRAIN_IDLE_TIMEOUT_MS = 15 * 60_000;
779
+
780
+ /**
781
+ * Idle budget for one SSE response: the caller's override when it asked for
782
+ * one, widened to the fan-out budget when the server says this response *is* a
783
+ * fan-out. The header is the authoritative signal — it is set by the same code
784
+ * that runs the fan-out, so a renamed brain id or a caller that forgot its
785
+ * flag cannot desynchronise the two. A response with no header (an older
786
+ * server, or a single-pass call) keeps the caller's budget or the 60s default.
787
+ */
788
+ function idleBudgetFor(res, requestedMs) {
789
+ const base = Number(requestedMs) > 0 ? Number(requestedMs) : SSE_IDLE_TIMEOUT_MS;
790
+ let header = '';
791
+ try {
792
+ const get = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get.bind(res.headers) : null;
793
+ header = (get && get('X-AEGIS-Brain')) || '';
794
+ } catch {
795
+ header = ''; // an exotic fetch shim without headers: keep the caller's budget
796
+ }
797
+ return header && String(header).trim() ? Math.max(base, BRAIN_IDLE_TIMEOUT_MS) : base;
798
+ }
799
+
800
+ const api = { createClient, envVar, randomUUID, DEFAULT_API_BASE, CLIENT_VERSION, idleBudgetFor, SSE_IDLE_TIMEOUT_MS, BRAIN_IDLE_TIMEOUT_MS };
756
801
 
757
802
  // Node / Electron (CommonJS): the MCP plugin and desktop shell require() this.
758
803
  if (typeof module !== 'undefined' && module.exports) {