aegis-desktop 0.8.33 → 0.8.35

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.
@@ -797,6 +797,7 @@ function createLocalEngine({
797
797
  // remembering "no" makes the first answer stick.
798
798
  const sessionDenials = new Map(); // rootSessionId -> Set<toolName>
799
799
  const pendingApprovals = new Map(); // approvalId -> { resolve }
800
+ const pendingRoundAsks = new Map(); // askId -> { resolve }
800
801
 
801
802
  function sessionAllows(rootId, name) {
802
803
  const set = sessionAllowlists.get(rootId);
@@ -911,6 +912,62 @@ function createLocalEngine({
911
912
  });
912
913
  }
913
914
 
915
+ /** The renderer's rounds-dialog answer resolves the pending
916
+ * requestRoundsAsk() promise below. An unknown/already-answered id is a
917
+ * no-op, same contract as respondApproval. */
918
+ function respondRoundsAsk(askId, decision) {
919
+ const pending = pendingRoundAsks.get(askId);
920
+ if (!pending) return { ok: false };
921
+ pendingRoundAsks.delete(askId);
922
+ pending.resolve(decision === true || decision === 'continue');
923
+ return { ok: true };
924
+ }
925
+
926
+ /**
927
+ * Ask the renderer whether to fund another block of tool rounds, mirroring
928
+ * requestApproval's shape exactly: pushed over `rootOnDelta` as a `{ rounds }`
929
+ * chunk (the one channel a detached renderer has into a running turn) and
930
+ * resolved externally via `respondRoundsAsk`. Resolves `true` (fund one more
931
+ * block) or `false` (stop here) — never throws. Fails safe to `false`: no
932
+ * listener, an aborted turn, or an unanswered ask must not keep the loop
933
+ * waiting forever (or, worse, spend rounds nobody agreed to).
934
+ */
935
+ function requestRoundsAsk(rootSessionId, rootOnDelta, signal, info) {
936
+ return new Promise((resolve) => {
937
+ if (signal && signal.aborted) {
938
+ resolve(false);
939
+ return;
940
+ }
941
+ const id = randomUUID();
942
+ let settled = false;
943
+ const onAbort = () => finish(false);
944
+ const finish = (decision) => {
945
+ if (settled) return;
946
+ settled = true;
947
+ pendingRoundAsks.delete(id);
948
+ if (signal) signal.removeEventListener('abort', onAbort);
949
+ resolve(decision);
950
+ };
951
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
952
+ pendingRoundAsks.set(id, { resolve: finish });
953
+ if (typeof rootOnDelta !== 'function') {
954
+ finish(false);
955
+ return;
956
+ }
957
+ rootOnDelta({
958
+ delta: '',
959
+ rounds: {
960
+ id,
961
+ sessionId: rootSessionId,
962
+ spent: info.spent,
963
+ extensions: info.extensions,
964
+ max: info.max,
965
+ size: info.size,
966
+ },
967
+ });
968
+ });
969
+ }
970
+
914
971
  /**
915
972
  * The gate itself: read-only tools and already-session-allowed mutating
916
973
  * tools run exactly like executeTool always did. A first-time mutating
@@ -1522,6 +1579,12 @@ function createLocalEngine({
1522
1579
  // synthesis pass) instead of a single provider call. UI-gated to the
1523
1580
  // 'aegis' class only (see AUTONOMOUS_CLASS in app.js).
1524
1581
  const autonomous = cls === 'aegis' && Boolean(payload && payload.autonomous);
1582
+ // Opt-in only: the round-horizon dialog (requestRoundsAsk below) needs a
1583
+ // live renderer on the other end of `rootOnDelta` able to answer it. The
1584
+ // interactive CLI session sets this; the queue worker and `-p` one-shot
1585
+ // runs never do (nobody is there to click), so they keep the old
1586
+ // stop-and-record-session behaviour (sessionRounds below) untouched.
1587
+ const roundsAsk = Boolean(payload && payload.roundsAsk);
1525
1588
  const sessionId = (payload && payload.sessionId) || randomUUID();
1526
1589
  // Recursion depth for the task tool: 0 for a real user turn, N+1 for a
1527
1590
  // subagent spawned by depth N. Never set by an IPC caller — only by
@@ -1797,8 +1860,17 @@ function createLocalEngine({
1797
1860
  }
1798
1861
  // A resumed turn gets a small bonus on top of the base horizon so
1799
1862
  // re-orientation does not consume the new budget before any work lands.
1800
- const maxRounds = statedRounds || baseRounds + (resumed ? sessionRounds.bonus(baseRounds) : 0);
1863
+ let maxRounds = statedRounds || baseRounds + (resumed ? sessionRounds.bonus(baseRounds) : 0);
1801
1864
  let round = 0;
1865
+ // Rounds bought by one confirmed extension, and a hard cap on how many
1866
+ // extensions a single turn may spend — matching the upstream engine's
1867
+ // numbers (src/autonomous.js ROUND_EXTENSION_SIZE/MAX_ROUND_EXTENSIONS)
1868
+ // so a turn behaves the same whether it runs there or here. The cap
1869
+ // exists even though the renderer is the one asking: a dialog that
1870
+ // always says yes must still terminate (8 x baseRounds extra rounds).
1871
+ const ROUND_EXTENSION_SIZE = baseRounds;
1872
+ const MAX_ROUND_EXTENSIONS = 8;
1873
+ let extensions = 0;
1802
1874
 
1803
1875
  // Token accounting for the whole TURN, not just its last round. An
1804
1876
  // agentic turn makes one provider call per tool round, and returning only
@@ -1858,11 +1930,49 @@ function createLocalEngine({
1858
1930
  // which is the same "(empty response)" failure the guards below exist
1859
1931
  // to prevent.
1860
1932
  if (round >= maxRounds) {
1933
+ // Before conceding, give an interactive caller the chance to fund
1934
+ // another block of rounds instead of ending the turn outright — the
1935
+ // upstream engine's "asks" contract (src/backend.js requestExtension).
1936
+ // Only reachable with `roundsAsk` AND a live signal: a caller with no
1937
+ // renderer on the other end (queue, `-p`) falls straight through to
1938
+ // the session-hold stop below, unchanged.
1939
+ if (roundsAsk && extensions < MAX_ROUND_EXTENSIONS && !signal.aborted) {
1940
+ const granted = await requestRoundsAsk(rootSessionId, rootOnDelta, signal, {
1941
+ spent: maxRounds,
1942
+ extensions: extensions + 1,
1943
+ max: MAX_ROUND_EXTENSIONS,
1944
+ size: ROUND_EXTENSION_SIZE,
1945
+ });
1946
+ if (granted) {
1947
+ extensions += 1;
1948
+ const spent = maxRounds;
1949
+ maxRounds += ROUND_EXTENSION_SIZE;
1950
+ history.push({
1951
+ role: 'user',
1952
+ content: `You have used all ${spent} tool rounds allowed for this turn; ` +
1953
+ `${ROUND_EXTENSION_SIZE} more have been granted (do not restart). Continue the task ` +
1954
+ 'from exactly where you stopped — do not re-plan, do not repeat work already done, ' +
1955
+ 'and finish with an answer.',
1956
+ });
1957
+ continue;
1958
+ }
1959
+ // Declined (or the extension cap is spent): fall through to the
1960
+ // same session-hold stop a non-interactive caller gets, so a "no"
1961
+ // here is bit-for-bit the old hard stop rather than a dead end.
1962
+ }
1963
+ // The knob is only advice where nobody could have been asked about
1964
+ // it. An interactive caller was already offered another block of
1965
+ // rounds (requestRoundsAsk above) and declined or spent the cap, so
1966
+ // pointing it at an env var it cannot set from inside the app — and
1967
+ // would have to restart to apply — is obsolete text: drop it. The
1968
+ // queue worker and `-p` one-shots have no dialog, so for them the
1969
+ // variable remains the only lever and is still named.
1970
+ const knob = roundsAsk ? '' : `, or raise ${roundEnvName} for a longer single turn`;
1861
1971
  const note =
1862
1972
  `[stopped at ${maxRounds} tool rounds` +
1863
1973
  `${turnUsage.total_tokens ? `, ${turnUsage.total_tokens.toLocaleString()} tokens` : ''}` +
1864
1974
  `. This session holds the work: your next message here resumes it automatically` +
1865
- ` (with a continuation preamble), or raise ${roundEnvName} for a longer single turn.]`;
1975
+ ` (with a continuation preamble)${knob}.]`;
1866
1976
  // File the interruption before returning, so the horizon is a pause
1867
1977
  // in the session rather than the end of the job. `chain` carries the
1868
1978
  // count forward across resumes.
@@ -2146,6 +2256,7 @@ function createLocalEngine({
2146
2256
  chat,
2147
2257
  cancel,
2148
2258
  respondApproval,
2259
+ respondRoundsAsk,
2149
2260
  clearSessionApprovals,
2150
2261
  settings,
2151
2262
  unloadLocalModels,
package/main.js CHANGED
@@ -1430,6 +1430,22 @@ function createModelDispatch(engine) {
1430
1430
  // session" grants so a fresh thread never inherits a prior one's.
1431
1431
  respondApproval: (payload) =>
1432
1432
  engine.respondApproval(payload && payload.approvalId, payload && payload.decision),
1433
+ // Round-horizon gate (desktop/lib/local/engine.js requestRoundsAsk): the
1434
+ // renderer's rounds card answers a pending "fund another block of tool
1435
+ // rounds?" ask here. The ask itself arrives as a `{ rounds }` chunk on the
1436
+ // same chat() delta stream the approval card rides — see
1437
+ // desktop/renderer/app.js renderRoundsCard. An unanswered id is a no-op,
1438
+ // same contract as respondApproval.
1439
+ //
1440
+ // Guarded on the method's existence rather than assuming it: an engine
1441
+ // without the ask can never emit a `{ rounds }` chunk, so this branch is
1442
+ // unreachable there — but a renderer/window pair that is newer than the
1443
+ // engine it drives must get an honest `{ ok: false }` instead of a
1444
+ // TypeError inside the IPC handler.
1445
+ respondRoundsAsk: (payload) =>
1446
+ typeof engine.respondRoundsAsk === 'function'
1447
+ ? engine.respondRoundsAsk(payload && payload.roundsAskId, payload && payload.decision)
1448
+ : { ok: false },
1433
1449
  clearApprovals: (payload) =>
1434
1450
  engine.clearSessionApprovals(payload && payload.sessionId),
1435
1451
  };
@@ -1748,6 +1764,18 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished,
1748
1764
  if (name === 'chat') {
1749
1765
  ipcMain.handle(`${MODEL_PREFIX}${name}`, (event, payload) => {
1750
1766
  const opts = { ...(payload || {}) };
1767
+ // The rung every renderer sends is low|medium|high; this is the one
1768
+ // place a stale window can still hand us the retired `auto` (a page
1769
+ // loaded before the select lost that row, or a renderer that forgot to
1770
+ // resolve), and a falsy effort is NOT "the engine's default" — the
1771
+ // shared engine states the field conditionally, so an absent value
1772
+ // means "state no opinion" and the server sizes the turn itself. So it
1773
+ // is normalised here rather than passed through: an unknown value
1774
+ // becomes `high`, the same floor the engine and the CLI apply, and no
1775
+ // turn can reach the wire with an effort nobody chose.
1776
+ const effortRung =
1777
+ opts.effort === 'low' || opts.effort === 'medium' ? opts.effort : 'high';
1778
+ opts.effort = effortRung;
1751
1779
  const sender = event && event.sender;
1752
1780
  const forward = (chunk) => {
1753
1781
  if (
@@ -1867,11 +1895,30 @@ function queueEngineWithDeniedApprovals(engine) {
1867
1895
  /* the turn's own error path reports a gate that cannot be answered */
1868
1896
  }
1869
1897
  };
1898
+ // The third net under the same rule, for the round-horizon ask. A queued task
1899
+ // is unattended, so nobody can fund another block and the answer is always
1900
+ // "no" — which is precisely the engine's own non-interactive behaviour (it
1901
+ // stops, files the session hold, and names the env knob). Answering `false`
1902
+ // rather than leaving the promise pending is what keeps a worker from parking
1903
+ // on a card nobody will ever click: an unanswered ask does resolve false
1904
+ // (requestRoundsAsk fails safe), but waiting for the abort instead would look
1905
+ // like a hung task in the queue for however long the turn ran.
1906
+ const stopRounds = (chunk) => {
1907
+ const ask = chunk && chunk.rounds;
1908
+ if (!ask || !ask.id) return;
1909
+ if (typeof engine.respondRoundsAsk !== 'function') return;
1910
+ try {
1911
+ engine.respondRoundsAsk(ask.id, false);
1912
+ } catch {
1913
+ /* as above: the turn reports a gate that cannot be answered */
1914
+ }
1915
+ };
1870
1916
  return {
1871
1917
  ...engine,
1872
1918
  chat(payload, onDelta) {
1873
1919
  return engine.chat(payload, (chunk) => {
1874
1920
  deny(chunk);
1921
+ stopRounds(chunk);
1875
1922
  if (typeof onDelta === 'function') onDelta(chunk);
1876
1923
  });
1877
1924
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.8.33",
4
+ "version": "0.8.35",
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/preload.js CHANGED
@@ -357,6 +357,12 @@ const models = {
357
357
  // this session" grants; newChat() calls it so a fresh thread starts clean.
358
358
  respondApproval: (approvalId, decision) =>
359
359
  invokeModel('respondApproval', { approvalId, decision }),
360
+ // Round-horizon gate: answers a pending `{ rounds }` ask raised by the engine
361
+ // when a turn has used every tool round it was allowed (see app.js
362
+ // renderRoundsCard, and requestRoundsAsk in lib/local/engine.js). Same shape
363
+ // and same channel as respondApproval above.
364
+ respondRoundsAsk: (roundsAskId, decision) =>
365
+ invokeModel('respondRoundsAsk', { roundsAskId, decision }),
360
366
  clearApprovals: (sessionId) => invokeModel('clearApprovals', { sessionId }),
361
367
  };
362
368