aegis-desktop 0.6.0 → 0.7.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 +85 -0
- package/lib/local/autonomous.js +210 -24
- package/lib/local/engine.js +100 -7
- package/lib/local/queue.js +20 -1
- package/lib/local/session-rounds.js +205 -0
- package/lib/settings.js +43 -0
- package/lib/sync/persist-gate.js +120 -0
- package/main.js +128 -6
- package/package.json +4 -2
- package/preload.js +31 -0
- package/renderer/app.js +117 -3
- package/renderer/diff.js +356 -0
- package/renderer/diffview.js +333 -0
- package/renderer/index.html +12 -3
- package/renderer/style.css +70 -0
package/README.md
CHANGED
|
@@ -45,6 +45,88 @@ queue that flushes on each "Sync now" or heartbeat retry. The **remember**
|
|
|
45
45
|
button on any assistant reply pins that message to cross-machine memory —
|
|
46
46
|
queued locally if you're offline.
|
|
47
47
|
|
|
48
|
+
## Autonomous queue
|
|
49
|
+
|
|
50
|
+
The unattended work queue, shared with the CLI: tasks are appended to
|
|
51
|
+
`~/.aegiscode/queue.jsonl` and drained later, one at a time, with tool approval
|
|
52
|
+
**disabled** — there is nobody there to click an approval card. The desktop
|
|
53
|
+
sidebar's queue card and `aegiscode autonomous` are two views of the same file.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
aegiscode autonomous add "fix the flaky retry test" --cwd ~/repo --commit
|
|
57
|
+
aegiscode autonomous list
|
|
58
|
+
aegiscode autonomous run # drain one task, then stop
|
|
59
|
+
aegiscode autonomous proceed --max 3 # drain up to three
|
|
60
|
+
aegiscode autonomous reconcile --auto # queue the next unfinished PLAN.md phase, then drain it
|
|
61
|
+
aegiscode autonomous retry <id> # put a finished task back
|
|
62
|
+
aegiscode autonomous clear --all # empty the queue
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A task's text is stored verbatim — `add "fix --json in the parser"` is a task,
|
|
66
|
+
not a flag. Drains commit only the paths that task's own tool layer wrote, so a
|
|
67
|
+
drain never sweeps a peer's in-flight edits into your commit.
|
|
68
|
+
|
|
69
|
+
### Aegis Cloud only
|
|
70
|
+
|
|
71
|
+
A queued task runs on the pooled brain — **`nexus-brain`** (alias
|
|
72
|
+
`aegis-brain`) — for the same reason the Claude Code plugin is cloud-only: the
|
|
73
|
+
queue hands work to a loop with no human in it, and the pooled class is the one
|
|
74
|
+
the server can route, budget, and bill on its own. A task that *states* another
|
|
75
|
+
model id is refused where you can still see it, instead of failing minutes into
|
|
76
|
+
a drain as an opaque server error:
|
|
77
|
+
|
|
78
|
+
| Where the model was stated | What happens |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `autonomous add --model <id>` | refused, with the reason, at add time — nothing is queued |
|
|
81
|
+
| A hand-edited `queue.jsonl` | refused pre-flight by the worker (`ms: 0`), before any turn is billed |
|
|
82
|
+
| `AEGIS_MODEL=<id>` in the environment | **ignored for queue runs**, and reported as a note — that variable is shared with the interactive surfaces, which *do* run direct providers |
|
|
83
|
+
| nothing stated | `nexus-brain` |
|
|
84
|
+
|
|
85
|
+
### What a queued task costs
|
|
86
|
+
|
|
87
|
+
The queue has two shapes, and the cheap one is the default:
|
|
88
|
+
|
|
89
|
+
| | Single pass (default) | Fan-out (opt in) |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| Provider calls | 1 | `workers` + 1 (investigation passes, then synthesis) |
|
|
92
|
+
| Effort rung | `medium` | `high` |
|
|
93
|
+
| How to ask for it | nothing — it is the default | `AEGIS_AUTONOMOUS_FANOUT=1` |
|
|
94
|
+
|
|
95
|
+
An earlier version sent **every** queued task as a fan-out at the priciest rung:
|
|
96
|
+
one queued line could become several reasoning calls plus a synthesis, all at
|
|
97
|
+
`high`. Making the fan-out opt-in, and letting effort follow the shape of the
|
|
98
|
+
task rather than always topping out, removes the worker multiplication and
|
|
99
|
+
roughly halves the budget on a one-line task. `--effort` (or
|
|
100
|
+
`AEGIS_AUTONOMOUS_EFFORT`) still wins outright, and `--workers N` is only sent
|
|
101
|
+
when you are actually fanning out.
|
|
102
|
+
|
|
103
|
+
> **Known gap:** there is no `--fanout` flag yet — the opt-in is the environment
|
|
104
|
+
> variable or `singlePass: false` in the task record. `--single-pass` still
|
|
105
|
+
> parses, but it now agrees with the default instead of overriding it.
|
|
106
|
+
|
|
107
|
+
### A turn that runs out of rounds keeps its work
|
|
108
|
+
|
|
109
|
+
The tool loop runs against a round horizon: 24 rounds for an interactive turn
|
|
110
|
+
(`AEGIS_CHAT_MAX_ROUNDS`), 40 for a queued one
|
|
111
|
+
(`AEGIS_AUTONOMOUS_MAX_ROUNDS`). A model that reached it mid-turn used to lose
|
|
112
|
+
everything it had assembled, because the cap was *turn* state.
|
|
113
|
+
|
|
114
|
+
The horizon is now **session state**, held in `lib/local/session-rounds.js`. When
|
|
115
|
+
a turn stops at the cap the interruption is filed against the session, and the
|
|
116
|
+
next message in that conversation is prefixed with a continuation preamble —
|
|
117
|
+
*cut off after N tool rounds; continue, do not restart* — along with
|
|
118
|
+
`max(4, ⌈horizon/4⌉)` extra rounds so re-orientation does not eat the new
|
|
119
|
+
horizon. The resume is announced in the transcript, so it is visible rather
|
|
120
|
+
than silent.
|
|
121
|
+
|
|
122
|
+
- In-memory and **process-local** (30-minute TTL, 64 entries) — it never leaves
|
|
123
|
+
the process and never touches disk.
|
|
124
|
+
- Claiming an entry **consumes** it: one resume per interruption, so a chain of
|
|
125
|
+
interruptions is a chain of deliberate asks, never an automatic loop.
|
|
126
|
+
- A **stated** horizon always wins; the ledger only pads its own default.
|
|
127
|
+
- A caller that mints a fresh session key every turn adopts the most recent held
|
|
128
|
+
entry (bounded to 10 minutes) instead of losing the work.
|
|
129
|
+
|
|
48
130
|
## Keyboard shortcuts
|
|
49
131
|
|
|
50
132
|
### In the main window
|
|
@@ -124,6 +206,9 @@ main.js Electron main process — window + IPC shell only
|
|
|
124
206
|
preload.js Context-isolated IPC bridge exposed to the renderer
|
|
125
207
|
renderer/ UI (vanilla JS, no framework)
|
|
126
208
|
lib/local/ Model classes, providers, agentic tool loop, prompt
|
|
209
|
+
lib/local/queue.js The shared work queue (~/.aegiscode/queue.jsonl)
|
|
210
|
+
lib/local/autonomous.js The unattended worker — directive, digest, commits
|
|
211
|
+
lib/local/session-rounds.js Session-scoped tool-round ledger (in-memory)
|
|
127
212
|
lib/sync/ Local session/memory persistence + sync queue
|
|
128
213
|
vendor/aegis.js The AEGIS transport client (thin — no engine logic)
|
|
129
214
|
bin/aegis.js `aegis` CLI entry point for the global npm install
|
package/lib/local/autonomous.js
CHANGED
|
@@ -55,6 +55,15 @@ const gitScope = require('./git-scope.js');
|
|
|
55
55
|
/** Default tool-round horizon for an unattended turn (matches the engine's). */
|
|
56
56
|
const DEFAULT_ROUNDS = 40;
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Consecutive tool-round-horizon stops a queued task gets before runOne gives
|
|
60
|
+
* up on it as unable to converge and settles it 'error' instead of 'pending'.
|
|
61
|
+
* Without this bound a task that never finishes would keep proceed()'s drain
|
|
62
|
+
* loop picking it back up forever — proceed() defaults to no --max at all, so
|
|
63
|
+
* nothing else would ever stop it.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_ROUND_STOPS = 3;
|
|
66
|
+
|
|
58
67
|
/**
|
|
59
68
|
* The engine tools that report the file they write, and the argument holding
|
|
60
69
|
* it. This set is the attribution record for a task's commit: `exec` is absent
|
|
@@ -73,13 +82,65 @@ function writtenPath(tool) {
|
|
|
73
82
|
|
|
74
83
|
/**
|
|
75
84
|
* The default AEGIS Cloud model for autonomous work: the pooled brain, which
|
|
76
|
-
* is the tier the server
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
85
|
+
* is the tier the server can fan out to multiple reasoning workers and
|
|
86
|
+
* synthesise. `nexus-brain` is the canonical id the catalog itself prefers
|
|
87
|
+
* (the other tier spellings are aliases of it — see filterAegisCatalog in
|
|
88
|
+
* engine.js).
|
|
89
|
+
*
|
|
90
|
+
* The model id is only the tier. It is NOT what makes an autonomous task
|
|
91
|
+
* expensive — the fan-out is, and the fan-out is opt-in per task (see
|
|
92
|
+
* resolveFanout below). This used to be documented the other way round ("the
|
|
93
|
+
* pooled brain ... autonomous tasks are exactly the ones worth that spend"),
|
|
94
|
+
* and the worker sent `autonomous: true` on every queued task, so the most
|
|
95
|
+
* expensive shape of the most expensive tier ran on one-line tasks too.
|
|
80
96
|
*/
|
|
81
97
|
const DEFAULT_MODEL = 'nexus-brain';
|
|
82
98
|
|
|
99
|
+
/**
|
|
100
|
+
* THE QUEUE RUNS ON AEGIS CLOUD, AND NOTHING ELSE.
|
|
101
|
+
*
|
|
102
|
+
* A queued task is billed to the AEGIS pool and every turn it makes goes out
|
|
103
|
+
* with `class: 'aegis'` (see runTask below) — the pool is the only backend this
|
|
104
|
+
* worker can reach. So a model id here has to be one the *pool* serves. A
|
|
105
|
+
* direct-provider id is not a cheaper option the queue could fall back to; it
|
|
106
|
+
* is a request the pool cannot honour, or worse, a per-provider spelling
|
|
107
|
+
* (`anthropic`, `groq`, …) that quietly pins one upstream instead of letting
|
|
108
|
+
* the pool auto-route across whichever providers hold a live key.
|
|
109
|
+
*
|
|
110
|
+
* The accept-list is therefore the pooled-brain tier family — the one entry
|
|
111
|
+
* engine.js's filterAegisCatalog offers for the Aegis Cloud class, plus the
|
|
112
|
+
* `-smart`/`-neo` tier spellings the server still serves as aliases of it. This
|
|
113
|
+
* mirrors selectBrainEntry() there rather than re-deriving "anything starting
|
|
114
|
+
* with nexus-": `nexus-fast` is not a tier the catalog has ever served, and
|
|
115
|
+
* accepting a made-up id means a queued task that fails at the server after
|
|
116
|
+
* being picked up, or runs on a tier nobody chose.
|
|
117
|
+
*/
|
|
118
|
+
const AEGIS_MODEL_RE = /^(?:nexus|aegis)-brain(?:-(?:smart|neo))?$/;
|
|
119
|
+
const AEGIS_MODEL_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
|
|
120
|
+
|
|
121
|
+
/** True when `id` names an AEGIS Cloud pooled-brain tier (the queue's only models). */
|
|
122
|
+
function isAegisModel(id) {
|
|
123
|
+
return AEGIS_MODEL_RE.test(String(id == null ? '' : id).trim());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Why a STATED model id cannot be queued, or '' when it can. Blank is not a
|
|
128
|
+
* refusal — "no pick" is the default model, which resolveModel supplies.
|
|
129
|
+
*
|
|
130
|
+
* The message names the pool, because the failure it prevents ("queued on
|
|
131
|
+
* claude-sonnet-4, ran on — or was billed to — something else") is invisible
|
|
132
|
+
* otherwise: `class: 'aegis'` would be sent with an id the server does not
|
|
133
|
+
* serve, and the task would come back as an opaque error minutes later.
|
|
134
|
+
*/
|
|
135
|
+
function modelRefusal(id) {
|
|
136
|
+
const stated = String(id == null ? '' : id).trim();
|
|
137
|
+
if (!stated || isAegisModel(stated)) return '';
|
|
138
|
+
return (
|
|
139
|
+
`the autonomous queue runs Aegis Cloud models only (${DEFAULT_MODEL}, ` +
|
|
140
|
+
`${AEGIS_MODEL_IDS.join('/')} aliases); "${stated}" is not one`
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
83
144
|
/** Phrase match for "work autonomously" in a prompt or a queued task. */
|
|
84
145
|
const AUTONOMOUS_REQUEST_RE =
|
|
85
146
|
/\bautonomously\b|\bon your own\b|\bwithout asking\b|\bend[- ]to[- ]end\b|\bno (?:more )?questions\b|\bfully autonomous\b|\bqueue it\b/i;
|
|
@@ -160,23 +221,92 @@ function withRoundHorizon(rounds, env, fn) {
|
|
|
160
221
|
}
|
|
161
222
|
|
|
162
223
|
/**
|
|
163
|
-
* The model an autonomous task runs on: an explicit pick wins, then
|
|
164
|
-
* environment (so a systemd timer can
|
|
165
|
-
* brain. Never the interactive session's model — a queue survives the
|
|
166
|
-
* that queued it, so it cannot inherit that session's choice.
|
|
224
|
+
* The model an autonomous task runs on: an explicit pick wins, then an AEGIS
|
|
225
|
+
* Cloud pin in the environment (so a systemd timer can choose a tier), then the
|
|
226
|
+
* pooled brain. Never the interactive session's model — a queue survives the
|
|
227
|
+
* session that queued it, so it cannot inherit that session's choice.
|
|
228
|
+
*
|
|
229
|
+
* TWO DIFFERENT TREATMENTS FOR TWO DIFFERENT SOURCES, on purpose:
|
|
230
|
+
*
|
|
231
|
+
* - a pick that came from the TASK is returned verbatim, even when it is
|
|
232
|
+
* wrong. Substituting a correct model for a stated one is how a queue
|
|
233
|
+
* "runs on nexus-brain" while the file says otherwise; the caller refuses
|
|
234
|
+
* it out loud instead (modelRefusal, queue.addTask, and the pre-flight in
|
|
235
|
+
* runTask).
|
|
236
|
+
* - a non-Aegis value in the ENVIRONMENT is skipped, because AEGIS_MODEL is
|
|
237
|
+
* shared with the interactive surfaces (which run direct providers), so a
|
|
238
|
+
* stray value there is not a statement about the queue. Refusing every
|
|
239
|
+
* task over it would break drains for a reason that is not the task's
|
|
240
|
+
* fault; a fallback to the pooled brain keeps the drain honest and on-cloud.
|
|
167
241
|
*/
|
|
168
242
|
function resolveModel({ model, env } = {}) {
|
|
169
243
|
const e = env || process.env;
|
|
170
244
|
const picked = String(model || '').trim();
|
|
171
245
|
if (picked) return picked;
|
|
172
246
|
const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
|
|
173
|
-
return fromEnv
|
|
247
|
+
return isAegisModel(fromEnv) ? fromEnv : DEFAULT_MODEL;
|
|
174
248
|
}
|
|
175
249
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
250
|
+
/**
|
|
251
|
+
* The environment pin the queue had to ignore, or '' when there was none.
|
|
252
|
+
*
|
|
253
|
+
* Reported (not silent) because an operator who exported
|
|
254
|
+
* AEGIS_AUTONOMOUS_MODEL=deepseek-v4-flash asked for a model and is not getting
|
|
255
|
+
* it: the queue falls back to the pool, and the one place that says so is this
|
|
256
|
+
* string, which runTask emits as a note and the desktop card shows.
|
|
257
|
+
*/
|
|
258
|
+
function ignoredEnvModel({ model, env } = {}) {
|
|
259
|
+
if (String(model || '').trim()) return ''; // the task's own pick is what counts
|
|
260
|
+
const e = env || process.env;
|
|
261
|
+
const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
|
|
262
|
+
if (!fromEnv || isAegisModel(fromEnv)) return '';
|
|
263
|
+
const which = String(e.AEGIS_AUTONOMOUS_MODEL || '').trim() ? 'AEGIS_AUTONOMOUS_MODEL' : 'AEGIS_MODEL';
|
|
264
|
+
return `${which}="${fromEnv}" is not an Aegis Cloud model — running on ${DEFAULT_MODEL} instead`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Whether a queued task runs the pooled-brain worker fan-out (aegis1
|
|
269
|
+
* services/pool_brain.py) or one plain turn on the same tier.
|
|
270
|
+
*
|
|
271
|
+
* COST IS THE REASON THIS IS OPT-IN. The fan-out is the single biggest
|
|
272
|
+
* multiplier this app can put on a bill: pool_brain spawns up to `workers`
|
|
273
|
+
* reasoning workers plus a synthesis pass, re-sends the task context to every
|
|
274
|
+
* one of them, and sizes each from the same effort ladder. A 3-worker
|
|
275
|
+
* high-effort task is therefore roughly four full reasoning calls against a
|
|
276
|
+
* 65536-token ladder, where the identical task single-pass is one call on the
|
|
277
|
+
* medium rung. The fan-out earns that on genuinely open-ended investigation
|
|
278
|
+
* ("why did X regress across this repo"); it is pure waste on a task that
|
|
279
|
+
* already names the file to edit.
|
|
280
|
+
*
|
|
281
|
+
* Precedence: the task's own `autonomous: true` (or `singlePass: false`, the
|
|
282
|
+
* explicit "fan me out") wins, then AEGIS_AUTONOMOUS_FANOUT=1 in the
|
|
283
|
+
* environment, else single pass.
|
|
284
|
+
*/
|
|
285
|
+
function resolveFanout(item = {}, env = process.env) {
|
|
286
|
+
const it = item || {};
|
|
287
|
+
if (it.autonomous === true || it.singlePass === false) return true;
|
|
178
288
|
const e = env || process.env;
|
|
179
|
-
return String(
|
|
289
|
+
return /^(1|true|yes|on)$/i.test(String(e.AEGIS_AUTONOMOUS_FANOUT || '').trim());
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Effort rung for an unattended turn.
|
|
294
|
+
*
|
|
295
|
+
* The rung is a spend knob, not a quality slider: the pooled class sizes its
|
|
296
|
+
* whole budget ladder from it (aegis1 services/pool_brain.py pass_budgets:
|
|
297
|
+
* low/medium/high -> 16384/32768/65536 tokens TOTAL across the fan-out), and
|
|
298
|
+
* the engine uses it for any model that reasons against its own output budget.
|
|
299
|
+
* `high` is the right rung for a fan-out — it is what buys a synthesis pass
|
|
300
|
+
* worth reading — but on a single pass it is a 2x over medium for budget
|
|
301
|
+
* nobody reads, so the default follows the shape of the task rather than
|
|
302
|
+
* always being the most expensive rung. An explicit pick (item.effort) or
|
|
303
|
+
* AEGIS_AUTONOMOUS_EFFORT still wins outright.
|
|
304
|
+
*/
|
|
305
|
+
function resolveEffort({ effort, env, fanout } = {}) {
|
|
306
|
+
const e = env || process.env;
|
|
307
|
+
const stated = String(effort || e.AEGIS_AUTONOMOUS_EFFORT || '').trim();
|
|
308
|
+
if (stated) return stated;
|
|
309
|
+
return fanout ? 'high' : 'medium';
|
|
180
310
|
}
|
|
181
311
|
|
|
182
312
|
/**
|
|
@@ -205,7 +335,8 @@ function autonomousDirective(rounds = DEFAULT_ROUNDS) {
|
|
|
205
335
|
*/
|
|
206
336
|
function digestLine(item, result) {
|
|
207
337
|
const task = String(item.task || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
208
|
-
const
|
|
338
|
+
const paused = Boolean(result && result.ok && result.stoppedOnRounds);
|
|
339
|
+
const mark = paused ? '⏸' : result && result.ok ? '✓' : '✗';
|
|
209
340
|
const files = (result && Array.isArray(result.files) && result.files.slice(0, 6)) || [];
|
|
210
341
|
const where = files.length ? ` — touched: ${files.join(', ')}${result.files.length > files.length ? ', …' : ''}` : '';
|
|
211
342
|
const why = !result || result.ok ? '' : ` — ${String((result && result.error) || 'failed').slice(0, 120)}`;
|
|
@@ -290,8 +421,24 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
|
|
|
290
421
|
*/
|
|
291
422
|
async function runTask(item, { carry = '', commit } = {}) {
|
|
292
423
|
const cwd = item.cwd || process.cwd();
|
|
424
|
+
// Aegis Cloud or nothing — checked BEFORE the turn, not at the server. An
|
|
425
|
+
// item whose model is not a pooled tier (a hand-edited queue file, a
|
|
426
|
+
// `--model` the CLI accepted before this rule existed, another host's
|
|
427
|
+
// older build) would otherwise go out as `class: 'aegis'` with an id the
|
|
428
|
+
// pool does not serve: billed work if it happens to be a per-provider
|
|
429
|
+
// spelling, an opaque server error otherwise. Failing here names the model
|
|
430
|
+
// and the allowed ones, and costs nothing.
|
|
431
|
+
const refusal = modelRefusal(item.model);
|
|
432
|
+
if (refusal) {
|
|
433
|
+
const failed = { ok: false, error: refusal, model: item.model, ms: 0 };
|
|
434
|
+
emit({ type: 'finish', taskId: item.id, ok: false, result: failed });
|
|
435
|
+
return failed;
|
|
436
|
+
}
|
|
293
437
|
const model = resolveModel({ model: item.model, env });
|
|
294
|
-
const
|
|
438
|
+
const ignored = ignoredEnvModel({ model: item.model, env });
|
|
439
|
+
if (ignored) emit({ type: 'note', taskId: item.id, note: ignored });
|
|
440
|
+
const fanout = resolveFanout(item, env);
|
|
441
|
+
const effort = resolveEffort({ effort: item.effort, env, fanout });
|
|
295
442
|
const rounds = maxRounds(env, item.maxRounds);
|
|
296
443
|
// Approval requests have no one to answer them here; see the header.
|
|
297
444
|
let approvalAsked = null;
|
|
@@ -331,7 +478,7 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
|
|
|
331
478
|
const wantCommit = commit === undefined ? Boolean(item.commit) : Boolean(commit);
|
|
332
479
|
const before = wantCommit ? safe(() => git.gitStatusSnapshot(cwd), null) : null;
|
|
333
480
|
|
|
334
|
-
emit({ type: 'start', taskId: item.id, model, cwd, rounds });
|
|
481
|
+
emit({ type: 'start', taskId: item.id, model, cwd, rounds, fanout, effort });
|
|
335
482
|
const started = now();
|
|
336
483
|
let result;
|
|
337
484
|
try {
|
|
@@ -341,13 +488,16 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
|
|
|
341
488
|
class: 'aegis',
|
|
342
489
|
model,
|
|
343
490
|
prompt: taskPrompt(item, { carry, rounds }),
|
|
344
|
-
// The pooled
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
491
|
+
// The pooled-brain fan-out ("fan out this turn" in the GUI chat
|
|
492
|
+
// header, and opt-in here): the server fans the round out to
|
|
493
|
+
// several reasoning workers and synthesises. It costs about
|
|
494
|
+
// workers+1 full reasoning calls, so it travels only when the task
|
|
495
|
+
// asked for it — see resolveFanout above. A `singlePass` task is
|
|
496
|
+
// the default for exactly that reason; the retry/write-up passes in
|
|
497
|
+
// the engine send `brain: false` for the same one-call reason.
|
|
498
|
+
autonomous: fanout,
|
|
349
499
|
effort,
|
|
350
|
-
workers: item.workers || undefined,
|
|
500
|
+
workers: fanout ? item.workers || undefined : undefined,
|
|
351
501
|
// The turn's working directory rides on `env`: engine.js reads the
|
|
352
502
|
// tool loop's cwd from envFor(payload), so a top-level `cwd` field
|
|
353
503
|
// is a directory the engine would ignore and every tool would run
|
|
@@ -476,13 +626,41 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
|
|
|
476
626
|
const items = queue.loadQueue(env);
|
|
477
627
|
const claimed = queue.markRunning(items, item.id, { now: now() });
|
|
478
628
|
if (!claimed) return { ok: false, error: `unknown task #${item.id}`, carry };
|
|
629
|
+
const priorRoundStops = Number(claimed.roundStops) || 0;
|
|
479
630
|
queue.saveQueue(env, items);
|
|
480
631
|
|
|
481
|
-
const
|
|
632
|
+
const raw = await runTask(claimed, { carry, commit });
|
|
633
|
+
|
|
634
|
+
// A task that hit its tool-round horizon has NOT finished — engine.js's
|
|
635
|
+
// session ledger (session-rounds.js) is holding the rest of the work
|
|
636
|
+
// under this task's stable session id (sessionIdFor(item.id)), ready to
|
|
637
|
+
// inject a continuation preamble the moment this item is dispatched
|
|
638
|
+
// again. Settling it 'done' here (the old behavior) threw that away: the
|
|
639
|
+
// queue believed the task was finished, so nothing ever sent the next
|
|
640
|
+
// turn that would have consumed the resume, and a human had to notice
|
|
641
|
+
// the output was incomplete and re-queue the whole task from scratch.
|
|
642
|
+
//
|
|
643
|
+
// Left 'pending' instead, so proceed()'s own drain loop picks it straight
|
|
644
|
+
// back up — unless it has now failed to converge MAX_ROUND_STOPS times in
|
|
645
|
+
// a row, at which point looping on it forever (an unattended proceed()
|
|
646
|
+
// with no --max has no other cap at all) is worse than a visible error.
|
|
647
|
+
const hitRoundCap = Boolean(raw.ok && raw.stoppedOnRounds);
|
|
648
|
+
const roundStops = hitRoundCap ? priorRoundStops + 1 : 0;
|
|
649
|
+
const exhausted = hitRoundCap && roundStops > MAX_ROUND_STOPS;
|
|
650
|
+
const result = exhausted
|
|
651
|
+
? {
|
|
652
|
+
...raw,
|
|
653
|
+
ok: false,
|
|
654
|
+
error:
|
|
655
|
+
`stopped at its tool-round horizon ${roundStops} times in a row without finishing — ` +
|
|
656
|
+
'raise AEGIS_AUTONOMOUS_MAX_ROUNDS or split the task into smaller ones',
|
|
657
|
+
}
|
|
658
|
+
: raw;
|
|
659
|
+
const status = exhausted ? 'error' : hitRoundCap ? 'pending' : result.ok ? 'done' : 'error';
|
|
482
660
|
|
|
483
661
|
const after = queue.loadQueue(env);
|
|
484
662
|
queue.settle(after, claimed.id, {
|
|
485
|
-
status
|
|
663
|
+
status,
|
|
486
664
|
result: {
|
|
487
665
|
ok: result.ok,
|
|
488
666
|
output: result.output || '',
|
|
@@ -494,13 +672,15 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
|
|
|
494
672
|
error: result.ok ? null : result.error,
|
|
495
673
|
now: now(),
|
|
496
674
|
});
|
|
675
|
+
const settled = queue.findTask(after, claimed.id);
|
|
676
|
+
if (settled) settled.roundStops = roundStops;
|
|
497
677
|
queue.saveQueue(env, after);
|
|
498
678
|
queue.appendRun(env, {
|
|
499
679
|
id: claimed.id,
|
|
500
680
|
task: claimed.task,
|
|
501
681
|
cwd: claimed.cwd,
|
|
502
682
|
model: resolveModel({ model: claimed.model, env }),
|
|
503
|
-
status:
|
|
683
|
+
status: status === 'pending' ? 'stopped' : status,
|
|
504
684
|
at: new Date(now()).toISOString(),
|
|
505
685
|
ms: result.ms,
|
|
506
686
|
usage: result.usage || null,
|
|
@@ -582,13 +762,19 @@ function commitMessage(item) {
|
|
|
582
762
|
module.exports = {
|
|
583
763
|
DEFAULT_MODEL,
|
|
584
764
|
DEFAULT_ROUNDS,
|
|
765
|
+
MAX_ROUND_STOPS,
|
|
766
|
+
AEGIS_MODEL_IDS,
|
|
767
|
+
isAegisModel,
|
|
768
|
+
modelRefusal,
|
|
585
769
|
WRITE_TOOLS,
|
|
586
770
|
writtenPath,
|
|
587
771
|
isAutonomousRequest,
|
|
588
772
|
maxRounds,
|
|
589
773
|
withRoundHorizon,
|
|
590
774
|
resolveModel,
|
|
775
|
+
ignoredEnvModel,
|
|
591
776
|
resolveEffort,
|
|
777
|
+
resolveFanout,
|
|
592
778
|
autonomousDirective,
|
|
593
779
|
taskPrompt,
|
|
594
780
|
appendDigest,
|
package/lib/local/engine.js
CHANGED
|
@@ -383,7 +383,14 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
383
383
|
* settings store's own accessor; then the safe default — ON, i.e. the gate
|
|
384
384
|
* stays up, so a store that predates the toggle can never silently
|
|
385
385
|
* disable it. */
|
|
386
|
+
// Session-scoped round accounting (lib/local/session-rounds.js): the
|
|
387
|
+
// tool-round cap is held against the *session* rather than the single turn,
|
|
388
|
+
// so a turn that reaches its horizon is resumed by the next turn in the same
|
|
389
|
+
// conversation instead of being cut off with the work lost. Module-level
|
|
390
|
+
// state, so it survives `send()` and even a fresh engine instance.
|
|
391
|
+
const sessionRounds = require('./session-rounds.js');
|
|
386
392
|
const confirmModeEnabled = () => {
|
|
393
|
+
|
|
387
394
|
if (typeof getConfirmMode === 'function') return getConfirmMode() !== false;
|
|
388
395
|
if (settings && typeof settings.getConfirmMode === 'function') {
|
|
389
396
|
const value = settings.getConfirmMode();
|
|
@@ -938,6 +945,11 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
938
945
|
|
|
939
946
|
const system = (payload && payload.system) || buildSystemPrompt(envFor(payload));
|
|
940
947
|
const history = Array.isArray(payload && payload.messages) ? payload.messages.filter(Boolean).slice() : [];
|
|
948
|
+
// Index into `history` of everything THIS turn added as opposed to what the
|
|
949
|
+
// caller brought with it. Reset after the resume rehydration below, so a
|
|
950
|
+
// restored transcript is never re-recorded as if this turn had produced it
|
|
951
|
+
// (which would double it on every interruption in a chain).
|
|
952
|
+
let historyStart = history.length;
|
|
941
953
|
let prompt = (payload && payload.prompt) || '';
|
|
942
954
|
|
|
943
955
|
// Lazily start ONE shell session for this turn; the exec tool shares it so
|
|
@@ -1020,12 +1032,67 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
1020
1032
|
// The numbers match aegiscodex-dev's (src/autonomous.js) so both clients
|
|
1021
1033
|
// behave the same: 24 rounds for a chat turn, 40 for an autonomous one.
|
|
1022
1034
|
// Env-overridable for a deliberately long job.
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1035
|
+
// A stated horizon (env) still wins outright — an explicit number is the
|
|
1036
|
+
// user overriding the engine, not something the ledger may pad.
|
|
1037
|
+
const roundEnvName = autonomous ? 'AEGIS_AUTONOMOUS_MAX_ROUNDS' : 'AEGIS_CHAT_MAX_ROUNDS';
|
|
1038
|
+
const statedRounds = (() => {
|
|
1039
|
+
const raw = Number.parseInt(process.env[roundEnvName] || '', 10);
|
|
1040
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
1028
1041
|
})();
|
|
1042
|
+
const baseRounds = statedRounds || (autonomous ? 40 : 24);
|
|
1043
|
+
|
|
1044
|
+
// Did the previous turn in this session die at its horizon? If so, this
|
|
1045
|
+
// turn is a continuation of it: same job, unfinished, and the model has
|
|
1046
|
+
// to be told — a cold restart is what made the old cap lose work.
|
|
1047
|
+
let roundSessionKey = sessionRounds.keyFor(payload, history);
|
|
1048
|
+
let resumed = sessionRounds.take(roundSessionKey);
|
|
1049
|
+
if (!resumed) {
|
|
1050
|
+
// The caller minted a fresh key this turn (no stated session id and a
|
|
1051
|
+
// rebuilt history array). Adopt the held work rather than dropping it,
|
|
1052
|
+
// but only within the ledger's recency window.
|
|
1053
|
+
const adopted = sessionRounds.adopt();
|
|
1054
|
+
if (adopted) {
|
|
1055
|
+
resumed = adopted.entry;
|
|
1056
|
+
roundSessionKey = adopted.key;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (resumed) {
|
|
1060
|
+
const preamble = sessionRounds.resumePreamble(resumed);
|
|
1061
|
+
// Restore the interrupted turn's transcript, because the preamble alone
|
|
1062
|
+
// says HOW MUCH was done, not WHAT. The two callers need different
|
|
1063
|
+
// halves of it, so `session-rounds` files both (see `record`):
|
|
1064
|
+
//
|
|
1065
|
+
// · a caller who brings no conversation of its own — the queue
|
|
1066
|
+
// worker's fresh dispatch; autonomous.js never sends `messages` —
|
|
1067
|
+
// has nothing, so it gets the FULL transcript;
|
|
1068
|
+
// · an interactive chat already carries the prior turns (and the
|
|
1069
|
+
// "stopped at N rounds" note), so it gets only what THIS turn added
|
|
1070
|
+
// that the caller never saw: the tool calls and their results. Full
|
|
1071
|
+
// restore here would duplicate the caller's own text.
|
|
1072
|
+
//
|
|
1073
|
+
// The old `history.length === 0` guard did the first and not the second,
|
|
1074
|
+
// so the resume it was written for a queue task never reached the
|
|
1075
|
+
// interactive sessions this feature is actually used from.
|
|
1076
|
+
if (history.length === 0) {
|
|
1077
|
+
if (Array.isArray(resumed.messages) && resumed.messages.length) history.push(...resumed.messages);
|
|
1078
|
+
} else if (Array.isArray(resumed.added) && resumed.added.length) {
|
|
1079
|
+
history.push(...resumed.added);
|
|
1080
|
+
}
|
|
1081
|
+
// Round 1's ask travels as `prompt`; a follow-up dispatch (and every
|
|
1082
|
+
// turn that skips the shorthand) has to receive it through history.
|
|
1083
|
+
if (prompt) prompt = `${preamble}\n\n${prompt}`;
|
|
1084
|
+
else history.push({ role: 'user', content: preamble });
|
|
1085
|
+
if (rootOnDelta) {
|
|
1086
|
+
rootOnDelta({
|
|
1087
|
+
delta:
|
|
1088
|
+
`\n\n[resuming work this session left unfinished at ${resumed.rounds} tool rounds` +
|
|
1089
|
+
`${resumed.interruptions > 1 ? ` (cut off ${resumed.interruptions}×)` : ''}…]\n`,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
// A resumed turn gets a small bonus on top of the base horizon so
|
|
1094
|
+
// re-orientation does not consume the new budget before any work lands.
|
|
1095
|
+
const maxRounds = statedRounds || baseRounds + (resumed ? sessionRounds.bonus(baseRounds) : 0);
|
|
1029
1096
|
let round = 0;
|
|
1030
1097
|
|
|
1031
1098
|
// Token accounting for the whole TURN, not just its last round. An
|
|
@@ -1075,6 +1142,11 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
1075
1142
|
prompt = '';
|
|
1076
1143
|
};
|
|
1077
1144
|
|
|
1145
|
+
// From here on, everything pushed into `history` is this turn's own work:
|
|
1146
|
+
// the restored transcript above (if any) belongs to the turn it came from,
|
|
1147
|
+
// and re-recording it would double the ledger on every interruption.
|
|
1148
|
+
historyStart = history.length;
|
|
1149
|
+
|
|
1078
1150
|
for (;;) {
|
|
1079
1151
|
// Stop and SAY so. A turn that reaches its horizon has usually done
|
|
1080
1152
|
// real work; ending silently would paint an empty answer over it,
|
|
@@ -1084,13 +1156,34 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
1084
1156
|
const note =
|
|
1085
1157
|
`[stopped at ${maxRounds} tool rounds` +
|
|
1086
1158
|
`${turnUsage.total_tokens ? `, ${turnUsage.total_tokens.toLocaleString()} tokens` : ''}` +
|
|
1087
|
-
`.
|
|
1088
|
-
|
|
1159
|
+
`. This session holds the work: your next message here resumes it automatically` +
|
|
1160
|
+
` (with a continuation preamble), or raise ${roundEnvName} for a longer single turn.]`;
|
|
1161
|
+
// File the interruption before returning, so the horizon is a pause
|
|
1162
|
+
// in the session rather than the end of the job. `chain` carries the
|
|
1163
|
+
// count forward across resumes.
|
|
1164
|
+
const held = sessionRounds.record(roundSessionKey, {
|
|
1165
|
+
rounds: round,
|
|
1166
|
+
tokens: turnUsage.total_tokens || 0,
|
|
1167
|
+
note,
|
|
1168
|
+
chain: resumed ? resumed.interruptions : 0,
|
|
1169
|
+
// The actual tool-call/tool-result transcript built up this turn —
|
|
1170
|
+
// everything folded/pushed into `history` by completed rounds — so
|
|
1171
|
+
// a resume can restore real memory of the work, not just a count
|
|
1172
|
+
// of it (see the `resumed.messages` rehydration above).
|
|
1173
|
+
messages: history,
|
|
1174
|
+
// …and only what this turn itself added, for the caller that
|
|
1175
|
+
// already has the rest of `history` in its own conversation.
|
|
1176
|
+
added: history.slice(historyStart),
|
|
1177
|
+
});
|
|
1089
1178
|
if (rootOnDelta) rootOnDelta({ delta: `\n\n${note}` });
|
|
1090
1179
|
return withTurnUsage({
|
|
1091
1180
|
model: base.model,
|
|
1092
1181
|
choices: [{ message: { content: note }, finish_reason: 'length' }],
|
|
1093
1182
|
stoppedOnRounds: true,
|
|
1183
|
+
heldForSession: true,
|
|
1184
|
+
heldRounds: held.rounds,
|
|
1185
|
+
sessionInterruptions: held.interruptions,
|
|
1186
|
+
resumedFrom: roundSessionKey,
|
|
1094
1187
|
});
|
|
1095
1188
|
}
|
|
1096
1189
|
round += 1;
|
package/lib/local/queue.js
CHANGED
|
@@ -179,17 +179,32 @@ function upsert(items, entry) {
|
|
|
179
179
|
* with it as the tool loop's working directory), `model` is the AEGIS Cloud
|
|
180
180
|
* model id to run it on, and `commit` asks the worker to commit what the task
|
|
181
181
|
* changed (scoped — see autonomous.js).
|
|
182
|
+
*
|
|
183
|
+
* AEGIS CLOUD ONLY, REFUSED AT THE DOOR. The worker sends every task out as
|
|
184
|
+
* `class: 'aegis'`, so a model the pool does not serve cannot run here at all —
|
|
185
|
+
* it is a task that fails minutes into a drain, after being picked up, with an
|
|
186
|
+
* opaque server error. `model: null` stays legal: that is "no pick", and
|
|
187
|
+
* autonomous.resolveModel fills in the pooled brain. Anything else has to pass
|
|
188
|
+
* that module's own accept-list, so the CLI (`aegiscode autonomous add
|
|
189
|
+
* --model`), the desktop card and a hand-written queue file are all held to the
|
|
190
|
+
* same rule. Required lazily: queue.js is a dependency of autonomous.js, and
|
|
191
|
+
* this check must not turn that into a load-order cycle.
|
|
182
192
|
*/
|
|
183
193
|
function addTask(env, opts = {}) {
|
|
184
194
|
const task = String(opts.task == null ? '' : opts.task).trim();
|
|
185
195
|
if (!task) throw new Error('queue: a task needs text');
|
|
196
|
+
const statedModel = String(opts.model == null ? '' : opts.model).trim();
|
|
197
|
+
if (statedModel) {
|
|
198
|
+
const refusal = require('./autonomous.js').modelRefusal(statedModel);
|
|
199
|
+
if (refusal) throw new Error(`queue: ${refusal}`);
|
|
200
|
+
}
|
|
186
201
|
const items = loadQueue(env);
|
|
187
202
|
const now = opts.now || Date.now();
|
|
188
203
|
const entry = {
|
|
189
204
|
id: nextId(items),
|
|
190
205
|
task,
|
|
191
206
|
cwd: opts.cwd || process.cwd(),
|
|
192
|
-
model:
|
|
207
|
+
model: statedModel || null,
|
|
193
208
|
effort: opts.effort || null,
|
|
194
209
|
workers: Number.isInteger(opts.workers) ? opts.workers : null,
|
|
195
210
|
singlePass: Boolean(opts.singlePass),
|
|
@@ -251,6 +266,10 @@ function retryTask(env, id, opts = {}) {
|
|
|
251
266
|
item.updated = opts.now || Date.now();
|
|
252
267
|
delete item.error;
|
|
253
268
|
delete item.pid;
|
|
269
|
+
// A manual retry is a fresh deliberate ask, so it gets its own full run of
|
|
270
|
+
// MAX_ROUND_STOPS chances (autonomous.js) rather than inheriting whatever
|
|
271
|
+
// count a previous, unrelated failure left behind.
|
|
272
|
+
delete item.roundStops;
|
|
254
273
|
saveQueue(env, items);
|
|
255
274
|
return item;
|
|
256
275
|
}
|