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.
- package/lib/local/engine.js +113 -2
- package/main.js +47 -0
- package/package.json +1 -1
- package/preload.js +6 -0
- package/renderer/app.js +398 -43
- package/renderer/index.html +4 -5
- package/renderer/stream-policy.js +24 -2
- package/renderer/style.css +67 -7
- package/renderer/usage.js +206 -20
package/lib/local/engine.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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.
|
|
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
|
|