@hncode/hncode 0.5.2 → 0.5.5
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/package.json +38 -38
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/config.js +10 -3
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/session-hub.js +37 -0
- package/src/settings-schema.js +2 -0
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +727 -577
- package/src/tools/fetch-url.js +118 -5
- 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 +17 -3
- package/src/tools/read.js +13 -2
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +85 -34
- package/src/tui.js +2089 -714
- package/src/update.js +38 -3
- package/src/web.js +47 -1
package/package.json
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@hncode/hncode",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/index.js",
|
|
7
|
-
"bin": {
|
|
8
|
-
"hncode": "bin/hncode"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"bin/",
|
|
12
|
-
"src/",
|
|
13
|
-
"LICENSE",
|
|
14
|
-
"README.md",
|
|
15
|
-
"README_CN.md",
|
|
16
|
-
"package.json"
|
|
17
|
-
],
|
|
18
|
-
"scripts": {
|
|
19
|
-
"preinstall": "node bin/preinstall.cjs",
|
|
20
|
-
"install": "node bin/install.cjs",
|
|
21
|
-
"i18n": "node tools/sync-i18n.mjs",
|
|
22
|
-
"i18n:check": "node tools/sync-i18n.mjs --check",
|
|
23
|
-
"test": "node --test"
|
|
24
|
-
},
|
|
25
|
-
"engines": {
|
|
26
|
-
"node": ">=20"
|
|
27
|
-
},
|
|
28
|
-
"license": "PolyForm-Noncommercial-1.0.0",
|
|
29
|
-
"author": "NiceHello666",
|
|
30
|
-
"keywords": [
|
|
31
|
-
"cli",
|
|
32
|
-
"agent",
|
|
33
|
-
"coding",
|
|
34
|
-
"ai",
|
|
35
|
-
"tui"
|
|
36
|
-
],
|
|
37
|
-
"dependencies": {}
|
|
38
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@hncode/hncode",
|
|
3
|
+
"version": "0.5.5",
|
|
4
|
+
"description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"hncode": "bin/hncode"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md",
|
|
15
|
+
"README_CN.md",
|
|
16
|
+
"package.json"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"preinstall": "node bin/preinstall.cjs",
|
|
20
|
+
"install": "node bin/install.cjs",
|
|
21
|
+
"i18n": "node tools/sync-i18n.mjs",
|
|
22
|
+
"i18n:check": "node tools/sync-i18n.mjs --check",
|
|
23
|
+
"test": "node --test \"test/**/*.test.mjs\""
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
29
|
+
"author": "NiceHello666",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"cli",
|
|
32
|
+
"agent",
|
|
33
|
+
"coding",
|
|
34
|
+
"ai",
|
|
35
|
+
"tui"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {}
|
|
38
|
+
}
|
package/src/agent.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { LLM, setToolsList } from './llm.js';
|
|
6
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;
|
|
@@ -230,6 +232,33 @@ function toolResultLimitsEnabled() {
|
|
|
230
232
|
* Normalised here, at the single point every tool result passes through, rather than in the
|
|
231
233
|
* team tools: a second tool family making the same choice cannot reintroduce the bug.
|
|
232
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
|
+
|
|
233
262
|
function toolResultText(value) {
|
|
234
263
|
if (typeof value === 'string') return value;
|
|
235
264
|
if (value == null) return '';
|
|
@@ -333,7 +362,26 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
333
362
|
// backed by anything. Off unless /swarm turned the mode on; the Agent tool
|
|
334
363
|
// stays available for a single, deliberate delegation.
|
|
335
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.
|
|
336
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;
|
|
337
385
|
// A block for the direct-call path too: the model can still emit a tool call
|
|
338
386
|
// for a tool that is not exposed, so refuse it here rather than at execution.
|
|
339
387
|
this.noDelegation = !!cfg.noDelegation;
|
|
@@ -385,10 +433,20 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
385
433
|
this.cfg = cfg;
|
|
386
434
|
}
|
|
387
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);
|
|
388
441
|
this.stopRequested = false;
|
|
389
442
|
// Auto-compaction is allowed at most once per TURN (not per step). Reset here so
|
|
390
443
|
// a caller that reuses one Agent across turns still gets a fresh allowance.
|
|
391
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;
|
|
392
450
|
// Rolling context-token total for `this.messages` (see usedTokens). It holds no
|
|
393
451
|
// messages of its own, only the count of what it has already priced, so a
|
|
394
452
|
// compaction that replaces the array simply falls out of the fast path.
|
|
@@ -441,10 +499,19 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
441
499
|
// rescan priced the entire transcript — Read results included — several times per
|
|
442
500
|
// step for a number that changes by one message.
|
|
443
501
|
let total = this._ledger.sync(this.messages, null);
|
|
444
|
-
|
|
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;
|
|
445
510
|
return total;
|
|
446
511
|
}
|
|
447
512
|
|
|
513
|
+
|
|
514
|
+
|
|
448
515
|
// Where to cut the history for compaction. Walks back from the end accumulating a
|
|
449
516
|
// token budget, but only accepts indices the boundary rule allows (so the kept
|
|
450
517
|
// slice can never begin on an orphan tool result). Returns how many messages to
|
|
@@ -531,11 +598,14 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
531
598
|
{ role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
|
|
532
599
|
{ role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
|
|
533
600
|
];
|
|
534
|
-
|
|
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 };
|
|
535
606
|
}
|
|
536
607
|
|
|
537
608
|
async run() {
|
|
538
|
-
// Clear any interrupt flag left from a previous (interrupted) turn. This runs at
|
|
539
609
|
// Clear any interrupt flag left from a previous (interrupted) turn. This runs at
|
|
540
610
|
// the start of EVERY turn, so a stalled Esc/Ctrl-C from an earlier compaction can
|
|
541
611
|
// never suppress auto-compaction on the next message. (The constructor also
|
|
@@ -591,10 +661,15 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
591
661
|
this._truncated = true;
|
|
592
662
|
break;
|
|
593
663
|
case 'usage':
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
// 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.
|
|
598
673
|
this.usage = addUsage(this.usage, e.usage);
|
|
599
674
|
this.onEvent({ type: 'usage', usage: e.usage, total: this.usage });
|
|
600
675
|
break;
|
|
@@ -667,9 +742,38 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
667
742
|
// below 85%, so the reply has room to be generated.
|
|
668
743
|
const maxCtx = this.cfg.maxContextTokens || 512000;
|
|
669
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);
|
|
670
769
|
// `reservedContextSize` (kimi): a request leaving less than this much headroom
|
|
671
770
|
// is compacted even below the trigger ratio, so the REPLY has room to be
|
|
672
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.
|
|
673
777
|
const reserved = this.cfg.reservedContextTokens || 50_000;
|
|
674
778
|
const needsRoom = reserved > 0 && reserved < maxCtx && usedNow + reserved >= maxCtx;
|
|
675
779
|
// Trigger ratio is configurable (/auto-compact threshold, config.toml
|
|
@@ -682,7 +786,8 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
682
786
|
// kept shrinking.
|
|
683
787
|
const shouldCompact = this.cfg.autoCompact !== false && maxCtx > 0
|
|
684
788
|
&& !this._compactedThisTurn
|
|
685
|
-
|
|
789
|
+
// `triggerTokens`, not `usedNow`: the history WITHOUT the message that opened this turn.
|
|
790
|
+
&& (triggerTokens >= maxCtx * trigger || needsRoom);
|
|
686
791
|
if (shouldCompact) {
|
|
687
792
|
const before = usedNow;
|
|
688
793
|
this._compactedThisTurn = true;
|
|
@@ -748,8 +853,18 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
748
853
|
this.onEvent({ type: 'stopped' });
|
|
749
854
|
break;
|
|
750
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 {
|
|
751
866
|
// NOW trim, at a split point the boundary rule allows.
|
|
752
|
-
const { split, dropped: droppedCount } = this.applyCompactionSplit(maxCtx, summary);
|
|
867
|
+
const { split, dropped: droppedCount, kept } = this.applyCompactionSplit(maxCtx, summary);
|
|
753
868
|
// The array was REPLACED, so every count the ledger held describes messages
|
|
754
869
|
// that no longer exist. `sync` would notice on its own (the first/last
|
|
755
870
|
// identities no longer match), but resetting here states the intent at the
|
|
@@ -757,13 +872,14 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
757
872
|
this._ledger.reset();
|
|
758
873
|
usedNow = this.usedTokens();
|
|
759
874
|
this.onEvent({
|
|
760
|
-
type: 'compacted', before, after: usedNow, kept
|
|
875
|
+
type: 'compacted', before, after: usedNow, kept,
|
|
761
876
|
dropped: droppedCount, summary,
|
|
762
877
|
});
|
|
763
878
|
// SHELL HOOK: PostCompact. Fires after the trim, so a hook can archive the
|
|
764
879
|
// summary or record the shrink. Never blocks.
|
|
765
880
|
await runShellHooks(this.hookConfig, 'PostCompact',
|
|
766
|
-
{ before, after: usedNow, kept
|
|
881
|
+
{ before, after: usedNow, kept, dropped: droppedCount, summary }, this.cfg.workspace);
|
|
882
|
+
}
|
|
767
883
|
}
|
|
768
884
|
// ---- tool-result trimming ----
|
|
769
885
|
// The cheap pass, and the one that actually runs in practice: a history is
|
|
@@ -820,11 +936,31 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
820
936
|
}
|
|
821
937
|
}
|
|
822
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
|
+
|
|
823
959
|
// Plugin hooks around the model round-trip. `onBeforeRequest` sees the exact
|
|
824
|
-
// message array being sent (and may mutate it); `
|
|
960
|
+
// message array being sent (and may mutate it); `onAfterResponse` fires once
|
|
825
961
|
// the stream has been consumed.
|
|
826
962
|
await runHooks('onBeforeRequest', this.messages, this.cfg);
|
|
827
|
-
await this.llm.request(this.messages, handle);
|
|
963
|
+
await this.llm.request(this.messages, handle, cacheEnd);
|
|
828
964
|
await runHooks('onAfterRequest', this.messages, this.cfg);
|
|
829
965
|
|
|
830
966
|
// An abort ends the turn: keep whatever text arrived, then stop.
|
|
@@ -961,13 +1097,20 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
961
1097
|
return { ok: false, result, editDiff: null, media: false };
|
|
962
1098
|
}
|
|
963
1099
|
|
|
964
|
-
// 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.
|
|
965
1106
|
let result = null;
|
|
966
1107
|
let ok = true;
|
|
967
1108
|
if (this.onApproval) {
|
|
968
|
-
const
|
|
1109
|
+
const verdict = await this.onApproval(tc.name, tc.args);
|
|
1110
|
+
const approved = verdict && typeof verdict === 'object' ? verdict.approved : verdict;
|
|
969
1111
|
if (!approved) {
|
|
970
|
-
|
|
1112
|
+
const why = verdict && typeof verdict === 'object' && verdict.error ? `: ${verdict.error}` : '.';
|
|
1113
|
+
result = `Blocked by user: tool "${tc.name}" was rejected${why}`;
|
|
971
1114
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
972
1115
|
return { ok: false, result, editDiff: null, media: false };
|
|
973
1116
|
}
|
|
@@ -1004,7 +1147,17 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
|
|
|
1004
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); }
|
|
1005
1148
|
if (_skip) { result = '[skipped by plugin: ' + tc.name + ']'; ok = true; }
|
|
1006
1149
|
else {
|
|
1007
|
-
|
|
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);
|
|
1008
1161
|
// `null` means the tool returned a structured MEDIA result: keep the object so the
|
|
1009
1162
|
// image blocks can be built from it. Only a plain value is stringified.
|
|
1010
1163
|
const text = toolResultText(raw);
|