@aragon-agent/cli 0.6.39 → 0.6.40
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/CHANGELOG.md +2589 -2523
- package/README.md +3706 -3614
- package/dist/agent/controller.d.ts +103 -9
- package/dist/agent/controller.js +196 -19
- package/dist/agent/model-profile-settings.js +12 -1
- package/dist/agent/plan-prompt.d.ts +1 -3
- package/dist/agent/plan-prompt.js +13 -7
- package/dist/agent/reducer.d.ts +29 -1
- package/dist/agent/reducer.js +34 -6
- package/dist/agent/skill-triage-prompt.d.ts +42 -0
- package/dist/agent/skill-triage-prompt.js +74 -0
- package/dist/agent/system-prompt.d.ts +14 -2
- package/dist/agent/system-prompt.js +7 -4
- package/dist/cli.js +21 -5
- package/dist/commands/builtins.js +12 -3
- package/dist/commands/skills.js +86 -1
- package/dist/commands/subagent-command.d.ts +25 -0
- package/dist/commands/subagent-command.js +242 -0
- package/dist/compaction/command.js +16 -3
- package/dist/compaction/compactor.d.ts +25 -0
- package/dist/compaction/compactor.js +111 -10
- package/dist/compaction/digest.d.ts +24 -1
- package/dist/compaction/digest.js +139 -17
- package/dist/compaction/limits.d.ts +21 -0
- package/dist/compaction/limits.js +21 -0
- package/dist/compaction/reason-text.d.ts +36 -0
- package/dist/compaction/reason-text.js +78 -0
- package/dist/compaction/types.d.ts +82 -14
- package/dist/compaction/wiring.d.ts +13 -8
- package/dist/compaction/wiring.js +1 -0
- package/dist/config/load.js +50 -3
- package/dist/config/schema.d.ts +82 -6
- package/dist/config/schema.js +107 -9
- package/dist/config/store.js +7 -2
- package/dist/proc/log-mirror.d.ts +21 -4
- package/dist/proc/log-mirror.js +29 -7
- package/dist/skills/service.d.ts +41 -0
- package/dist/skills/service.js +62 -0
- package/dist/skills/triage.d.ts +194 -0
- package/dist/skills/triage.js +393 -0
- package/dist/team/forced-dispatch.d.ts +95 -0
- package/dist/team/forced-dispatch.js +208 -0
- package/dist/team/limits.d.ts +21 -1
- package/dist/team/limits.js +21 -1
- package/dist/team/normalize.d.ts +15 -0
- package/dist/team/normalize.js +21 -4
- package/dist/team/prompt.d.ts +6 -3
- package/dist/team/prompt.js +9 -5
- package/dist/team/task-tool.js +11 -7
- package/dist/tools/human-input.d.ts +35 -2
- package/dist/tools/human-input.js +38 -0
- package/dist/tools/plan-tools.d.ts +20 -12
- package/dist/tools/plan-tools.js +24 -17
- package/dist/ui/App.js +14 -1
- package/dist/ui/StatusBar.d.ts +1 -0
- package/dist/ui/StatusBar.js +1 -0
- package/dist/ui/Transcript.js +1 -1
- package/dist/ui/entries/CompactionCard.d.ts +11 -1
- package/dist/ui/entries/CompactionCard.js +65 -4
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +18 -1
- package/dist/ui/overlays/SettingsScreen.js +29 -5
- package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
- package/dist/ui/overlays/TeamFanoutPage.js +78 -0
- package/dist/ui/overlays/model-profile-state.d.ts +19 -1
- package/dist/ui/overlays/model-profile-state.js +12 -0
- package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
- package/dist/ui/overlays/team-fanout-state.js +97 -0
- package/package.json +2 -2
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/subagent` — force the next turn to fan out into a parallel team
|
|
3
|
+
* (subagent-fanout 3.5 / 5.2).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`.
|
|
6
|
+
*
|
|
7
|
+
* WHAT THIS COMMAND IS AND IS NOT (D-8 / D-9 / D-10). It does not dispatch: it
|
|
8
|
+
* parses the user's intent, refuses honestly when forcing cannot work, prints
|
|
9
|
+
* ONE line saying what will happen next, and submits a normal user turn. The
|
|
10
|
+
* lead then calls `task` on the one dispatch path that already exists, so Esc,
|
|
11
|
+
* the watchdog pause, the report, spend accounting and the concurrency lock all
|
|
12
|
+
* keep working without a second implementation. The consequence the user must
|
|
13
|
+
* be told about is that forcing is INSTRUCTION-LEVEL: the notice says "the next
|
|
14
|
+
* turn dispatches them", never "N subagents started".
|
|
15
|
+
*
|
|
16
|
+
* THE GUARD ORDER IS A CONTRACT (R-P1-10 / R-P1-11): status -> capability ->
|
|
17
|
+
* parse. A `--no-team` session typing `/subagent -n x` is told the session has
|
|
18
|
+
* no team capability, because a syntax hint would send them to fix the one thing
|
|
19
|
+
* that is not wrong.
|
|
20
|
+
*/
|
|
21
|
+
import { buildForcedDirective, parseSubagentArgs } from '../team/forced-dispatch.js';
|
|
22
|
+
import { subagentModelSummary } from '../ui/overlays/model-profile-state.js';
|
|
23
|
+
/**
|
|
24
|
+
* The default count when the user names none (D-11).
|
|
25
|
+
*
|
|
26
|
+
* FOUR, not `maxSubagents`: `<team_mode>` tells the model two to four is usually
|
|
27
|
+
* enough, so "force one dispatch" must not silently become the most expensive
|
|
28
|
+
* call the session can make. The status line advertises the real ceiling so the
|
|
29
|
+
* user who wants the widest fan-out knows which flag to add.
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_FORCED_COUNT = 4;
|
|
32
|
+
/** `off (--no-team)` / `off` / `on`: the three states the status line tells apart. */
|
|
33
|
+
function teamState(ctx) {
|
|
34
|
+
if (!ctx.controller.isTeamRegistered())
|
|
35
|
+
return 'off (--no-team)';
|
|
36
|
+
return ctx.controller.isTeamEnabled() ? 'on' : 'off';
|
|
37
|
+
}
|
|
38
|
+
/** The subagent model line, phrased by the same authority `/team status` uses. */
|
|
39
|
+
function modelLine(ctx) {
|
|
40
|
+
const config = ctx.controller.getConfig();
|
|
41
|
+
const team = ctx.controller.getTeamConfig();
|
|
42
|
+
const fast = config.fast;
|
|
43
|
+
return subagentModelSummary({
|
|
44
|
+
mode: team.model,
|
|
45
|
+
modelProfileId: team.modelProfileId,
|
|
46
|
+
mainConnection: `${config.provider}:${config.model}`,
|
|
47
|
+
fastConnection: fast.enabled && fast.model
|
|
48
|
+
? `${fast.provider || config.provider}:${fast.model}` : null,
|
|
49
|
+
profiles: config.modelProfiles,
|
|
50
|
+
hasKey: (id) => ctx.controller.hasApiKey(id),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The no-argument answer: five lines, honest in every session state.
|
|
55
|
+
*
|
|
56
|
+
* IT RUNS BEFORE EVERY GUARD (R-P1-11). "How wide can I go?" is a question, not
|
|
57
|
+
* a request, and a question deserves an answer even in a `--no-team` session -
|
|
58
|
+
* which is exactly where the user is most likely to ask it.
|
|
59
|
+
*/
|
|
60
|
+
function reportStatus(ctx) {
|
|
61
|
+
const team = ctx.controller.getTeamConfig();
|
|
62
|
+
const max = team.maxSubagents;
|
|
63
|
+
// The bracket's bounds are the LIVE width, never a literal: at capacity 2 this
|
|
64
|
+
// line has to read `<2-2>`, or the status line teaches a flag that arrives
|
|
65
|
+
// narrowed every time.
|
|
66
|
+
const low = Math.min(2, max);
|
|
67
|
+
const lines = [
|
|
68
|
+
`Forced fan-out: /subagent [-n <${low}-${max}>] [--parts a,b,c] [--read-only] <brief>`,
|
|
69
|
+
`Capacity: ${max} per dispatch, ${team.maxConcurrent} at once `
|
|
70
|
+
+ '(team.maxSubagents / team.maxConcurrent)',
|
|
71
|
+
`Subagent model: ${modelLine(ctx)}`,
|
|
72
|
+
`Supervisor: ${team.overseer ? 'on' : 'off'}. Team mode: ${teamState(ctx)}.`,
|
|
73
|
+
];
|
|
74
|
+
// Disclosed, never refused (R-P2-6): plan mode may remove `task` from this
|
|
75
|
+
// turn's tool array, and a user who is told so will not read the silence as a
|
|
76
|
+
// broken command.
|
|
77
|
+
if (ctx.controller.getAgentMode() === 'plan') {
|
|
78
|
+
lines.push('Plan mode is on: the task tool may be unavailable this turn.');
|
|
79
|
+
}
|
|
80
|
+
ctx.notify('info', lines.join('\n'));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Guard 5's sentence. The provider is named because "no API key" alone leaves
|
|
84
|
+
* the user guessing which of their configured providers is the empty one.
|
|
85
|
+
*/
|
|
86
|
+
function reportMissingKey(ctx, team) {
|
|
87
|
+
const config = ctx.controller.getConfig();
|
|
88
|
+
const resolution = ctx.controller.subagentModelResolution();
|
|
89
|
+
const provider = resolution.effective === 'main'
|
|
90
|
+
? config.provider : resolution.ref.providerId;
|
|
91
|
+
const note = team.model === 'fast' && !ctx.controller.hasApiKey(undefined, 'fast')
|
|
92
|
+
? ' Fast tier has no key either; children will fail and the report will say so.'
|
|
93
|
+
: '';
|
|
94
|
+
ctx.notify('warn', `No API key for ${provider}. Set one with /settings or an environment variable.${note}`);
|
|
95
|
+
}
|
|
96
|
+
/** The five capability refusals, in the fixed order 3.5.3 records. */
|
|
97
|
+
function refused(ctx, team) {
|
|
98
|
+
if (!ctx.controller.isTeamRegistered()) {
|
|
99
|
+
ctx.notify('warn', 'Team mode is off for this session (started with --no-team). '
|
|
100
|
+
+ 'Restart without --no-team to use /subagent.');
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (!ctx.controller.isTeamEnabled()) {
|
|
104
|
+
ctx.notify('warn', 'Team mode is off. Run /team on, then /subagent again.');
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
// The team lock and the lead's turn are separate refusals for one reason: both
|
|
108
|
+
// would collide with `task`'s own BUSY_REFUSAL, and the user would see a failed
|
|
109
|
+
// tool card instead of a sentence explaining why nothing ran.
|
|
110
|
+
if (ctx.controller.isTeamBusy()) {
|
|
111
|
+
ctx.notify('warn', 'A team dispatch is already running. '
|
|
112
|
+
+ 'Wait for its report, or press Esc to stop it.');
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if (ctx.controller.isRunning()) {
|
|
116
|
+
ctx.notify('warn', 'A turn is in progress. Press Esc to stop it, then run /subagent again.');
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (!ctx.controller.hasApiKey()) {
|
|
120
|
+
reportMissingKey(ctx, team);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
/** The four parse failures, each with the one sentence that fixes it. */
|
|
126
|
+
function reportParseError(ctx, parsed) {
|
|
127
|
+
if (parsed.error === 'count') {
|
|
128
|
+
ctx.notify('warn', '/subagent: -n needs a number of 1 or more, e.g. -n 4.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (parsed.error === 'conflict') {
|
|
132
|
+
ctx.notify('warn', '/subagent: use -n or --parts, not both.');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (parsed.error === 'flag') {
|
|
136
|
+
ctx.notify('warn', `/subagent: unknown option "${parsed.token ?? ''}". Try /subagent for usage.`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
ctx.notify('warn', '/subagent <brief> - describe the work; add -n <count> to fix the fan-out.');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Narrow the parsed intent against the LIVE width.
|
|
143
|
+
*
|
|
144
|
+
* TWO CEILINGS, IN THIS ORDER (R-P1-3): the structural `forcedPartsMax` bounds
|
|
145
|
+
* what one argument may carry (the parser already applied it), and the policy
|
|
146
|
+
* `maxSubagents` bounds what this session may dispatch - which only this layer
|
|
147
|
+
* can apply, because only it can read the live config.
|
|
148
|
+
*/
|
|
149
|
+
function planForced(args, team) {
|
|
150
|
+
const max = team.maxSubagents;
|
|
151
|
+
if (args.parts.length > 0) {
|
|
152
|
+
// A PARTS LIST STARTS WITH A CHILD PER SURVIVING PART, and `requested` is the
|
|
153
|
+
// number the user TYPED - not the structural 8 the parser already applied.
|
|
154
|
+
// Quoting the post-truncation count made the old notice tell a user who asked
|
|
155
|
+
// for eleven parts that they had asked for eight (R-4: the notice and the
|
|
156
|
+
// directive must both name the user's number).
|
|
157
|
+
const parts = args.parts.slice(0, max);
|
|
158
|
+
return {
|
|
159
|
+
brief: args.brief,
|
|
160
|
+
parts,
|
|
161
|
+
partsAdmitted: args.parts.length,
|
|
162
|
+
partsRequested: args.partsRequested,
|
|
163
|
+
requested: args.partsRequested,
|
|
164
|
+
count: parts.length,
|
|
165
|
+
readOnly: args.readOnly,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const requested = args.count ?? Math.min(DEFAULT_FORCED_COUNT, max);
|
|
169
|
+
return {
|
|
170
|
+
brief: args.brief,
|
|
171
|
+
parts: [],
|
|
172
|
+
partsAdmitted: 0,
|
|
173
|
+
partsRequested: 0,
|
|
174
|
+
requested,
|
|
175
|
+
count: Math.max(1, Math.min(requested, max)),
|
|
176
|
+
readOnly: args.readOnly,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** The one line that says what the NEXT turn will do - a plan, not a result. */
|
|
180
|
+
function planNotice(planned, team) {
|
|
181
|
+
const noun = planned.count === 1 ? 'subagent' : 'subagents';
|
|
182
|
+
const head = `Forcing a fan-out of ${planned.count} ${noun}`;
|
|
183
|
+
const followUp = `${head} (max ${team.maxSubagents}) - the next turn dispatches them. `
|
|
184
|
+
+ 'Esc stops the turn.';
|
|
185
|
+
// TWO CEILINGS, TWO SENTENCES. `forcedPartsMax` bounds what one argument may
|
|
186
|
+
// carry and `team.maxSubagents` bounds what may be dispatched; naming the wrong
|
|
187
|
+
// one sends the user to change a number that is not the reason.
|
|
188
|
+
if (planned.parts.length > 0) {
|
|
189
|
+
if (planned.parts.length < planned.partsAdmitted) {
|
|
190
|
+
return `${head} (asked for ${planned.partsRequested} parts; `
|
|
191
|
+
+ `team.maxSubagents is ${team.maxSubagents}).`;
|
|
192
|
+
}
|
|
193
|
+
if (planned.partsAdmitted < planned.partsRequested) {
|
|
194
|
+
return `${head} (asked for ${planned.partsRequested} parts, `
|
|
195
|
+
+ `the first ${planned.parts.length} are used).`;
|
|
196
|
+
}
|
|
197
|
+
return followUp;
|
|
198
|
+
}
|
|
199
|
+
if (planned.requested > planned.count) {
|
|
200
|
+
return `${head} (asked for ${planned.requested}; `
|
|
201
|
+
+ `team.maxSubagents is ${team.maxSubagents}).`;
|
|
202
|
+
}
|
|
203
|
+
return followUp;
|
|
204
|
+
}
|
|
205
|
+
/** The command body; `registerSubagentCommand` is the only registration site. */
|
|
206
|
+
export function runSubagentCommand(ctx) {
|
|
207
|
+
if (ctx.args.trim().length === 0) {
|
|
208
|
+
reportStatus(ctx);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const team = ctx.controller.getTeamConfig();
|
|
212
|
+
if (refused(ctx, team))
|
|
213
|
+
return;
|
|
214
|
+
const parsed = parseSubagentArgs(ctx.args);
|
|
215
|
+
if (!parsed.ok) {
|
|
216
|
+
reportParseError(ctx, parsed);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const planned = planForced(parsed.args, team);
|
|
220
|
+
const directive = buildForcedDirective({
|
|
221
|
+
brief: planned.brief,
|
|
222
|
+
count: planned.count,
|
|
223
|
+
readOnly: planned.readOnly,
|
|
224
|
+
parts: planned.parts,
|
|
225
|
+
maxSubagents: team.maxSubagents,
|
|
226
|
+
clamped: planned.requested > planned.count,
|
|
227
|
+
requested: planned.requested,
|
|
228
|
+
planMode: ctx.controller.getAgentMode() === 'plan',
|
|
229
|
+
});
|
|
230
|
+
ctx.notify('info', planNotice(planned, team));
|
|
231
|
+
// `userInitiated: false`: a directive the user did not type belongs in neither
|
|
232
|
+
// the prompt history nor the hint-fade counter (todo-plan-followthrough C-12).
|
|
233
|
+
ctx.submit(directive, { userInitiated: false });
|
|
234
|
+
}
|
|
235
|
+
/** Registered from `builtins.ts` with one line, next to every other subsystem. */
|
|
236
|
+
export function registerSubagentCommand(registry) {
|
|
237
|
+
registry.register({
|
|
238
|
+
name: 'subagent',
|
|
239
|
+
description: 'Force the next turn to fan out into a parallel team',
|
|
240
|
+
run: (ctx) => { runSubagentCommand(ctx); },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -16,6 +16,7 @@ import { parseThresholdInput } from '../config/schema.js';
|
|
|
16
16
|
import { formatCost, formatTokens } from '../agent/usage.js';
|
|
17
17
|
import { computeCost } from '../agent/usage.js';
|
|
18
18
|
import { COMPACTION_LIMITS } from './limits.js';
|
|
19
|
+
import { compactionReasonLine } from './reason-text.js';
|
|
19
20
|
import { findArchive, listArchives } from './archive.js';
|
|
20
21
|
/**
|
|
21
22
|
* `/compact status` — THE GUARANTEED REPORTING SURFACE (§6.5 / AC-17).
|
|
@@ -46,7 +47,7 @@ export function formatCompactionStatus(ctx) {
|
|
|
46
47
|
return lines.join('\n');
|
|
47
48
|
}
|
|
48
49
|
const state = snapshot.selfDisabled
|
|
49
|
-
? `self-disabled (${snapshot.selfDisabledReason ?? 'no progress'})`
|
|
50
|
+
? `self-disabled (${compactionReasonLine(snapshot.selfDisabledReason ?? 'no progress')})`
|
|
50
51
|
: controller.isCompactionEnabled()
|
|
51
52
|
? snapshot.live
|
|
52
53
|
? 'on'
|
|
@@ -61,7 +62,13 @@ export function formatCompactionStatus(ctx) {
|
|
|
61
62
|
`[${p.source === 'usage' ? 'measured' : 'estimated'}]`);
|
|
62
63
|
lines.push(` Automatic trigger: occupancy >= ${(config.threshold * 100).toFixed(2)}%; ` +
|
|
63
64
|
`manual: /compact (${Math.ceil(p.contextWindow * config.threshold)} tokens)`);
|
|
65
|
+
// TWO LINES, BECAUSE THE TAIL GUARANTEE IS NOW CONDITIONAL (never-fires B).
|
|
66
|
+
// `keepRecentTurns` stays the promise; the second line is what happens when a run
|
|
67
|
+
// has too few user turns for that promise to be satisfiable, and saying it here
|
|
68
|
+
// is what makes the card's `kept M of N` readable rather than surprising.
|
|
64
69
|
lines.push(` Keeps: the original task + ${config.keepRecentTurns} recent turns, verbatim`);
|
|
70
|
+
lines.push(` Relaxes to: the last ${COMPACTION_LIMITS.minRetainedMessages} messages ` +
|
|
71
|
+
'when a run has too few user turns for that');
|
|
65
72
|
lines.push(` Summarizer: ${snapshot.model || '(none)'}` +
|
|
66
73
|
(config.useFastTier ? ' (fast tier preferred)' : ' (fast tier not used)'));
|
|
67
74
|
lines.push(' On summarize failure: ' + (config.onFailure === 'truncate'
|
|
@@ -79,8 +86,14 @@ export function formatCompactionStatus(ctx) {
|
|
|
79
86
|
if (declined > 0) {
|
|
80
87
|
sessionNotes.push(`${declined} ${declined === 1 ? 'checkpoint' : 'checkpoints'} declined`);
|
|
81
88
|
}
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
// ATTEMPTS, THEN APPLIED (never-fires C). `compactions` counts every card the
|
|
90
|
+
// user saw, which now includes the ones that declined, so reporting it alone
|
|
91
|
+
// would overstate how much the feature actually achieved.
|
|
92
|
+
const attempts = snapshot.applied === undefined
|
|
93
|
+
? `${snapshot.compactions} ${snapshot.compactions === 1 ? 'compaction' : 'compactions'}`
|
|
94
|
+
: `${snapshot.compactions} ${snapshot.compactions === 1 ? 'attempt' : 'attempts'}` +
|
|
95
|
+
` (${snapshot.applied} applied)`;
|
|
96
|
+
lines.push(` This session: ${attempts}` +
|
|
84
97
|
(sessionNotes.length > 0 ? ` (${sessionNotes.join(', ')})` : '') +
|
|
85
98
|
`, ${formatTokens(snapshot.tokensReclaimed)} tokens reclaimed`);
|
|
86
99
|
const cost = snapshot.pricingUnknown
|
|
@@ -93,6 +93,7 @@ export declare class Compactor implements ContextManager {
|
|
|
93
93
|
private compactionsThisRun;
|
|
94
94
|
private lastCompactionTurn;
|
|
95
95
|
private compactions;
|
|
96
|
+
private appliedCompactions;
|
|
96
97
|
private declined;
|
|
97
98
|
private tokensReclaimed;
|
|
98
99
|
private sessionUsage;
|
|
@@ -132,8 +133,31 @@ export declare class Compactor implements ContextManager {
|
|
|
132
133
|
private callOnce;
|
|
133
134
|
private checkCandidate;
|
|
134
135
|
private isCurrent;
|
|
136
|
+
/**
|
|
137
|
+
* Open the card for THIS attempt (C).
|
|
138
|
+
*
|
|
139
|
+
* CALLED FROM `compact()`'S ENTRY, so the two pre-model rejections are visible
|
|
140
|
+
* too. The old position - inside `callOnce` - made an attempt that never called
|
|
141
|
+
* a model indistinguishable from a checkpoint that never fired, which is exactly
|
|
142
|
+
* how this feature spent 96 attempts without a single user-visible symptom.
|
|
143
|
+
*
|
|
144
|
+
* `state.index !== undefined` keeps it idempotent: `callOnce` no longer calls it,
|
|
145
|
+
* but the guard stays so a future re-announce cannot open a second card for one
|
|
146
|
+
* attempt.
|
|
147
|
+
*/
|
|
135
148
|
private announce;
|
|
149
|
+
/** Publish the current phase, for the live card only. */
|
|
150
|
+
private phase;
|
|
136
151
|
private keep;
|
|
152
|
+
/**
|
|
153
|
+
* The relaxation disclosure, as record fields.
|
|
154
|
+
*
|
|
155
|
+
* ONE PLACE BUILDS IT, so the card, the reducer and the diagnostic log cannot
|
|
156
|
+
* describe one relaxed cut three different ways. Absent entirely when the first
|
|
157
|
+
* planning pass found a cut, which is what keeps a normal compaction's record
|
|
158
|
+
* byte-identical to the round before this change.
|
|
159
|
+
*/
|
|
160
|
+
private relaxedFields;
|
|
137
161
|
private finishRecord;
|
|
138
162
|
/** Commit only the engine/host verdict; candidates have no authority before this point. */
|
|
139
163
|
settleOperation(verdict: {
|
|
@@ -152,6 +176,7 @@ export declare class Compactor implements ContextManager {
|
|
|
152
176
|
sessionTotals(): {
|
|
153
177
|
selfDisabledReason?: string | undefined;
|
|
154
178
|
compactions: number;
|
|
179
|
+
applied: number;
|
|
155
180
|
declined: number;
|
|
156
181
|
tokensReclaimed: number;
|
|
157
182
|
usage: TokenUsage;
|
|
@@ -13,6 +13,7 @@ import { prepareMemoryInput } from './memory-input.js';
|
|
|
13
13
|
import { mergeMemory, parseMemoryDelta } from './memory.js';
|
|
14
14
|
import { validateMemoryDelta } from './memory-validation.js';
|
|
15
15
|
import { renderMemoryMarkdown } from './memory-render.js';
|
|
16
|
+
import { compactionReasonLine } from './reason-text.js';
|
|
16
17
|
import { createCompactionIdentity, verifyCompactionIdentity, } from './memory-identity.js';
|
|
17
18
|
export class Compactor {
|
|
18
19
|
deps;
|
|
@@ -20,6 +21,7 @@ export class Compactor {
|
|
|
20
21
|
compactionsThisRun = 0;
|
|
21
22
|
lastCompactionTurn = -Infinity;
|
|
22
23
|
compactions = 0;
|
|
24
|
+
appliedCompactions = 0;
|
|
23
25
|
declined = 0;
|
|
24
26
|
tokensReclaimed = 0;
|
|
25
27
|
sessionUsage = { inputTokens: 0, outputTokens: 0 };
|
|
@@ -110,16 +112,24 @@ export class Compactor {
|
|
|
110
112
|
return { action: 'keep', reason: 'already_running' };
|
|
111
113
|
const manual = this.pendingManual;
|
|
112
114
|
this.pendingManual = null;
|
|
115
|
+
const keepRecentTurns = this.deps.getConfig().compaction.keepRecentTurns;
|
|
113
116
|
const state = {
|
|
114
117
|
operation: new CompactionOperation(++this.serial, ctx.signal, COMPACTION_LIMITS.operationTimeoutMs),
|
|
115
118
|
epoch: this.historyEpoch, settings: this.settingsKey(),
|
|
116
119
|
trigger: manual ? 'manual' : ctx.trigger, pressure: this.meter().current(),
|
|
117
120
|
threshold: this.deps.getConfig().compaction.threshold,
|
|
118
121
|
started: Date.now(), messages: [...ctx.messages], model: '',
|
|
122
|
+
keepRelaxed: false, keepRecentTurns,
|
|
119
123
|
};
|
|
120
124
|
this.active = state;
|
|
121
125
|
this.compactionsThisRun += 1;
|
|
122
126
|
this.lastCompactionTurn = ctx.turnIndex;
|
|
127
|
+
// THE CARD OPENS HERE, NOT AT THE FIRST PAID CALL (C). `announce` used to be
|
|
128
|
+
// invoked from `callOnce`, so the two rejections that account for 96 of 96
|
|
129
|
+
// attempts in the field - no safe cut, digest too long - happened BEFORE it
|
|
130
|
+
// and produced no card at all. Every attempt is now visible, and every one is
|
|
131
|
+
// settled by `finishRecord` below or by the wiring's own settle.
|
|
132
|
+
this.announce(state);
|
|
123
133
|
try {
|
|
124
134
|
if (state.operation.signal.aborted)
|
|
125
135
|
throw new Error('aborted');
|
|
@@ -134,23 +144,38 @@ export class Compactor {
|
|
|
134
144
|
if (this.identity && !verifyCompactionIdentity(state.messages, this.identity)) {
|
|
135
145
|
return this.keep(state, 'invalid_prior_memory');
|
|
136
146
|
}
|
|
147
|
+
// B: `retainMessages` IS THE SECOND PASS, AND IT IS OPT-IN (see the core
|
|
148
|
+
// argument at `PlanCompactionOptions.retainMessages`). Without it a long
|
|
149
|
+
// autonomous run - one user prompt and three hundred tool calls - has no
|
|
150
|
+
// legal cut at all, because every candidate leaves fewer than
|
|
151
|
+
// `keepRecentTurns` USER messages behind it. 107 of 119 long sessions on the
|
|
152
|
+
// reporting machine were in exactly that state.
|
|
137
153
|
const plan = planCompaction(state.messages, {
|
|
138
154
|
keepRecentTurns: config.keepRecentTurns,
|
|
139
155
|
protectedPrefix: countProtectedPrefix(state.messages, this.identity),
|
|
156
|
+
retainMessages: COMPACTION_LIMITS.minRetainedMessages,
|
|
140
157
|
});
|
|
141
158
|
if (!plan)
|
|
142
159
|
return this.keep(state, 'nothing_to_drop');
|
|
160
|
+
state.keepRelaxed = plan.keepRelaxed === true;
|
|
161
|
+
state.retainedTurns = plan.retainedTurns;
|
|
162
|
+
state.retainedMessages = state.messages.length - plan.cutIndex;
|
|
163
|
+
this.phase(state, 'digesting');
|
|
143
164
|
const input = prepareMemoryInput({ messages: state.messages, cutIndex: plan.cutIndex,
|
|
144
165
|
...(this.identity ? { identity: this.identity } : {}) });
|
|
145
166
|
const selected = this.selectInput(input, instructions);
|
|
167
|
+
state.elided = selected.digest.omitted;
|
|
146
168
|
this.checkMinimumCandidate(input, selected.digest, state, ctx.systemPrompt);
|
|
169
|
+
this.phase(state, 'summarizing');
|
|
147
170
|
const memory = await this.summarize(state, input, selected, instructions);
|
|
148
171
|
if (!this.isCurrent(state, ctx))
|
|
149
172
|
return this.keep(state, 'stale_history');
|
|
173
|
+
this.phase(state, 'validating');
|
|
150
174
|
const block = buildCompactedBlock({ memory, replaced: input.cutIndex,
|
|
151
175
|
turns: input.tail.filter((m) => m.role === 'user').length,
|
|
152
176
|
anchor: input.anchorShape, generation: memory.generation, truncated: false });
|
|
153
177
|
const candidate = [input.anchor, block, ...input.tail];
|
|
178
|
+
this.phase(state, 'applying');
|
|
154
179
|
this.checkCandidate(candidate, state, ctx.systemPrompt);
|
|
155
180
|
state.candidate = candidate;
|
|
156
181
|
state.generation = memory.generation;
|
|
@@ -168,7 +193,14 @@ export class Compactor {
|
|
|
168
193
|
return { choice, digest: this.digestFor(input, choice, instructions) };
|
|
169
194
|
}
|
|
170
195
|
catch (error) {
|
|
171
|
-
|
|
196
|
+
// BOTH CODES MEAN "THIS SUMMARIZER IS TOO SMALL FOR THIS HEAD", and both are
|
|
197
|
+
// worth one retry on the main model. `digest_budget_exceeded` no longer
|
|
198
|
+
// comes from the digest LENGTH (A1 elides instead of throwing) - it now
|
|
199
|
+
// comes only from the request-vs-window projection below - so the two names
|
|
200
|
+
// differ by which ceiling was hit, not by which is fatal.
|
|
201
|
+
const windowBound = /digest_budget_exceeded|summarizer_window_exceeded/
|
|
202
|
+
.test(errorText(error));
|
|
203
|
+
if (!choice.fromFastTier || !windowBound)
|
|
172
204
|
throw error;
|
|
173
205
|
const main = this.mainChoice();
|
|
174
206
|
return { choice: main, digest: this.digestFor(input, main, instructions) };
|
|
@@ -204,7 +236,12 @@ export class Compactor {
|
|
|
204
236
|
...(instructions ? { instructions } : {}) });
|
|
205
237
|
if (estimatePromptTokens(request.messages, request.systemPrompt) +
|
|
206
238
|
COMPACTION_LIMITS.summaryOutputTokens + CONTEXT_SAFETY_MARGIN_TOKENS >= window) {
|
|
207
|
-
|
|
239
|
+
// A DIFFERENT GATE FROM THE DIGEST'S LENGTH BUDGET, and it keeps a different
|
|
240
|
+
// reason code on purpose (A1). This one is the REAL ceiling - the actual
|
|
241
|
+
// request does not fit the summarizer's window - so it must still refuse.
|
|
242
|
+
// Giving it the length budget's name would send `selectInput`'s retry ladder
|
|
243
|
+
// and every log reader after the wrong cause.
|
|
244
|
+
throw new Error('summarizer_window_exceeded');
|
|
208
245
|
}
|
|
209
246
|
return digest;
|
|
210
247
|
}
|
|
@@ -246,7 +283,10 @@ export class Compactor {
|
|
|
246
283
|
const apiKey = this.deps.getApiKey(choice.ref.providerId, choice.role);
|
|
247
284
|
if (!apiKey)
|
|
248
285
|
throw new Error('no_api_key');
|
|
249
|
-
|
|
286
|
+
// THE RECORD NAMES THE MODEL THAT ACTUALLY RAN, which may be the main model
|
|
287
|
+
// after a fast-tier retry - `announce` can only name the one it EXPECTED.
|
|
288
|
+
if (state.index !== undefined)
|
|
289
|
+
state.model = choice.ref.modelId;
|
|
250
290
|
const outcome = await state.operation.run(() => runSummarizeCall({
|
|
251
291
|
...input, ref: choice.ref, apiKey, signal: state.operation.signal, now: Date.now(),
|
|
252
292
|
complete: (providerId, request) => this.deps.complete(providerId, request),
|
|
@@ -273,13 +313,35 @@ export class Compactor {
|
|
|
273
313
|
state.settings === this.settingsKey() && !ctx.signal.aborted &&
|
|
274
314
|
!state.operation.signal.aborted;
|
|
275
315
|
}
|
|
276
|
-
|
|
277
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Open the card for THIS attempt (C).
|
|
318
|
+
*
|
|
319
|
+
* CALLED FROM `compact()`'S ENTRY, so the two pre-model rejections are visible
|
|
320
|
+
* too. The old position - inside `callOnce` - made an attempt that never called
|
|
321
|
+
* a model indistinguishable from a checkpoint that never fired, which is exactly
|
|
322
|
+
* how this feature spent 96 attempts without a single user-visible symptom.
|
|
323
|
+
*
|
|
324
|
+
* `state.index !== undefined` keeps it idempotent: `callOnce` no longer calls it,
|
|
325
|
+
* but the guard stays so a future re-announce cannot open a second card for one
|
|
326
|
+
* attempt.
|
|
327
|
+
*/
|
|
328
|
+
announce(state) {
|
|
278
329
|
if (state.index !== undefined)
|
|
279
330
|
return;
|
|
331
|
+
// THE MODEL IS NAMED BEFORE IT IS USED, and `''` when none resolves - the card
|
|
332
|
+
// renders that as "compacting context #N" with no dangling separator, and the
|
|
333
|
+
// attempt then declines at `selectInput` with `no_summarizer_model`.
|
|
334
|
+
state.model = this.resolveSummarizer()?.ref.modelId ?? '';
|
|
280
335
|
state.index = ++this.compactions;
|
|
281
336
|
this.deps.emit({ type: 'compaction_start', index: state.index,
|
|
282
337
|
trigger: state.trigger, model: state.model });
|
|
338
|
+
this.phase(state, 'planning');
|
|
339
|
+
}
|
|
340
|
+
/** Publish the current phase, for the live card only. */
|
|
341
|
+
phase(state, phase) {
|
|
342
|
+
if (state.index === undefined)
|
|
343
|
+
return;
|
|
344
|
+
this.deps.emit({ type: 'phase', index: state.index, phase });
|
|
283
345
|
}
|
|
284
346
|
keep(state, reason) {
|
|
285
347
|
state.reason = reason;
|
|
@@ -287,21 +349,50 @@ export class Compactor {
|
|
|
287
349
|
if (this.active !== state)
|
|
288
350
|
return outcome;
|
|
289
351
|
this.finishRecord(state, outcome);
|
|
290
|
-
|
|
352
|
+
// NO DUPLICATE NOTICE FOR A DECLINE THE CARD ALREADY REPORTS (C).
|
|
353
|
+
// `setEnabled`/`command.ts` used to pair every failed `/compact` with a
|
|
354
|
+
// `History preserved: <code>.` toast while the transcript said nothing; now the
|
|
355
|
+
// card is the surface, and two surfaces saying one thing is the failure mode
|
|
356
|
+
// `fast` and `team` both avoid. The notice survives ONLY for the one path that
|
|
357
|
+
// still has no card (an attempt that never announced).
|
|
358
|
+
if (state.trigger === 'manual' && state.index === undefined) {
|
|
291
359
|
this.deps.notify('warn', `History preserved: ${reason}.`);
|
|
360
|
+
}
|
|
292
361
|
if (this.deps.getConfig().compaction.onFailure === 'truncate' && !this.legacyFailureNotified) {
|
|
293
362
|
this.legacyFailureNotified = true;
|
|
294
363
|
this.deps.notify('info', 'Legacy truncate configuration: preserving history on failure.');
|
|
295
364
|
}
|
|
296
365
|
return outcome;
|
|
297
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* The relaxation disclosure, as record fields.
|
|
369
|
+
*
|
|
370
|
+
* ONE PLACE BUILDS IT, so the card, the reducer and the diagnostic log cannot
|
|
371
|
+
* describe one relaxed cut three different ways. Absent entirely when the first
|
|
372
|
+
* planning pass found a cut, which is what keeps a normal compaction's record
|
|
373
|
+
* byte-identical to the round before this change.
|
|
374
|
+
*/
|
|
375
|
+
relaxedFields(state) {
|
|
376
|
+
if (!state.keepRelaxed)
|
|
377
|
+
return {};
|
|
378
|
+
return {
|
|
379
|
+
keepRelaxed: true,
|
|
380
|
+
keepRecentTurnsUsed: state.keepRecentTurns,
|
|
381
|
+
...(state.retainedTurns === undefined ? {} : { retainedTurns: state.retainedTurns }),
|
|
382
|
+
...(state.retainedMessages === undefined
|
|
383
|
+
? {}
|
|
384
|
+
: { retainedMessages: state.retainedMessages }),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
298
387
|
finishRecord(state, outcome, summary) {
|
|
299
388
|
if (this.active !== state)
|
|
300
389
|
return;
|
|
301
|
-
|
|
390
|
+
// A DECLINE IS COUNTED EVEN WHEN IT HAS A CARD, so `/compact status` and the
|
|
391
|
+
// card agree about how many attempts did nothing.
|
|
392
|
+
if (outcome.action === 'keep')
|
|
302
393
|
this.declined += 1;
|
|
394
|
+
if (state.index === undefined)
|
|
303
395
|
return;
|
|
304
|
-
}
|
|
305
396
|
const p = state.pressure;
|
|
306
397
|
const record = {
|
|
307
398
|
index: state.index, trigger: state.trigger,
|
|
@@ -312,6 +403,8 @@ export class Compactor {
|
|
|
312
403
|
tokensBefore: p.occupied, tokensAfter: 0, model: state.model,
|
|
313
404
|
durationMs: Date.now() - state.started, ...(summary ? { summary } : {}),
|
|
314
405
|
memoryVersion: 2,
|
|
406
|
+
...this.relaxedFields(state),
|
|
407
|
+
...(state.elided ? { elidedMessages: state.elided } : {}),
|
|
315
408
|
decision: { occupied: p.occupied, contextWindow: p.contextWindow,
|
|
316
409
|
threshold: state.threshold,
|
|
317
410
|
source: p.source, deltaTokens: p.deltaTokens },
|
|
@@ -327,6 +420,7 @@ export class Compactor {
|
|
|
327
420
|
if (verdict.applied && state.candidate) {
|
|
328
421
|
this.identity = createCompactionIdentity(state.candidate);
|
|
329
422
|
this.generation = state.generation ?? this.generation;
|
|
423
|
+
this.appliedCompactions += 1;
|
|
330
424
|
this.recordApplied(verdict.tokensBefore ?? state.pressure.occupied, verdict.tokensAfter);
|
|
331
425
|
this.meter().onHistorySpliced();
|
|
332
426
|
const projected = (verdict.tokensAfter + (state.pressure.estimateOffset ?? 0)) /
|
|
@@ -359,7 +453,13 @@ export class Compactor {
|
|
|
359
453
|
return;
|
|
360
454
|
this.selfDisabled = true;
|
|
361
455
|
this.selfDisabledReason = reason;
|
|
362
|
-
|
|
456
|
+
// THE LOG LINE IS BACK ON PURPOSE (C / analysis 3.3.1 (c)). Commit 4b61a2f8a
|
|
457
|
+
// replaced `log.warn('compaction_self_disabled')` with a toast, and a toast
|
|
458
|
+
// scrolls away - so the one state that stops auto-compaction for the rest of a
|
|
459
|
+
// run had NO durable trace at all. Diagnosing the next report without this line
|
|
460
|
+
// means reading a session file and a config file by hand.
|
|
461
|
+
this.log.warn('compaction_self_disabled', { reason, trigger });
|
|
462
|
+
this.deps.notify('warn', `Auto-compaction paused: ${compactionReasonLine(reason)}. History preserved. Use /compact.`);
|
|
363
463
|
}
|
|
364
464
|
measure(_probe) { return this.meter().current(); }
|
|
365
465
|
lastMeasured() { return this.meter().lastPublished(); }
|
|
@@ -367,7 +467,8 @@ export class Compactor {
|
|
|
367
467
|
this.tokensReclaimed += Math.max(0, tokensBefore - tokensAfter);
|
|
368
468
|
}
|
|
369
469
|
sessionTotals() {
|
|
370
|
-
return { compactions: this.compactions,
|
|
470
|
+
return { compactions: this.compactions, applied: this.appliedCompactions,
|
|
471
|
+
declined: this.declined,
|
|
371
472
|
tokensReclaimed: this.tokensReclaimed, usage: this.sessionUsage, costUsd: this.costUsd,
|
|
372
473
|
pricingUnknown: this.pricingUnknown, inFlight: this.active !== null,
|
|
373
474
|
selfDisabled: this.selfDisabled, generation: this.generation,
|
|
@@ -22,5 +22,28 @@ export interface DigestResult {
|
|
|
22
22
|
export declare function isPriorSummaryBlock(_message: Message): boolean;
|
|
23
23
|
/** Derive the digest ceiling; known windows below reserve have no input room. */
|
|
24
24
|
export declare function digestBudgetChars(summarizerWindow?: number): number;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Build every head entry without mutating messages.
|
|
27
|
+
*
|
|
28
|
+
* NEVER REJECTS FOR LENGTH (context-compaction-never-fires A1). It used to throw
|
|
29
|
+
* `digest_budget_exceeded` whenever the rendered head exceeded the budget, which is
|
|
30
|
+
* the gate that turned 96 of 96 compaction attempts on the reporting machine into
|
|
31
|
+
* no-ops - the digests were simply too big, and every caller (including the
|
|
32
|
+
* preflight in `memory-input.ts`, which discards the text and only wanted the two
|
|
33
|
+
* counts) inherited the rejection.
|
|
34
|
+
*
|
|
35
|
+
* WHAT IS ELIDED AND WHAT IS NOT. The anchor, the prior memory and the legacy
|
|
36
|
+
* summary are never elided: the first is the user's own instruction and the other
|
|
37
|
+
* two are the memory this compaction is extending. Only `head` entries are
|
|
38
|
+
* candidates, and an elided entry is dropped WHOLE - never half-rendered, which
|
|
39
|
+
* would produce a quoted fragment with a source id that does not correspond to
|
|
40
|
+
* anything the model actually read.
|
|
41
|
+
*
|
|
42
|
+
* AN ELIDED ENTRY IS NOT QUOTABLE, BUT IT IS STILL COUNTED. It does not enter
|
|
43
|
+
* `sourceMap` (so a delta citing it fails validation rather than inventing
|
|
44
|
+
* evidence) and it DOES count towards `coverage.summarizedMessages`, because that
|
|
45
|
+
* counter is what tells `validateMemoryDelta` that this head contained something
|
|
46
|
+
* summarizable at all - an all-elided digest that counted zero would let an empty
|
|
47
|
+
* delta through and record a "successful" compaction with an empty memory.
|
|
48
|
+
*/
|
|
26
49
|
export declare function buildDigest(input: DigestInput): DigestResult;
|