@hncode/hncode 0.5.1 → 0.5.4
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/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +19 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/src/agent.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// model answers without tool calls (or a step/interrupt condition is hit).
|
|
4
4
|
|
|
5
5
|
import { LLM, setToolsList } from './llm.js';
|
|
6
|
-
import { tools, getTool, llmTools } from './tools/index.js';
|
|
6
|
+
import { tools, combinedTools, getTool, llmTools } from './tools/index.js';
|
|
7
7
|
import { DELEGATION_TOOLS } from './subagent-types.js';
|
|
8
|
-
import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
|
|
8
|
+
import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, measureToolDefTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
|
|
9
9
|
import { runHooks, runPatch, runPatchSync } from './plugin.js';
|
|
10
10
|
import { loadHooks, runShellHooks } from './hooks.js';
|
|
11
11
|
import {
|
|
@@ -101,7 +101,9 @@ function clipToTokens(text, maxTokens) {
|
|
|
101
101
|
// * it is an assistant message that still has tool calls, or
|
|
102
102
|
// * the message after the cut is a tool result, or
|
|
103
103
|
// * the prefix ends inside an open tool exchange (more results to come).
|
|
104
|
-
|
|
104
|
+
// Exported for the headless -p path (index.js), which must trim a resumed
|
|
105
|
+
// history on the same legal boundaries the compaction logic uses.
|
|
106
|
+
export function canSplitAfter(messages, index) {
|
|
105
107
|
if (index < 0 || index >= messages.length) return false;
|
|
106
108
|
const m = messages[index];
|
|
107
109
|
if (!m) return false;
|
|
@@ -140,9 +142,10 @@ function hashKey(s) {
|
|
|
140
142
|
h = (h * 0x01000193) >>> 0;
|
|
141
143
|
}
|
|
142
144
|
return h.toString(36) + '-' + str.length.toString(36);
|
|
143
|
-
return h.toString(36) + '-' + str.length.toString(36);
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
// Render what an Edit actually changed, for the MODEL's tool result.
|
|
148
|
+
|
|
146
149
|
// Render what an Edit actually changed, for the MODEL's tool result.
|
|
147
150
|
//
|
|
148
151
|
// The UI has always received the edit through `editDiff` (see the tool_result
|
|
@@ -166,10 +169,18 @@ export function formatEditDiffForModel(diff, maxLines = 400) {
|
|
|
166
169
|
const out = [];
|
|
167
170
|
out.push('');
|
|
168
171
|
out.push('--- diff ---');
|
|
172
|
+
// Unchanged lines above and below, unmarked, so the change can be LOCATED. Without them this
|
|
173
|
+
// was a quoted block with no indication of where in the file it landed — and identical-looking
|
|
174
|
+
// code appears in many places, so neither the model nor the user could confirm the edit hit
|
|
175
|
+
// the intended one rather than a same-text block further down.
|
|
176
|
+
const before = (diff.context && Array.isArray(diff.context.before)) ? diff.context.before : [];
|
|
177
|
+
const after = (diff.context && Array.isArray(diff.context.after)) ? diff.context.after : [];
|
|
178
|
+
for (const c of before) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
|
|
169
179
|
let n = startLine;
|
|
170
180
|
for (const l of oldLines) out.push(' ' + String(n++).padStart(4) + ' - ' + l);
|
|
171
181
|
n = startLine;
|
|
172
182
|
for (const l of newLines) out.push(' ' + String(n++).padStart(4) + ' + ' + l);
|
|
183
|
+
for (const c of after) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
|
|
173
184
|
// A whole-file edit (a Write-sized `new`) must not be echoed back in full.
|
|
174
185
|
if (out.length > maxLines + 3) {
|
|
175
186
|
const hidden = out.length - (maxLines + 3);
|
|
@@ -205,6 +216,64 @@ function toolResultLimitsEnabled() {
|
|
|
205
216
|
return !/^(0|off|false|no)$/i.test(String(process.env.HNCODE_TOOL_RESULT_LIMITS || ''));
|
|
206
217
|
}
|
|
207
218
|
|
|
219
|
+
/**
|
|
220
|
+
* A tool's return value, as the STRING the model can be sent.
|
|
221
|
+
*
|
|
222
|
+
* Every tool in `src/tools/` returns a plain string except the team tools, which return
|
|
223
|
+
* `{ content }` / `{ error }`. That object went into the `tool` message verbatim, and a provider
|
|
224
|
+
* that expects a string rejects the NEXT request with `missing type field in content item at
|
|
225
|
+
* index 0` — so a teammate's first `SendMessage` worked and then the conversation died, one
|
|
226
|
+
* message in. The symptom was a team that says hello and then never answers again.
|
|
227
|
+
*
|
|
228
|
+
* Returns `null` for the one shape that must stay an OBJECT: `ReadMediaFile` returns
|
|
229
|
+
* `{ text, media: [...] }`, and the media parts travel as structured blocks the caller builds
|
|
230
|
+
* from the object. Stringifying that would attach no image at all, so the caller keeps it.
|
|
231
|
+
*
|
|
232
|
+
* Normalised here, at the single point every tool result passes through, rather than in the
|
|
233
|
+
* team tools: a second tool family making the same choice cannot reintroduce the bug.
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* Settle `promise`, or reject as soon as `signal` aborts — whichever happens first.
|
|
237
|
+
*
|
|
238
|
+
* A tool is GIVEN the abort signal but cannot be forced to observe it. Built-ins do (Bash kills its
|
|
239
|
+
* process tree); a plugin tool that ignores it kept running, and the caller's `await` kept waiting, so
|
|
240
|
+
* pressing Esc during a slow plugin tool changed nothing on screen until the plugin finished on its own.
|
|
241
|
+
*
|
|
242
|
+
* Racing ends the WAIT regardless. The abandoned promise is left to settle detached — an unobserved
|
|
243
|
+
* rejection would otherwise surface as an unhandled rejection — and the turn moves on, which is what
|
|
244
|
+
* the key was asking for. It cannot stop the plugin's work, and this comment says so rather than
|
|
245
|
+
* implying otherwise.
|
|
246
|
+
*/
|
|
247
|
+
function raceWithAbort(promise, signal) {
|
|
248
|
+
if (!signal) return promise;
|
|
249
|
+
if (signal.aborted) return Promise.reject(new Error('aborted'));
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
const onAbort = () => reject(new Error('aborted'));
|
|
252
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
253
|
+
Promise.resolve(promise).then(
|
|
254
|
+
(v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
|
|
255
|
+
(e) => { signal.removeEventListener('abort', onAbort); reject(e); },
|
|
256
|
+
);
|
|
257
|
+
// A tool that resolves AFTER the abort must not become an unhandled rejection.
|
|
258
|
+
Promise.resolve(promise).catch(() => {});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function toolResultText(value) {
|
|
263
|
+
if (typeof value === 'string') return value;
|
|
264
|
+
if (value == null) return '';
|
|
265
|
+
if (typeof value === 'object') {
|
|
266
|
+
// Leave a media result alone — the caller needs the parts, not a sentence about them.
|
|
267
|
+
if (Array.isArray(value.media)) return null;
|
|
268
|
+
if (typeof value.content === 'string') return value.content;
|
|
269
|
+
if (typeof value.error === 'string') return value.error;
|
|
270
|
+
if (typeof value.text === 'string') return value.text;
|
|
271
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
272
|
+
}
|
|
273
|
+
return String(value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
208
277
|
|
|
209
278
|
export const SYSTEM_PROMPT = `You are hncode, a coding agent in the user's terminal on ${process.platform}.
|
|
210
279
|
You work inside a workspace directory using the tools provided.
|
|
@@ -280,9 +349,9 @@ export class Agent {
|
|
|
280
349
|
// even if the filter would have allowed them. The catalog already excludes
|
|
281
350
|
// them from every profile; this is the hard backstop that also covers the
|
|
282
351
|
// `null` (= everything) case, so a subagent can never spawn another agent.
|
|
283
|
-
|
|
284
|
-
?
|
|
285
|
-
:
|
|
352
|
+
const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
353
|
+
? combinedTools().filter((t) => cfg.toolFilter.includes(t.name))
|
|
354
|
+
: combinedTools()
|
|
286
355
|
)
|
|
287
356
|
.filter((t) => !(cfg.noDelegation && DELEGATION_TOOLS.includes(t.name)))
|
|
288
357
|
// AgentSwarm is gated behind swarm mode, like Agent is gated behind the user
|
|
@@ -293,7 +362,26 @@ export class Agent {
|
|
|
293
362
|
// backed by anything. Off unless /swarm turned the mode on; the Agent tool
|
|
294
363
|
// stays available for a single, deliberate delegation.
|
|
295
364
|
.filter((t) => !(t.name === 'AgentSwarm' && !cfg.swarm));
|
|
365
|
+
// Kept as the process-wide FALLBACK for LLM instances that never register
|
|
366
|
+
// their own list (doctor's connectivity probe). Every real agent registers
|
|
367
|
+
// its list on its own LLM below — requests no longer read whichever Agent
|
|
368
|
+
// happened to be constructed last.
|
|
296
369
|
setToolsList(filtered);
|
|
370
|
+
// THE REAL COST OF THE TOOLS, measured once per agent.
|
|
371
|
+
//
|
|
372
|
+
// The context gauge charged a flat 8 tokens per tool, which is short by an order of magnitude — the
|
|
373
|
+
// schemas of these 68 tools come to about 15 900 tokens. That figure was missing from every
|
|
374
|
+
// usage estimate, so the app's own count ran tens of thousands below the provider's.
|
|
375
|
+
//
|
|
376
|
+
// Measured here because this is the only place that knows the FINAL set: `toolFilter`, the
|
|
377
|
+
// subagent block and the swarm gate above all change what is sent, and a number derived from
|
|
378
|
+
// `cfg.toolFilter` alone would price tools the model never sees.
|
|
379
|
+
this.toolDefTokens = measureToolDefTokens(filtered);
|
|
380
|
+
// ALSO on `cfg`, because the TUI's gauge writers live outside this object and price a request as
|
|
381
|
+
// `estimateMessagesTokens(...) + cfg.toolDefTokens`. Without this line those readers saw `undefined`
|
|
382
|
+
// and silently fell back to zero, so every restore and every /trim reported a number short by the
|
|
383
|
+
// whole tool block — the same fixed gap, from a different cause.
|
|
384
|
+
if (this.cfg && typeof this.cfg === 'object') this.cfg.toolDefTokens = this.toolDefTokens;
|
|
297
385
|
// A block for the direct-call path too: the model can still emit a tool call
|
|
298
386
|
// for a tool that is not exposed, so refuse it here rather than at execution.
|
|
299
387
|
this.noDelegation = !!cfg.noDelegation;
|
|
@@ -345,10 +433,20 @@ export class Agent {
|
|
|
345
433
|
this.cfg = cfg;
|
|
346
434
|
}
|
|
347
435
|
this.llm = new LLM(cfg);
|
|
436
|
+
// THIS agent's tool defs ride on THIS instance. The module-global was overwritten
|
|
437
|
+
// by every `new Agent()` in the process — a subagent, swarm member or teammate
|
|
438
|
+
// constructed while a parent request was being built swapped the parent's tool
|
|
439
|
+
// list mid-turn, silently stripping Edit/Bash from its next request.
|
|
440
|
+
this.llm.setTools(filtered);
|
|
348
441
|
this.stopRequested = false;
|
|
349
442
|
// Auto-compaction is allowed at most once per TURN (not per step). Reset here so
|
|
350
443
|
// a caller that reuses one Agent across turns still gets a fresh allowance.
|
|
351
444
|
this._compactedThisTurn = false;
|
|
445
|
+
// THE PROMPT-CACHE BOUNDARY: how many messages an earlier request of this turn
|
|
446
|
+
// has already sent. Everything below that index is stable and can carry a cache
|
|
447
|
+
// breakpoint. Reset per turn because a new turn opens with a new user message, so
|
|
448
|
+
// the previous turn's boundary would describe a prefix this one never sent.
|
|
449
|
+
this._stableEnd = 0;
|
|
352
450
|
// Rolling context-token total for `this.messages` (see usedTokens). It holds no
|
|
353
451
|
// messages of its own, only the count of what it has already priced, so a
|
|
354
452
|
// compaction that replaces the array simply falls out of the fast path.
|
|
@@ -401,10 +499,19 @@ export class Agent {
|
|
|
401
499
|
// rescan priced the entire transcript — Read results included — several times per
|
|
402
500
|
// step for a number that changes by one message.
|
|
403
501
|
let total = this._ledger.sync(this.messages, null);
|
|
404
|
-
|
|
502
|
+
// THE TOOL DEFINITIONS ARE PART OF WHAT IS SENT, and they are not messages.
|
|
503
|
+
//
|
|
504
|
+
// This charged `toolFilter.length * 8` — and only when a filter was set, so an ordinary turn
|
|
505
|
+
// added NOTHING at all. Measured, the schemas of the 68 registered tools come to about 15 900
|
|
506
|
+
// tokens, which is why the app's own gauge ran tens of thousands below the provider's report.
|
|
507
|
+
//
|
|
508
|
+
// `toolDefTokens` is measured once in the constructor, from the FINAL filtered set.
|
|
509
|
+
total += Number.isFinite(this.toolDefTokens) ? this.toolDefTokens : 0;
|
|
405
510
|
return total;
|
|
406
511
|
}
|
|
407
512
|
|
|
513
|
+
|
|
514
|
+
|
|
408
515
|
// Where to cut the history for compaction. Walks back from the end accumulating a
|
|
409
516
|
// token budget, but only accepts indices the boundary rule allows (so the kept
|
|
410
517
|
// slice can never begin on an orphan tool result). Returns how many messages to
|
|
@@ -420,9 +527,10 @@ export class Agent {
|
|
|
420
527
|
const used = this.usedTokens();
|
|
421
528
|
const maxSize = Math.max(1, Math.floor((used || (maxCtx || 512000) * 0.85) * keepRatio));
|
|
422
529
|
const msgs = this.messages || [];
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
530
|
+
// How far the LEADING system block runs. A cut before `floor` would land inside it,
|
|
531
|
+
// splitting the assembled system prompt across the drop boundary. The block itself is
|
|
532
|
+
// carried over by the trim (renamed `leadingSystem` there), because a cut can only
|
|
533
|
+
// ever land at or after its end.
|
|
426
534
|
let floor = 0;
|
|
427
535
|
while (floor < msgs.length && msgs[floor] && msgs[floor].role === 'system') floor++;
|
|
428
536
|
let recent = 1;
|
|
@@ -435,12 +543,68 @@ export class Agent {
|
|
|
435
543
|
// call. It is also the memoised form, so repeating this walk after a large tool
|
|
436
544
|
// result is a WeakMap hit rather than a re-measure.
|
|
437
545
|
size += messageTokens(msgs[split], null);
|
|
438
|
-
|
|
546
|
+
// The guard the leading system block needs, and the reverse of the `split > floor`
|
|
547
|
+
// this used to test. A cut at `split` drops `[0..split-1]`, so `split >= floor`
|
|
548
|
+
// keeps the cut at or after the block's end and never inside it — which is what
|
|
549
|
+
// the block's comment above asks for, and what `split > floor` inverted.
|
|
550
|
+
if (split >= floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
|
|
439
551
|
if (size >= maxSize && bestSplit !== undefined) break;
|
|
440
552
|
}
|
|
441
553
|
return bestSplit ?? 0;
|
|
442
554
|
}
|
|
443
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Trim the history at a legal cut and append the summary + continuation note.
|
|
558
|
+
*
|
|
559
|
+
* Extracted from `run()` so it can be tested against the REAL implementation. A test that
|
|
560
|
+
* re-implemented these four lines was green against a duplicated system prompt — the copy
|
|
561
|
+
* carried its own guard, which is the failure mode of testing a restatement.
|
|
562
|
+
*
|
|
563
|
+
* Returns `{ split, dropped }`.
|
|
564
|
+
*
|
|
565
|
+
* Shape: `[ ...lead, ...kept, summary, continuation ]`.
|
|
566
|
+
*
|
|
567
|
+
* The summary is a USER message wrapped in a `<system-reminder>` (kimi's shape), not a
|
|
568
|
+
* `system` one. Two reasons:
|
|
569
|
+
* * the TUI rebuilds the system prompt from scratch each turn and SKIPS every `system`
|
|
570
|
+
* entry in the stored history, so a system summary was dropped before the model saw it;
|
|
571
|
+
* * the turn-end save filters `system` out of the session, so it was not persisted either
|
|
572
|
+
* — history trimmed AND summary lost at both ends, which made "compaction" pure data loss.
|
|
573
|
+
* The continuation note goes LAST so the model's next move is "keep going" rather than
|
|
574
|
+
* "answer the summary".
|
|
575
|
+
*/
|
|
576
|
+
applyCompactionSplit(maxCtx, summary) {
|
|
577
|
+
const split = this.computeCompactSplit(maxCtx);
|
|
578
|
+
const keep = this.messages.slice(split);
|
|
579
|
+
const dropped = this.messages.length - keep.length;
|
|
580
|
+
// The LEADING system block is carried over explicitly. It sits at the head of the array
|
|
581
|
+
// and the cut drops everything before `split`, so it would otherwise be taken by every
|
|
582
|
+
// compaction — leaving the request with no instructions, which is the loss the `floor`
|
|
583
|
+
// guard in computeCompactSplit exists to prevent.
|
|
584
|
+
let leadingSystem = 0;
|
|
585
|
+
while (leadingSystem < this.messages.length
|
|
586
|
+
&& this.messages[leadingSystem] && this.messages[leadingSystem].role === 'system') leadingSystem++;
|
|
587
|
+
// Only what the cut actually REMOVED. `keep` is `slice(split)`, so at `split === 0` it
|
|
588
|
+
// still contains the whole array including the block — prepending the block then sends the
|
|
589
|
+
// system prompt twice, and `llm.js` concatenates every system message into the request, so
|
|
590
|
+
// it would stay doubled in every compacted request from then on.
|
|
591
|
+
const lead = split > 0 ? this.messages.slice(0, leadingSystem) : [];
|
|
592
|
+
const summaryText = summary
|
|
593
|
+
? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
|
|
594
|
+
: `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
|
|
595
|
+
this.messages = [
|
|
596
|
+
...lead,
|
|
597
|
+
...keep,
|
|
598
|
+
{ role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
|
|
599
|
+
{ role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
|
|
600
|
+
];
|
|
601
|
+
// `kept` is returned, not just its length: the caller reports how many messages survived, and it
|
|
602
|
+
// has no other way to know. It was `keep.length` at the call site referring to a local that lives
|
|
603
|
+
// only in here — a ReferenceError raised inside `run()`, which reached unhandledRejection and exited
|
|
604
|
+
// the whole TUI at the moment compaction fired.
|
|
605
|
+
return { split, dropped, kept: keep.length };
|
|
606
|
+
}
|
|
607
|
+
|
|
444
608
|
async run() {
|
|
445
609
|
// Clear any interrupt flag left from a previous (interrupted) turn. This runs at
|
|
446
610
|
// the start of EVERY turn, so a stalled Esc/Ctrl-C from an earlier compaction can
|
|
@@ -497,10 +661,15 @@ export class Agent {
|
|
|
497
661
|
this._truncated = true;
|
|
498
662
|
break;
|
|
499
663
|
case 'usage':
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
// and
|
|
664
|
+
// Provider accounting, accumulated on the AGENT so it survives across the steps of one turn.
|
|
665
|
+
//
|
|
666
|
+
// IT DOES NOT FEED THE CONTEXT GAUGE. `usage.input` was wired into both the gauge and a
|
|
667
|
+
// calibration here, and removed from both: it counts the request that already went out while the
|
|
668
|
+
// estimate counts the one about to, so the number jumped between two definitions depending on
|
|
669
|
+
// which ran last.
|
|
670
|
+
//
|
|
671
|
+
// What it IS used for: cost and `/usage`, where it is the only honest source, and the turn's
|
|
672
|
+
// output counter.
|
|
504
673
|
this.usage = addUsage(this.usage, e.usage);
|
|
505
674
|
this.onEvent({ type: 'usage', usage: e.usage, total: this.usage });
|
|
506
675
|
break;
|
|
@@ -573,9 +742,38 @@ export class Agent {
|
|
|
573
742
|
// below 85%, so the reply has room to be generated.
|
|
574
743
|
const maxCtx = this.cfg.maxContextTokens || 512000;
|
|
575
744
|
let usedNow = this.usedTokens();
|
|
745
|
+
// THE MESSAGE THAT TRIGGERED THIS TURN IS NOT YET IN A REQUEST.
|
|
746
|
+
//
|
|
747
|
+
// The user's message is appended before `run()` is entered, so a fresh turn that pushes the
|
|
748
|
+
// history over the threshold would compact FIRST and then send — summarising a conversation that
|
|
749
|
+
// now includes the message it is about to answer, and paying a summary round-trip before the model
|
|
750
|
+
// has seen the request at all.
|
|
751
|
+
//
|
|
752
|
+
// Excluding the trailing user message from the TRIGGER means the decision is made on what the
|
|
753
|
+
// previous turn left behind, which is the history the message is joining. If adding it would still
|
|
754
|
+
// overflow, `needsRoom` catches that on the NEXT pass — after the model has answered something —
|
|
755
|
+
// and `clipToTokens` bounds the request itself in the meantime.
|
|
756
|
+
//
|
|
757
|
+
// Counted as a subtraction rather than by pruning the array: the message must stay in `messages`,
|
|
758
|
+
// it just must not decide whether to compact.
|
|
759
|
+
let triggerTokens = usedNow;
|
|
760
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
761
|
+
const m = this.messages[i];
|
|
762
|
+
if (!m) continue;
|
|
763
|
+
// Only skip a TRAILING run of user messages — a steering note queued mid-turn, or the prompt that
|
|
764
|
+
// opened this turn. A user message further back is part of the history being measured.
|
|
765
|
+
if (m.role !== 'user' || m._harness) break;
|
|
766
|
+
triggerTokens -= messageTokens(m, this.cfg);
|
|
767
|
+
}
|
|
768
|
+
triggerTokens = Math.max(0, triggerTokens);
|
|
576
769
|
// `reservedContextSize` (kimi): a request leaving less than this much headroom
|
|
577
770
|
// is compacted even below the trigger ratio, so the REPLY has room to be
|
|
578
771
|
// generated. A request just under the trigger could otherwise still fail.
|
|
772
|
+
//
|
|
773
|
+
// Measured on `usedNow`, NOT on `triggerTokens`: the headroom question is about the request that
|
|
774
|
+
// will actually be sent, and that one does include the new message. The two thresholds ask
|
|
775
|
+
// different things — "is the history full" versus "will this request fit" — so they read different
|
|
776
|
+
// numbers on purpose.
|
|
579
777
|
const reserved = this.cfg.reservedContextTokens || 50_000;
|
|
580
778
|
const needsRoom = reserved > 0 && reserved < maxCtx && usedNow + reserved >= maxCtx;
|
|
581
779
|
// Trigger ratio is configurable (/auto-compact threshold, config.toml
|
|
@@ -588,7 +786,8 @@ export class Agent {
|
|
|
588
786
|
// kept shrinking.
|
|
589
787
|
const shouldCompact = this.cfg.autoCompact !== false && maxCtx > 0
|
|
590
788
|
&& !this._compactedThisTurn
|
|
591
|
-
|
|
789
|
+
// `triggerTokens`, not `usedNow`: the history WITHOUT the message that opened this turn.
|
|
790
|
+
&& (triggerTokens >= maxCtx * trigger || needsRoom);
|
|
592
791
|
if (shouldCompact) {
|
|
593
792
|
const before = usedNow;
|
|
594
793
|
this._compactedThisTurn = true;
|
|
@@ -654,30 +853,18 @@ export class Agent {
|
|
|
654
853
|
this.onEvent({ type: 'stopped' });
|
|
655
854
|
break;
|
|
656
855
|
}
|
|
856
|
+
// The summarizer FAILED (requestText swallowed a network/429 error — even
|
|
857
|
+
// after its retry — and returned null) or came back capped-empty. Trimming
|
|
858
|
+
// now would replace real history with "(no summary available)": the exact
|
|
859
|
+
// silent data loss the comment above warns about. Leave the history intact
|
|
860
|
+
// and let the turn continue un-compacted; if the fat request genuinely
|
|
861
|
+
// cannot fit, the provider's context-length error is an honest, recoverable
|
|
862
|
+
// failure instead of a vanished transcript.
|
|
863
|
+
if (!summary && this.messages.length > 0) {
|
|
864
|
+
this.onEvent({ type: 'compaction_cancelled' });
|
|
865
|
+
} else {
|
|
657
866
|
// NOW trim, at a split point the boundary rule allows.
|
|
658
|
-
const split = this.
|
|
659
|
-
const keep = this.messages.slice(split);
|
|
660
|
-
const droppedCount = this.messages.length - keep.length;
|
|
661
|
-
// Shape: [ ...kept, summary, continuation ].
|
|
662
|
-
//
|
|
663
|
-
// The summary is a USER message wrapped in a <system-reminder> (kimi's
|
|
664
|
-
// shape), not a `system` message. Two reasons:
|
|
665
|
-
// * the TUI rebuilds the system prompt from scratch each turn and SKIPS
|
|
666
|
-
// every `system` entry in the stored history, so a system summary was
|
|
667
|
-
// dropped before the model ever saw it;
|
|
668
|
-
// * the turn-end save filters `system` out of the session, so it was not
|
|
669
|
-
// persisted either — history trimmed AND summary lost at both ends,
|
|
670
|
-
// which made "compaction" pure data loss.
|
|
671
|
-
// The continuation note goes LAST so the model's next move is "keep going"
|
|
672
|
-
// rather than "answer the summary".
|
|
673
|
-
const summaryText = summary
|
|
674
|
-
? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
|
|
675
|
-
: `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
|
|
676
|
-
this.messages = [
|
|
677
|
-
...keep,
|
|
678
|
-
{ role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
|
|
679
|
-
{ role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
|
|
680
|
-
];
|
|
867
|
+
const { split, dropped: droppedCount, kept } = this.applyCompactionSplit(maxCtx, summary);
|
|
681
868
|
// The array was REPLACED, so every count the ledger held describes messages
|
|
682
869
|
// that no longer exist. `sync` would notice on its own (the first/last
|
|
683
870
|
// identities no longer match), but resetting here states the intent at the
|
|
@@ -685,13 +872,14 @@ export class Agent {
|
|
|
685
872
|
this._ledger.reset();
|
|
686
873
|
usedNow = this.usedTokens();
|
|
687
874
|
this.onEvent({
|
|
688
|
-
type: 'compacted', before, after: usedNow, kept
|
|
875
|
+
type: 'compacted', before, after: usedNow, kept,
|
|
689
876
|
dropped: droppedCount, summary,
|
|
690
877
|
});
|
|
691
878
|
// SHELL HOOK: PostCompact. Fires after the trim, so a hook can archive the
|
|
692
879
|
// summary or record the shrink. Never blocks.
|
|
693
880
|
await runShellHooks(this.hookConfig, 'PostCompact',
|
|
694
|
-
{ before, after: usedNow, kept
|
|
881
|
+
{ before, after: usedNow, kept, dropped: droppedCount, summary }, this.cfg.workspace);
|
|
882
|
+
}
|
|
695
883
|
}
|
|
696
884
|
// ---- tool-result trimming ----
|
|
697
885
|
// The cheap pass, and the one that actually runs in practice: a history is
|
|
@@ -748,11 +936,31 @@ export class Agent {
|
|
|
748
936
|
}
|
|
749
937
|
}
|
|
750
938
|
|
|
939
|
+
// THE CACHE BOUNDARY: everything BEFORE this index was already sent in an
|
|
940
|
+
// earlier request of this turn and has not changed since. It is captured
|
|
941
|
+
// here — AFTER the previous step's tool results are committed, BEFORE the
|
|
942
|
+
// steering drain appends anything new — so it marks exactly the prefix the
|
|
943
|
+
// provider can reuse.
|
|
944
|
+
//
|
|
945
|
+
// This used to be derived from the message COUNT ("keep the last two"), which
|
|
946
|
+
// is the wrong question. One agent step appends an assistant message plus one
|
|
947
|
+
// tool result PER call, so a step that read four files left five fresh
|
|
948
|
+
// messages; a breakpoint two back sat in the middle of that and the provider
|
|
949
|
+
// had to re-prefill all of it. Counting the boundary from the previous
|
|
950
|
+
// request instead means a step of any size leaves exactly one stable prefix.
|
|
951
|
+
// A COMPACTION REPLACED THE ARRAY, so the boundary measured against the old
|
|
952
|
+
// one no longer describes anything in this one. Clamping would place the
|
|
953
|
+
// breakpoint at a message that never existed when the value was taken.
|
|
954
|
+
if (this._stableEnd > this.messages.length) this._stableEnd = 0;
|
|
955
|
+
if (this._stableEnd !== this.messages.length) this._stableEnd = this.messages.length;
|
|
956
|
+
const cacheEnd = this._stableEnd - 1;
|
|
957
|
+
|
|
958
|
+
|
|
751
959
|
// Plugin hooks around the model round-trip. `onBeforeRequest` sees the exact
|
|
752
|
-
// message array being sent (and may mutate it); `
|
|
960
|
+
// message array being sent (and may mutate it); `onAfterResponse` fires once
|
|
753
961
|
// the stream has been consumed.
|
|
754
962
|
await runHooks('onBeforeRequest', this.messages, this.cfg);
|
|
755
|
-
await this.llm.request(this.messages, handle);
|
|
963
|
+
await this.llm.request(this.messages, handle, cacheEnd);
|
|
756
964
|
await runHooks('onAfterRequest', this.messages, this.cfg);
|
|
757
965
|
|
|
758
966
|
// An abort ends the turn: keep whatever text arrived, then stop.
|
|
@@ -889,13 +1097,20 @@ export class Agent {
|
|
|
889
1097
|
return { ok: false, result, editDiff: null, media: false };
|
|
890
1098
|
}
|
|
891
1099
|
|
|
892
|
-
// Permission check before executing each tool
|
|
1100
|
+
// Permission check before executing each tool.
|
|
1101
|
+
//
|
|
1102
|
+
// The hook may answer with a BOOLEAN (the original contract) or with `{ approved, error }`. The
|
|
1103
|
+
// object form exists for the team mailbox, where a refusal travels with a reason the model can act
|
|
1104
|
+
// on: "Blocked by user" tells it nothing about what to try instead, and a member that has been
|
|
1105
|
+
// refused for a specific reason can re-plan.
|
|
893
1106
|
let result = null;
|
|
894
1107
|
let ok = true;
|
|
895
1108
|
if (this.onApproval) {
|
|
896
|
-
const
|
|
1109
|
+
const verdict = await this.onApproval(tc.name, tc.args);
|
|
1110
|
+
const approved = verdict && typeof verdict === 'object' ? verdict.approved : verdict;
|
|
897
1111
|
if (!approved) {
|
|
898
|
-
|
|
1112
|
+
const why = verdict && typeof verdict === 'object' && verdict.error ? `: ${verdict.error}` : '.';
|
|
1113
|
+
result = `Blocked by user: tool "${tc.name}" was rejected${why}`;
|
|
899
1114
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
900
1115
|
return { ok: false, result, editDiff: null, media: false };
|
|
901
1116
|
}
|
|
@@ -931,7 +1146,23 @@ export class Agent {
|
|
|
931
1146
|
let _args = tc.args; let _skip = false;
|
|
932
1147
|
try { const out = await runPatch('toolDispatch', { name: tc.name, args: tc.args, ctx: this.ctx }, (c) => c); if (out) { _args = out.args !== undefined ? out.args : tc.args; if (out.skip) _skip = true; } } catch (e) { console.error('[hncode-plugin] toolDispatch error:', e.message); }
|
|
933
1148
|
if (_skip) { result = '[skipped by plugin: ' + tc.name + ']'; ok = true; }
|
|
934
|
-
else
|
|
1149
|
+
else {
|
|
1150
|
+
// RACE THE ABORT SIGNAL, do not merely pass it.
|
|
1151
|
+
//
|
|
1152
|
+
// `ctx.signal` is handed to every tool, but nothing enforces that a tool LISTENS. A built-in
|
|
1153
|
+
// does — Bash kills its process tree — while a plugin tool that ignores it kept running, and
|
|
1154
|
+
// this `await` kept waiting for it: Esc reached `interrupt()`, aborted the controller, and the
|
|
1155
|
+
// row still said "Using …" until the plugin happened to finish.
|
|
1156
|
+
//
|
|
1157
|
+
// Aborting the signal now ends the wait immediately whatever the tool is doing. The abandoned
|
|
1158
|
+
// promise keeps running detached — a plugin that ignores the signal cannot be forced to stop —
|
|
1159
|
+
// but the TURN is no longer held by it, which is what pressing Esc actually asked for.
|
|
1160
|
+
const raw = await raceWithAbort(tool.execute(_args, this.ctx), this.toolAbort.signal);
|
|
1161
|
+
// `null` means the tool returned a structured MEDIA result: keep the object so the
|
|
1162
|
+
// image blocks can be built from it. Only a plain value is stringified.
|
|
1163
|
+
const text = toolResultText(raw);
|
|
1164
|
+
result = text === null ? raw : text;
|
|
1165
|
+
}
|
|
935
1166
|
} catch (err) {
|
|
936
1167
|
result = `Error running ${tc.name}: ${err.message}`;
|
|
937
1168
|
ok = false;
|