@hncode/hncode 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/agent-task.js +154 -84
- package/src/agent.js +533 -145
- package/src/clipboard.js +5 -2
- package/src/colors.js +242 -238
- package/src/config.js +217 -17
- package/src/file-history.js +177 -0
- package/src/hooks.js +11 -1
- package/src/i18n.js +389 -0
- package/src/index.js +46 -3
- package/src/llm.js +139 -16
- package/src/permissions.js +120 -0
- package/src/session-hub.js +407 -0
- package/src/session.js +31 -0
- package/src/term.js +19 -3
- package/src/tool-result.js +236 -0
- package/src/tools/agent.js +11 -0
- package/src/tools/bash.js +84 -17
- package/src/tools/edit.js +192 -32
- package/src/tools/file-lines.js +5 -0
- package/src/tools/index.js +4 -0
- package/src/tools/memory.js +88 -0
- package/src/tools/read-media-file.js +85 -68
- package/src/tools/read.js +15 -1
- package/src/tools/utils.js +14 -0
- package/src/tools/write.js +35 -5
- package/src/tui.js +11665 -9179
- package/src/web-auth.js +98 -0
- package/src/web-daemon-cli.js +63 -0
- package/src/web-daemon-client.js +203 -0
- package/src/web-daemon.js +871 -0
- package/src/web-public/app.css +1509 -0
- package/src/web-public/app.js +2913 -0
- package/src/web-public/complete.js +154 -0
- package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc.css +775 -0
- package/src/web-public/fonts.css +31 -0
- package/src/web-public/home.css +222 -0
- package/src/web-public/home.html +33 -0
- package/src/web-public/home.js +160 -0
- package/src/web-public/i18n.js +983 -0
- package/src/web-public/index.html +196 -0
- package/src/web-public/login.css +123 -0
- package/src/web-public/login.html +25 -0
- package/src/web-public/login.js +46 -0
- package/src/web-public/settings.js +290 -0
- package/src/web-ws.js +226 -0
- package/src/web.js +380 -0
- package/src/tui.js.bak_20260921_191527 +0 -9203
package/src/agent.js
CHANGED
|
@@ -5,9 +5,106 @@
|
|
|
5
5
|
import { LLM, setToolsList } from './llm.js';
|
|
6
6
|
import { tools, getTool, llmTools } from './tools/index.js';
|
|
7
7
|
import { DELEGATION_TOOLS } from './subagent-types.js';
|
|
8
|
-
import { estimateMessagesTokens } from './term.js';
|
|
8
|
+
import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens } from './term.js';
|
|
9
9
|
import { runHooks } from './plugin.js';
|
|
10
10
|
import { loadHooks, runShellHooks } from './hooks.js';
|
|
11
|
+
import {
|
|
12
|
+
shapeToolResult, trimToolResults, DEFAULT_TRIM_THRESHOLD, DEFAULT_TRIM_KEEP_RATIO,
|
|
13
|
+
} from './tool-result.js';
|
|
14
|
+
// ---- compaction text (kimi's shapes) ----------------------------------------
|
|
15
|
+
// These mirror kimi-code's contextMemory/compactionHandoff.ts so a compacted
|
|
16
|
+
// history reads the same way there.
|
|
17
|
+
|
|
18
|
+
// Wraps harness-generated text so it reads as a system note rather than something
|
|
19
|
+
// the user actually typed.
|
|
20
|
+
function wrapSystemReminder(content) {
|
|
21
|
+
return `<system-reminder>\n${String(content).trim()}\n</system-reminder>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const COMPACTION_SUMMARY_PREFIX = [
|
|
25
|
+
'The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over.',
|
|
26
|
+
'Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it.',
|
|
27
|
+
'User messages earlier in this context are preserved verbatim from the compacted conversation. The summary records which earlier requests were already addressed.',
|
|
28
|
+
].join(' ');
|
|
29
|
+
|
|
30
|
+
// The instruction given to the model that WRITES the summary. Adapted from
|
|
31
|
+
// kimi-code's fullCompaction/compaction-instruction.md: it is a handoff for the
|
|
32
|
+
// model that resumes, not a recap for a human. The language rule matters — without
|
|
33
|
+
// it a Chinese conversation produced an English summary and the resumed context
|
|
34
|
+
// lost the vocabulary the task was being discussed in.
|
|
35
|
+
const COMPACTION_INSTRUCTION = [
|
|
36
|
+
'You are about to run out of context. Create a handoff summary for the model that will resume this task after the earlier conversation is cleared.',
|
|
37
|
+
'',
|
|
38
|
+
'This message is a direct task, not part of the above conversation.',
|
|
39
|
+
'',
|
|
40
|
+
'Write it in the same language the conversation has been using — do not switch to English just because these instructions are in English.',
|
|
41
|
+
'',
|
|
42
|
+
'Make the summary self-sufficient: the next turn will see only the preserved messages and this summary. In your own words, preserve what is genuinely needed to continue:',
|
|
43
|
+
'',
|
|
44
|
+
'- What the latest request actually asks for, and any ambiguity you have already resolved. If the latest request is large (a big paste or file) and may be truncated in the kept messages, preserve the actual ask.',
|
|
45
|
+
'- The instructions and constraints currently in force (user preferences, project rules, environment limits), with settled decisions kept separate from open questions, so neither is reopened nor mistaken for decided.',
|
|
46
|
+
'- What has actually been done, at high fidelity: exact commands run, exact file paths touched, and their results — the concrete values, key lines and error text — not just the commands. Keep only the final working version of any code; drop intermediate attempts and already-resolved errors.',
|
|
47
|
+
'- What you still do not know: files referenced but not read, APIs assumed but unseen, questions the user has not answered. Name these gaps so the next turn checks them instead of assuming.',
|
|
48
|
+
'- The forward plan: the exact next step, the sequence to finish, decisions already made for those steps, foreseeable obstacles and how you mean to handle them.',
|
|
49
|
+
'',
|
|
50
|
+
'Be honest about uncertainty. If an earlier step claimed something was done but was never verified, say so plainly and treat it as unverified rather than fact.',
|
|
51
|
+
'',
|
|
52
|
+
'Be concise and proportional: a long multi-step task warrants detail, a nearly finished exchange needs only a sentence or two.',
|
|
53
|
+
'',
|
|
54
|
+
'Respond with text only. Do not call any tools.',
|
|
55
|
+
].join('\n');
|
|
56
|
+
|
|
57
|
+
// Trailing note: the model should RESUME, not answer the summary.
|
|
58
|
+
const COMPACTION_CONTINUATION_TEXT = 'Context compaction is complete — continue the work that was in progress when it began.';
|
|
59
|
+
|
|
60
|
+
// Clip to a TOKEN budget, counting non-ASCII as one token per character (the same
|
|
61
|
+
// Clip to a TOKEN budget, counting non-ASCII as one token per character (the same
|
|
62
|
+
// rule estimateTokens uses). Slicing by characters is wrong for CJK text: 4000
|
|
63
|
+
// Chinese characters are ~4000 tokens, not ~1000.
|
|
64
|
+
function clipToTokens(text, maxTokens) {
|
|
65
|
+
if (maxTokens <= 0) return '';
|
|
66
|
+
const s = String(text ?? '');
|
|
67
|
+
let ascii = 0, nonAscii = 0, end = 0;
|
|
68
|
+
for (const ch of s) {
|
|
69
|
+
if (ch.codePointAt(0) <= 127) ascii++; else nonAscii++;
|
|
70
|
+
if (Math.ceil(ascii / 4) + nonAscii > maxTokens) break;
|
|
71
|
+
end += ch.length;
|
|
72
|
+
}
|
|
73
|
+
return end >= s.length ? s : s.slice(0, end) + '\n…[clipped]';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// May the history be split between `index` and `index + 1`? Mirrors kimi's
|
|
77
|
+
// canSplitAfter — a split is illegal when it would orphan a tool exchange:
|
|
78
|
+
// * the message before the cut is a user message (would start the kept part
|
|
79
|
+
// mid-turn), or
|
|
80
|
+
// * it is an assistant message that still has tool calls, or
|
|
81
|
+
// * the message after the cut is a tool result, or
|
|
82
|
+
// * the prefix ends inside an open tool exchange (more results to come).
|
|
83
|
+
function canSplitAfter(messages, index) {
|
|
84
|
+
if (index < 0 || index >= messages.length) return false;
|
|
85
|
+
const m = messages[index];
|
|
86
|
+
if (!m) return false;
|
|
87
|
+
if (m.role === 'user') return false;
|
|
88
|
+
if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) return false;
|
|
89
|
+
const next = messages[index + 1];
|
|
90
|
+
if (next && next.role === 'tool') return false;
|
|
91
|
+
return !prefixEndsWithOpenToolExchange(messages, index);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// True when the prefix [0..index] ends with an assistant that issued MORE tool
|
|
95
|
+
// calls than have results so far — cutting there would leave a call unanswered.
|
|
96
|
+
function prefixEndsWithOpenToolExchange(messages, index) {
|
|
97
|
+
if (!messages[index] || messages[index].role !== 'tool') return false;
|
|
98
|
+
let results = 0;
|
|
99
|
+
for (let i = index; i >= 0; i--) {
|
|
100
|
+
const m = messages[i];
|
|
101
|
+
if (!m) return false;
|
|
102
|
+
if (m.role === 'tool') { results++; continue; }
|
|
103
|
+
return m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > results;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
11
108
|
|
|
12
109
|
// A short, stable key for the prompt cache, derived from the conversation's
|
|
13
110
|
// first user message. Stability is the whole point: the same conversation must
|
|
@@ -22,15 +119,71 @@ function hashKey(s) {
|
|
|
22
119
|
h = (h * 0x01000193) >>> 0;
|
|
23
120
|
}
|
|
24
121
|
return h.toString(36) + '-' + str.length.toString(36);
|
|
122
|
+
return h.toString(36) + '-' + str.length.toString(36);
|
|
25
123
|
}
|
|
26
124
|
|
|
125
|
+
// Render what an Edit actually changed, for the MODEL's tool result.
|
|
126
|
+
//
|
|
127
|
+
// The UI has always received the edit through `editDiff` (see the tool_result
|
|
128
|
+
// event), but the message pushed into `this.messages` carried only the one-line
|
|
129
|
+
// receipt ("Edited a.js: replaced 1 occurrence(s)."). The model was therefore
|
|
130
|
+
// asked to verify its own edit from a sentence that describes nothing: it could
|
|
131
|
+
// not see WHAT changed, so a wrong-but-successful edit looked identical to a
|
|
132
|
+
// correct one. The diff is appended here — after the receipt — so the model
|
|
133
|
+
// reads what it did.
|
|
134
|
+
//
|
|
135
|
+
// The format is the usual unified one (`- old`, `+ new`) with the line number
|
|
136
|
+
// from the file, and it is deliberately indented two spaces: a tool result is
|
|
137
|
+
// rendered as plain text, and the indent keeps the +/- marks from reading as
|
|
138
|
+
// part of the JSON envelope some providers wrap tool output in.
|
|
139
|
+
export function formatEditDiffForModel(diff, maxLines = 400) {
|
|
140
|
+
if (!diff || typeof diff.old !== 'string' || typeof diff.new !== 'string') return '';
|
|
141
|
+
const split = (s) => String(s).replace(/\r\n/g, '\n').split('\n');
|
|
142
|
+
const oldLines = split(diff.old);
|
|
143
|
+
const newLines = split(diff.new);
|
|
144
|
+
const startLine = Number(diff.startLine) > 0 ? Number(diff.startLine) : 1;
|
|
145
|
+
const out = [];
|
|
146
|
+
out.push('');
|
|
147
|
+
out.push('--- diff ---');
|
|
148
|
+
let n = startLine;
|
|
149
|
+
for (const l of oldLines) out.push(' ' + String(n++).padStart(4) + ' - ' + l);
|
|
150
|
+
n = startLine;
|
|
151
|
+
for (const l of newLines) out.push(' ' + String(n++).padStart(4) + ' + ' + l);
|
|
152
|
+
// A whole-file edit (a Write-sized `new`) must not be echoed back in full.
|
|
153
|
+
if (out.length > maxLines + 3) {
|
|
154
|
+
const hidden = out.length - (maxLines + 3);
|
|
155
|
+
out.length = maxLines + 3;
|
|
156
|
+
out.push(' … ' + hidden + ' more diff line(s) omitted');
|
|
157
|
+
}
|
|
158
|
+
return out.join('\n');
|
|
159
|
+
}
|
|
27
160
|
|
|
28
161
|
// Final instruction appended to the system prompt (see SYSTEM_PROMPT).
|
|
29
162
|
// (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
|
|
30
163
|
|
|
31
164
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
165
|
+
// Tools whose execution cannot affect what any OTHER tool call in the same step
|
|
166
|
+
// observes, and cannot be affected by one. Two read-only tools therefore have no
|
|
167
|
+
// reason to run one after the other, and the agent runs a consecutive run of them
|
|
168
|
+
// in parallel (see the tool loop in run()). Anything that writes, spawns, shells
|
|
169
|
+
// out or mutates the working tree is excluded — that rules out `Git` (it can
|
|
170
|
+
// stage/commit/switch), `ReadMediaFile` (read-only, but it decodes megabytes of
|
|
171
|
+
// media and is never the bottleneck) and every delegation tool. Claude Code
|
|
172
|
+
// expresses the same idea as `isConcurrencySafe()` on each tool; this is the flat
|
|
173
|
+
// version of it, matching hncode's tool registry.
|
|
174
|
+
const READ_ONLY_TOOLS = new Set([
|
|
175
|
+
'Read', 'Grep', 'Glob', 'FileLines', 'FetchURL', 'WebSearch',
|
|
176
|
+
'TaskList', 'TaskOutput',
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
// Debug switch for the size/age control in tool-result.js, mirroring Claude
|
|
180
|
+
// Code's env-gated knobs. Set HNCODE_TOOL_RESULT_LIMITS=off to send every tool
|
|
181
|
+
// result verbatim (useful when a spill is suspected of hiding something the
|
|
182
|
+
// model needed).
|
|
183
|
+
function toolResultLimitsEnabled() {
|
|
184
|
+
return !/^(0|off|false|no)$/i.test(String(process.env.HNCODE_TOOL_RESULT_LIMITS || ''));
|
|
185
|
+
}
|
|
186
|
+
|
|
34
187
|
|
|
35
188
|
export const SYSTEM_PROMPT = `You are hncode, a coding agent in the user's terminal on ${process.platform}.
|
|
36
189
|
You work inside a workspace directory using the tools provided.
|
|
@@ -84,6 +237,12 @@ Finishing:
|
|
|
84
237
|
- If context is running low, call /compact to shrink history.
|
|
85
238
|
`;
|
|
86
239
|
|
|
240
|
+
/** Plain text of a structured tool result, for the model and the transcript. */
|
|
241
|
+
function mediaResultText(r) {
|
|
242
|
+
if (!r || typeof r !== 'object') return String(r == null ? '' : r);
|
|
243
|
+
return String(r.text == null ? '' : r.text);
|
|
244
|
+
}
|
|
245
|
+
|
|
87
246
|
export class Agent {
|
|
88
247
|
constructor({ cfg, messages, onEvent, maxSteps = 0, onToolStart, canInterrupt = () => false, onApproval }) {
|
|
89
248
|
this.cfg = cfg;
|
|
@@ -110,7 +269,11 @@ export class Agent {
|
|
|
110
269
|
// when the user presses Esc / Ctrl-C — otherwise the command keeps running
|
|
111
270
|
// after the turn was interrupted.
|
|
112
271
|
this.toolAbort = new AbortController();
|
|
113
|
-
|
|
272
|
+
// Reuse an incoming task store when the caller provides one. Background
|
|
273
|
+
// tasks OUTLIVE the turn that started them (their process is detached), so a
|
|
274
|
+
// fresh `{}` per Agent made them vanish from the UI on the next turn while
|
|
275
|
+
// still running. The TUI passes its persistent `state.tasks` through cfg.
|
|
276
|
+
this.ctx = { ...cfg, tasks: cfg.tasks || {}, todoState: Array.isArray(cfg.todoState) ? cfg.todoState.slice() : [], allowExternal: !!cfg.allowExternal, signal: this.toolAbort.signal };
|
|
114
277
|
// Live output stream for a RUNNING tool (Bash): the tool calls this with raw
|
|
115
278
|
// chunks and the agent tags them with the id of the tool call in flight, so
|
|
116
279
|
// the TUI can append them to the matching "Using …" row while it runs.
|
|
@@ -118,12 +281,24 @@ export class Agent {
|
|
|
118
281
|
this.ctx.onOutput = (chunk) => {
|
|
119
282
|
if (chunk && this._currentToolId) this.onEvent({ type: 'tool_output', id: this._currentToolId, chunk });
|
|
120
283
|
};
|
|
284
|
+
// The Read snapshot pool lives ON THE AGENT, not on a per-turn ctx. It records
|
|
285
|
+
// what the model has read (and the hashes at that moment) so a later Edit can
|
|
286
|
+
// refuse to act on a stale snippet. /undo rewinds files on disk, i.e. it makes
|
|
287
|
+
// those hashes describe content that is no longer there — the TUI clears this
|
|
288
|
+
// pool right after a rewind so the next Edit is not rejected as stale.
|
|
289
|
+
this.readPool = new Map();
|
|
290
|
+
this.ctx.readPool = this.readPool;
|
|
121
291
|
// `fork` (the Agent tool) snapshots THIS conversation into the subagent. The
|
|
122
|
-
// tool reads `ctx._parentMessages
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
292
|
+
// tool reads `ctx._parentMessages`. This must be a GETTER, not the array
|
|
293
|
+
// itself: auto-compaction REPLACES `this.messages` with a trimmed history, so
|
|
294
|
+
// a retained reference kept pointing at the pre-compaction array — a fork
|
|
295
|
+
// after a compaction would hand the subagent exactly the oversized history
|
|
296
|
+
// that was just cut, and could push it straight past its own window.
|
|
297
|
+
Object.defineProperty(this.ctx, '_parentMessages', {
|
|
298
|
+
get: () => this.messages,
|
|
299
|
+
enumerable: true,
|
|
300
|
+
configurable: true,
|
|
301
|
+
});
|
|
127
302
|
// Prompt-cache key: identify the SESSION, not the request, so the cached
|
|
128
303
|
// prefix is reused across turns instead of thrashing. The caller may pass one
|
|
129
304
|
// (the TUI knows the session id); otherwise derive a stable value from the
|
|
@@ -136,6 +311,10 @@ export class Agent {
|
|
|
136
311
|
}
|
|
137
312
|
this.llm = new LLM(cfg);
|
|
138
313
|
this.stopRequested = false;
|
|
314
|
+
// Auto-compaction is allowed at most once per TURN (not per step). Reset here so
|
|
315
|
+
// a caller that reuses one Agent across turns still gets a fresh allowance.
|
|
316
|
+
this._compactedThisTurn = false;
|
|
317
|
+
this.onApproval = onApproval; // async (toolName, args) => boolean
|
|
139
318
|
this.onApproval = onApproval; // async (toolName, args) => boolean
|
|
140
319
|
// Shell hooks (Claude Code-style): user-configured commands run at lifecycle
|
|
141
320
|
// events. ALWAYS stored as a `{ hooks: {Event: [...]} }` config, which is the
|
|
@@ -165,6 +344,55 @@ export class Agent {
|
|
|
165
344
|
this.stopRequested = true;
|
|
166
345
|
try { this.toolAbort.abort(); } catch {}
|
|
167
346
|
if (this.llm) this.llm.abort();
|
|
347
|
+
// The compaction summarizer is a SEPARATE LLM instance; abort it too, or an
|
|
348
|
+
// auto-compaction in flight cannot be interrupted (Esc did nothing until the
|
|
349
|
+
// summary request finished on its own).
|
|
350
|
+
if (this._summaryLlm) { try { this._summaryLlm.abort(); } catch {} }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// What the NEXT request will carry: the messages (which already begin with the
|
|
354
|
+
// assembled system prompt — see the TUI's runAgent) plus the tool-definition
|
|
355
|
+
// overhead. It must NOT add `cfg.systemPrompt` on top: that value is the RAW
|
|
356
|
+
// config string, not the assembled prompt (calm/plan/swarm/AGENTS.md are appended
|
|
357
|
+
// later), so counting it both double-counted the system text and measured the
|
|
358
|
+
// wrong figure — while being the only input that decides whether to compact.
|
|
359
|
+
usedTokens() {
|
|
360
|
+
let total = 0;
|
|
361
|
+
for (const m of this.messages || []) total += estimateMessagesTokens([m], null);
|
|
362
|
+
if (Array.isArray(this.cfg.toolFilter)) total += this.cfg.toolFilter.length * 8;
|
|
363
|
+
return total;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Where to cut the history for compaction. Walks back from the end accumulating a
|
|
367
|
+
// token budget, but only accepts indices the boundary rule allows (so the kept
|
|
368
|
+
// slice can never begin on an orphan tool result). Returns how many messages to
|
|
369
|
+
// DROP. Mirrors kimi's computeCompactCount + canSplitAfter.
|
|
370
|
+
computeCompactSplit(maxCtx) {
|
|
371
|
+
// Keep a fraction of the CURRENT usage, not of the whole window: a big window
|
|
372
|
+
// (1M) should not force a huge tail to be kept when only a little was used.
|
|
373
|
+
// Ratio is configurable (/auto-compact keep, config.toml compact_keep_ratio),
|
|
374
|
+
// default 0.2.
|
|
375
|
+
const keepRatio = (typeof this.cfg.compactKeepRatio === 'number'
|
|
376
|
+
&& this.cfg.compactKeepRatio > 0 && this.cfg.compactKeepRatio < 1)
|
|
377
|
+
? this.cfg.compactKeepRatio : 0.2;
|
|
378
|
+
const used = this.usedTokens();
|
|
379
|
+
const maxSize = Math.max(1, Math.floor((used || (maxCtx || 512000) * 0.85) * keepRatio));
|
|
380
|
+
const msgs = this.messages || [];
|
|
381
|
+
// Never cut inside the LEADING system block. `msgs[0]` is the assembled system
|
|
382
|
+
// prompt, and canSplitAfter() would happily allow a split right after it — which
|
|
383
|
+
// would drop the prompt itself and leave the request with no instructions.
|
|
384
|
+
let floor = 0;
|
|
385
|
+
while (floor < msgs.length && msgs[floor] && msgs[floor].role === 'system') floor++;
|
|
386
|
+
let recent = 1;
|
|
387
|
+
let size = 0;
|
|
388
|
+
let bestSplit;
|
|
389
|
+
for (; recent < msgs.length; recent++) {
|
|
390
|
+
const split = msgs.length - recent;
|
|
391
|
+
size += estimateMessagesTokens([msgs[split]], null);
|
|
392
|
+
if (split > floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
|
|
393
|
+
if (size >= maxSize && bestSplit !== undefined) break;
|
|
394
|
+
}
|
|
395
|
+
return bestSplit ?? 0;
|
|
168
396
|
}
|
|
169
397
|
|
|
170
398
|
async run() {
|
|
@@ -191,6 +419,10 @@ export class Agent {
|
|
|
191
419
|
let thought = '';
|
|
192
420
|
// decide whether running out of steps actually left the turn unfinished.
|
|
193
421
|
let hadToolCallsOnFinalStep = false;
|
|
422
|
+
// How many times this turn has continued an answer the output cap cut in half.
|
|
423
|
+
// Bounded so a model that cannot fit its answer in the budget cannot loop.
|
|
424
|
+
let truncationRecoveries = 0;
|
|
425
|
+
const MAX_TRUNCATION_RECOVERIES = 3;
|
|
194
426
|
|
|
195
427
|
const handle = async (e) => {
|
|
196
428
|
switch (e.type) {
|
|
@@ -204,6 +436,12 @@ export class Agent {
|
|
|
204
436
|
thought += e.text;
|
|
205
437
|
this.onEvent({ type: 'think', text: e.text });
|
|
206
438
|
break;
|
|
439
|
+
case 'truncated':
|
|
440
|
+
// The reply hit max_tokens and was cut off mid-answer. Remember it: the
|
|
441
|
+
// loop below continues the turn instead of ending it (see the `truncated`
|
|
442
|
+
// branch), so a half-written answer is never presented as a finished one.
|
|
443
|
+
this._truncated = true;
|
|
444
|
+
break;
|
|
207
445
|
case 'tool_start':
|
|
208
446
|
toolCalls.push({ id: e.id, name: e.name, args: {}, argsJson: '' });
|
|
209
447
|
this.onEvent({ type: 'tool_start', id: e.id, name: e.name });
|
|
@@ -220,7 +458,15 @@ export class Agent {
|
|
|
220
458
|
const tc = toolCalls.find((t) => t.id === e.id);
|
|
221
459
|
if (tc) {
|
|
222
460
|
try { tc.args = tc.argsJson ? JSON.parse(tc.argsJson) : {}; }
|
|
223
|
-
catch {
|
|
461
|
+
catch {
|
|
462
|
+
// The streamed arguments did not form complete JSON. The usual cause
|
|
463
|
+
// is truncation (a huge `content`, a long command) or an interrupted
|
|
464
|
+
// stream. The `raw` shape is kept so a tool can SEE that its
|
|
465
|
+
// arguments failed to parse and say so, instead of reporting a
|
|
466
|
+
// missing field; `_rawLen` lets that message state how big the
|
|
467
|
+
// partial payload was, which is what tells the model to split it.
|
|
468
|
+
tc.args = { raw: tc.argsJson, _rawLen: tc.argsJson ? tc.argsJson.length : 0 };
|
|
469
|
+
}
|
|
224
470
|
}
|
|
225
471
|
break;
|
|
226
472
|
}
|
|
@@ -253,20 +499,37 @@ export class Agent {
|
|
|
253
499
|
}
|
|
254
500
|
if (this.stopRequested) { this.onEvent({ type: 'stopped' }); break; }
|
|
255
501
|
text = ''; toolCalls = []; emittedEnd = false; thought = '';
|
|
502
|
+
this._truncated = false;
|
|
256
503
|
// Report the start of each model round so the TUI can count steps/rounds.
|
|
257
504
|
this.onEvent({ type: 'step_start', step, round: step + 1 });
|
|
258
505
|
// ---- auto-compaction (kimi's runtime strategy) ----
|
|
259
506
|
// Before each model call, if the estimated context usage is at or above
|
|
260
|
-
// 85% of the model's max context, summarize the older portion
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// preserved rather than simply discarded.
|
|
507
|
+
// 85% of the model's max context, summarize the older portion and keep a
|
|
508
|
+
// small recent tail. Mirrors kimi's triggerRatio=0.85 /
|
|
509
|
+
// maxRecentSizeRatio=0.2 policy, plus its `reservedContextSize` rule: a
|
|
510
|
+
// request that leaves less than the reserved headroom is compacted even
|
|
511
|
+
// below 85%, so the reply has room to be generated.
|
|
266
512
|
const maxCtx = this.cfg.maxContextTokens || 512000;
|
|
267
|
-
let usedNow =
|
|
268
|
-
|
|
513
|
+
let usedNow = this.usedTokens();
|
|
514
|
+
// `reservedContextSize` (kimi): a request leaving less than this much headroom
|
|
515
|
+
// is compacted even below the trigger ratio, so the REPLY has room to be
|
|
516
|
+
// generated. A request just under the trigger could otherwise still fail.
|
|
517
|
+
const reserved = this.cfg.reservedContextTokens || 50_000;
|
|
518
|
+
const needsRoom = reserved > 0 && reserved < maxCtx && usedNow + reserved >= maxCtx;
|
|
519
|
+
// Trigger ratio is configurable (/auto-compact threshold, config.toml
|
|
520
|
+
// compact_threshold); default 0.85.
|
|
521
|
+
const trigger = (typeof this.cfg.compactThreshold === 'number' && this.cfg.compactThreshold > 0
|
|
522
|
+
&& this.cfg.compactThreshold < 1) ? this.cfg.compactThreshold : 0.85;
|
|
523
|
+
// At most ONCE per turn. This used to run inside the step loop with no counter,
|
|
524
|
+
// so a trim that failed to get under the threshold triggered again on the very
|
|
525
|
+
// next step — paying for another summary round-trip each step while the history
|
|
526
|
+
// kept shrinking.
|
|
527
|
+
const shouldCompact = this.cfg.autoCompact !== false && maxCtx > 0
|
|
528
|
+
&& !this._compactedThisTurn
|
|
529
|
+
&& (usedNow >= maxCtx * trigger || needsRoom);
|
|
530
|
+
if (shouldCompact) {
|
|
269
531
|
const before = usedNow;
|
|
532
|
+
this._compactedThisTurn = true;
|
|
270
533
|
// SHELL HOOK: PreCompact — a chance to back up the transcript before the
|
|
271
534
|
// older half is summarized away. Never blocks (the trim must go ahead).
|
|
272
535
|
await runShellHooks(this.hookConfig, 'PreCompact',
|
|
@@ -276,79 +539,124 @@ export class Agent {
|
|
|
276
539
|
// with no sign anything was happening.
|
|
277
540
|
this.onEvent({ type: 'compacting', before: usedNow, keep: 0, dropped: 0 });
|
|
278
541
|
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
// part and lost the information the summary needed most.
|
|
542
|
+
// SUMMARIZE BEFORE TRIMMING. The summary is generated from the FULL history,
|
|
543
|
+
// so the summarizing model can see what just happened — the most recent
|
|
544
|
+
// exchange is what tells it what the session is doing now. Trimming first
|
|
545
|
+
// summarized only the OLD part and lost what the summary needed most.
|
|
284
546
|
//
|
|
285
|
-
// The transcript fed to the summarizer is
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
const
|
|
291
|
-
const
|
|
547
|
+
// The transcript fed to the summarizer is bounded by TOKENS, not characters.
|
|
548
|
+
// A character cap is not a size cap: 120k Chinese characters is roughly 120k
|
|
549
|
+
// tokens, which alone can exceed the window and make the call FAIL — after
|
|
550
|
+
// which the history was discarded with no summary at all. That is the normal
|
|
551
|
+
// case for a Chinese conversation, not an edge case.
|
|
552
|
+
const summaryBudget = Math.max(1, Math.floor(maxCtx * 0.3));
|
|
553
|
+
const perMsgTokens = 4_000;
|
|
292
554
|
const summaryParts = [];
|
|
293
|
-
let
|
|
555
|
+
let summaryTokens = 0;
|
|
294
556
|
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
295
557
|
const m = this.messages[i];
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const line = `${m.role}: ${
|
|
299
|
-
|
|
300
|
-
|
|
558
|
+
let raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '');
|
|
559
|
+
if (estimateTokens(raw) > perMsgTokens) raw = clipToTokens(raw, perMsgTokens);
|
|
560
|
+
const line = `${m.role}: ${raw}`;
|
|
561
|
+
const t = estimateTokens(line);
|
|
562
|
+
if (summaryTokens + t > summaryBudget) break;
|
|
563
|
+
summaryTokens += t;
|
|
301
564
|
summaryParts.unshift(line);
|
|
302
565
|
}
|
|
303
566
|
let summary = '';
|
|
304
567
|
if (this.messages.length > 0) {
|
|
305
568
|
const summaryPrompt = [
|
|
306
|
-
{ role: 'system', content:
|
|
569
|
+
{ role: 'system', content: COMPACTION_INSTRUCTION },
|
|
307
570
|
{ role: 'user', content: summaryParts.join('\n\n') },
|
|
308
571
|
];
|
|
309
|
-
const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096,
|
|
572
|
+
const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096, 4096) };
|
|
310
573
|
const llm = new LLM(cfgCopy);
|
|
311
|
-
|
|
574
|
+
// Remember this throwaway summarizer so Esc / Ctrl-C can abort IT too.
|
|
575
|
+
// It is NOT `this.llm`, so `interrupt()` could not reach it — a running
|
|
576
|
+
// auto-compaction was uninterruptible, which felt like a hang.
|
|
577
|
+
this._summaryLlm = llm;
|
|
578
|
+
// Thinking OFF and no tools, matching Claude Code's summarizer
|
|
579
|
+
// (`thinkingConfig: { type: 'disabled' }`). Summarizing is a straight
|
|
580
|
+
// transcription task: reasoning tokens buy nothing here, and on a
|
|
581
|
+
// reasoning model they can consume the whole output budget — which
|
|
582
|
+
// returns an EMPTY summary, and an empty summary is what turns a
|
|
583
|
+
// compaction into a silent loss of history.
|
|
584
|
+
try { summary = await llm.requestText(summaryPrompt, { noReasoning: true, noTools: true }); } catch { summary = ''; }
|
|
585
|
+
this._summaryLlm = null;
|
|
312
586
|
}
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
let keepFrom = this.messages.length;
|
|
321
|
-
let keptTokens = 0;
|
|
322
|
-
while (keepFrom > 0) {
|
|
323
|
-
const n = this.messages.length - keepFrom; // already-kept count
|
|
324
|
-
if (n >= 2 && keptTokens >= keepBudget) break;
|
|
325
|
-
const cand = this.messages[keepFrom - 1];
|
|
326
|
-
const t = estimateMessagesTokens([cand], this.cfg) - estimateMessagesTokens([], this.cfg);
|
|
327
|
-
// Always take at least the last two messages, however big they are.
|
|
328
|
-
if (n >= 2 && keptTokens + t > keepBudget) break;
|
|
329
|
-
keptTokens += t;
|
|
330
|
-
keepFrom--;
|
|
587
|
+
// Interrupted mid-summary: do NOT trim. Aborting yields an empty summary, and
|
|
588
|
+
// proceeding would drop history with nothing to replace it — data loss. Mark
|
|
589
|
+
// the compaction block cancelled and bail out of the turn.
|
|
590
|
+
if (this.stopRequested) {
|
|
591
|
+
this.onEvent({ type: 'compaction_cancelled' });
|
|
592
|
+
this.onEvent({ type: 'stopped' });
|
|
593
|
+
break;
|
|
331
594
|
}
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
595
|
+
// NOW trim, at a split point the boundary rule allows.
|
|
596
|
+
const split = this.computeCompactSplit(maxCtx);
|
|
597
|
+
const keep = this.messages.slice(split);
|
|
598
|
+
const droppedCount = this.messages.length - keep.length;
|
|
599
|
+
// Shape: [ ...kept, summary, continuation ].
|
|
600
|
+
//
|
|
601
|
+
// The summary is a USER message wrapped in a <system-reminder> (kimi's
|
|
602
|
+
// shape), not a `system` message. Two reasons:
|
|
603
|
+
// * the TUI rebuilds the system prompt from scratch each turn and SKIPS
|
|
604
|
+
// every `system` entry in the stored history, so a system summary was
|
|
605
|
+
// dropped before the model ever saw it;
|
|
606
|
+
// * the turn-end save filters `system` out of the session, so it was not
|
|
607
|
+
// persisted either — history trimmed AND summary lost at both ends,
|
|
608
|
+
// which made "compaction" pure data loss.
|
|
609
|
+
// The continuation note goes LAST so the model's next move is "keep going"
|
|
610
|
+
// rather than "answer the summary".
|
|
611
|
+
const summaryText = summary
|
|
612
|
+
? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
|
|
613
|
+
: `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
|
|
614
|
+
this.messages = [
|
|
615
|
+
...keep,
|
|
616
|
+
{ role: 'user', content: wrapSystemReminder(summaryText) },
|
|
617
|
+
{ role: 'user', content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
|
|
618
|
+
];
|
|
619
|
+
usedNow = this.usedTokens();
|
|
620
|
+
this.onEvent({
|
|
621
|
+
type: 'compacted', before, after: usedNow, kept: keep.length,
|
|
622
|
+
dropped: droppedCount, summary,
|
|
623
|
+
});
|
|
624
|
+
// SHELL HOOK: PostCompact. Fires after the trim, so a hook can archive the
|
|
625
|
+
// summary or record the shrink. Never blocks.
|
|
626
|
+
await runShellHooks(this.hookConfig, 'PostCompact',
|
|
627
|
+
{ before, after: usedNow, kept: keep.length, dropped: droppedCount, summary }, this.cfg.workspace);
|
|
628
|
+
}
|
|
629
|
+
// ---- tool-result trimming ----
|
|
630
|
+
// The cheap pass, and the one that actually runs in practice: a history is
|
|
631
|
+
// usually LARGE because of old tool output nobody is reading, not because the
|
|
632
|
+
// conversation is long. Bodies are replaced by a short pointer — the message
|
|
633
|
+
// list keeps its exact length and roles, so the assistant/tool pairing and the
|
|
634
|
+
// cached prefix shape are untouched — and the removed text is copied to disk
|
|
635
|
+
// first, so it stays recoverable.
|
|
636
|
+
//
|
|
637
|
+
// Trigger: the request reached `trimThreshold` (50% by default) of the window.
|
|
638
|
+
// Target: keep `trimKeepRatio` (30% by default) of the tool-result TEXT,
|
|
639
|
+
// newest first. Skipped while a compaction just ran (that already shrank
|
|
640
|
+
// things) and above the compaction trigger (compaction is the right tool by
|
|
641
|
+
// then, and the two must not fight over the same history).
|
|
642
|
+
const trimThreshold = Number.isFinite(this.cfg.trimThreshold) ? this.cfg.trimThreshold : DEFAULT_TRIM_THRESHOLD;
|
|
643
|
+
if (this.cfg.autoTrim !== false && toolResultLimitsEnabled()
|
|
644
|
+
&& !this._compactedThisTurn && maxCtx > 0
|
|
645
|
+
&& usedNow >= maxCtx * trimThreshold && usedNow < maxCtx * (this.cfg.compactThreshold || 0.85)) {
|
|
646
|
+
const res = trimToolResults(this.messages, {
|
|
647
|
+
sessionId: this.cfg.sessionId,
|
|
648
|
+
keepRatio: Number.isFinite(this.cfg.trimKeepRatio) ? this.cfg.trimKeepRatio : DEFAULT_TRIM_KEEP_RATIO,
|
|
649
|
+
// The model may still be reading the result it was handed last step.
|
|
650
|
+
mustKeep: this._lastToolResultIndex,
|
|
651
|
+
});
|
|
652
|
+
if (res && res.elided > 0) {
|
|
653
|
+
const after = this.usedTokens();
|
|
654
|
+
this.onEvent({
|
|
655
|
+
type: 'results_trimmed', elided: res.elided, elidedBytes: res.elidedBytes,
|
|
656
|
+
kept: res.kept, keptBytes: res.keptBytes, before: usedNow, after,
|
|
657
|
+
});
|
|
658
|
+
usedNow = after;
|
|
337
659
|
}
|
|
338
|
-
const keep = this.messages.slice(keepFrom);
|
|
339
|
-
const droppedCount = keepFrom;
|
|
340
|
-
// Final shape: SUMMARY + the most recent messages.
|
|
341
|
-
// ALWAYS prepend a marker, even when summarization failed. Silently
|
|
342
|
-
// dropping history left the model (and the user) with no idea that context
|
|
343
|
-
// was lost; a one-line note makes the gap explicit.
|
|
344
|
-
this.messages = [{
|
|
345
|
-
role: 'system',
|
|
346
|
-
content: summary
|
|
347
|
-
? `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of the conversation so far:\n\n${summary}`
|
|
348
|
-
: `[hncode] ${droppedCount} earlier message(s) were dropped to stay within the context window, and no summary could be generated. Their detail is no longer available.`,
|
|
349
|
-
}, ...keep];
|
|
350
|
-
usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
351
|
-
this.onEvent({ type: 'compacted', before, after: usedNow, kept: keep.length });
|
|
352
660
|
}
|
|
353
661
|
// Live context gauge: report the estimated size of the request about to be
|
|
354
662
|
// sent, so the TUI's context readout is refreshed at every step rather than
|
|
@@ -378,8 +686,9 @@ export class Agent {
|
|
|
378
686
|
break;
|
|
379
687
|
}
|
|
380
688
|
|
|
381
|
-
//
|
|
382
|
-
//
|
|
689
|
+
// Append the assistant message. NEVER push one that carries neither text
|
|
690
|
+
// nor tool_calls: upstream rejects it with "content or tool_calls must be
|
|
691
|
+
// set".
|
|
383
692
|
const hasText = !!(text && text.trim());
|
|
384
693
|
const hasCalls = toolCalls.length > 0;
|
|
385
694
|
if (hasText || hasCalls) {
|
|
@@ -403,6 +712,32 @@ export class Agent {
|
|
|
403
712
|
const steers = this.steerQueue.splice(0);
|
|
404
713
|
for (const msg of steers) this.messages.push({ role: 'user', content: msg });
|
|
405
714
|
continue;
|
|
715
|
+
} else if (this._truncated && truncationRecoveries < MAX_TRUNCATION_RECOVERIES) {
|
|
716
|
+
if (!hasText) {
|
|
717
|
+
// Truncated with NO text at all: every output token went into reasoning
|
|
718
|
+
// (a reasoning model on a small budget). There is nothing to continue
|
|
719
|
+
// from, and ending the turn here would look like the model simply had
|
|
720
|
+
// nothing to say. Say what actually happened instead.
|
|
721
|
+
this.onEvent({
|
|
722
|
+
type: 'error',
|
|
723
|
+
error: new Error(
|
|
724
|
+
`The model reached its ${this.cfg.maxOutputTokens || 'configured'} output-token limit `
|
|
725
|
+
+ 'before producing any text — raise the output limit (HNCODE_MAX_OUTPUT) or lower the thinking effort.',
|
|
726
|
+
),
|
|
727
|
+
});
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
// The answer was cut off by max_tokens: it is HALF an answer, and it is
|
|
731
|
+
// already committed above with no tool calls. Ask the model to pick up
|
|
732
|
+
// where it stopped instead of ending the turn — otherwise the user reads
|
|
733
|
+
// a sentence that stops mid-word and assumes that was all of it.
|
|
734
|
+
truncationRecoveries++;
|
|
735
|
+
this.messages.push({
|
|
736
|
+
role: 'user',
|
|
737
|
+
content: 'Your previous message was cut off because it reached the output token limit. Continue from exactly where it stopped — do not repeat what you already wrote, and do not start over.',
|
|
738
|
+
});
|
|
739
|
+
this.onEvent({ type: 'truncation_recovery', attempt: truncationRecoveries });
|
|
740
|
+
continue;
|
|
406
741
|
} else if (!hasText) {
|
|
407
742
|
// No tool calls and no answer text: the model stopped without saying
|
|
408
743
|
// anything. End the turn.
|
|
@@ -428,26 +763,29 @@ export class Agent {
|
|
|
428
763
|
const modeName = Array.isArray(this.cfg.toolFilter)
|
|
429
764
|
? (this.cfg.toolFilter.includes('Bash') ? 'Focus' : 'Plan')
|
|
430
765
|
: undefined;
|
|
431
|
-
|
|
432
|
-
|
|
766
|
+
|
|
767
|
+
// Gate + execute + hooks for ONE call, with no message bookkeeping: the
|
|
768
|
+
// caller posts the result separately, in call order. Splitting it this way
|
|
769
|
+
// is what makes a parallel batch possible at all — concurrent calls finish
|
|
770
|
+
// out of order, and tool results MUST be appended in the order the model
|
|
771
|
+
// asked for them or the protocol pairing breaks.
|
|
772
|
+
const prepareToolCall = async (tc) => {
|
|
433
773
|
// Tag output produced by THIS tool call so onOutput can route it to the
|
|
434
774
|
// right "Using …" row (a turn can run several tools in sequence).
|
|
435
775
|
this._currentToolId = tc.id;
|
|
436
|
-
|
|
776
|
+
|
|
437
777
|
// Permission check before executing each tool
|
|
438
|
-
let result;
|
|
778
|
+
let result = null;
|
|
779
|
+
let ok = true;
|
|
439
780
|
if (this.onApproval) {
|
|
440
|
-
const
|
|
441
|
-
if (!
|
|
781
|
+
const approved = await this.onApproval(tc.name, tc.args);
|
|
782
|
+
if (!approved) {
|
|
442
783
|
result = `Blocked by user: tool "${tc.name}" was rejected.`;
|
|
443
784
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
444
|
-
|
|
445
|
-
this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
|
|
446
|
-
allOk = false;
|
|
447
|
-
continue;
|
|
785
|
+
return { ok: false, result, editDiff: null, media: false };
|
|
448
786
|
}
|
|
449
787
|
}
|
|
450
|
-
|
|
788
|
+
|
|
451
789
|
const tool = getTool(tc.name);
|
|
452
790
|
// A subagent may not delegate. The tool is not exposed to it, but the model
|
|
453
791
|
// can still emit a call for a tool it was told about earlier, so refuse it
|
|
@@ -455,73 +793,123 @@ export class Agent {
|
|
|
455
793
|
if (this.noDelegation && DELEGATION_TOOLS.includes(tc.name)) {
|
|
456
794
|
result = `You CAN'T use ${tc.name}: subagents cannot delegate. Do the work yourself and report the result to the agent that started you.`;
|
|
457
795
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
458
|
-
|
|
459
|
-
this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
|
|
460
|
-
allOk = false;
|
|
461
|
-
continue;
|
|
796
|
+
return { ok: false, result, editDiff: null, media: false };
|
|
462
797
|
}
|
|
463
798
|
if (modeName && !this.cfg.toolFilter.includes(tc.name)) {
|
|
464
799
|
result = `You CAN'T use this tool on ${modeName} Mode. Allowed: ${this.cfg.toolFilter.join(', ')}.`;
|
|
465
800
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
466
|
-
|
|
467
|
-
this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
|
|
468
|
-
allOk = false;
|
|
469
|
-
continue;
|
|
801
|
+
return { ok: false, result, editDiff: null, media: false };
|
|
470
802
|
}
|
|
471
803
|
if (!tool) {
|
|
472
804
|
result = `Error: unknown tool "${tc.name}".`;
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
485
|
-
this.messages.push({ role: 'tool', toolCallId: tc.id, content: result });
|
|
486
|
-
this.onEvent({ type: 'tool_result', id: tc.id, name: tc.name, content: result });
|
|
487
|
-
allOk = false;
|
|
488
|
-
continue;
|
|
489
|
-
}
|
|
490
|
-
if (this.onToolStart) this.onToolStart(tc.name, tc.args);
|
|
805
|
+
return { ok: false, result, editDiff: null, media: false };
|
|
806
|
+
}
|
|
807
|
+
// SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
|
|
808
|
+
// message goes back to the model in place of the tool result — that is
|
|
809
|
+
// the whole point of a pre-hook (e.g. refuse a write that fails a lint
|
|
810
|
+
// gate). Checked BEFORE approving/executing so a blocked call never
|
|
811
|
+
// touches the filesystem.
|
|
812
|
+
const pre = await runShellHooks(this.hookConfig, 'PreToolUse',
|
|
813
|
+
{ toolName: tc.name, toolArgs: tc.args }, this.ctx.cwd || this.cfg.workspace);
|
|
814
|
+
if (pre.blocked) {
|
|
815
|
+
result = `Blocked by a PreToolUse hook: ${pre.reason}`;
|
|
491
816
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
817
|
+
return { ok: false, result, editDiff: null, media: false };
|
|
818
|
+
}
|
|
819
|
+
if (this.onToolStart) this.onToolStart(tc.name, tc.args);
|
|
820
|
+
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
821
|
+
// Plugin hook: onToolExecute fires BEFORE the tool runs and receives the
|
|
822
|
+
// mutable args object, so a plugin can inspect or adjust a call. Errors
|
|
823
|
+
// are swallowed by runHooks — a broken plugin must not break a turn.
|
|
824
|
+
await runHooks('onToolExecute', tc.name, tc.args, this.ctx);
|
|
825
|
+
try {
|
|
826
|
+
result = await tool.execute(tc.args, this.ctx);
|
|
827
|
+
} catch (err) {
|
|
828
|
+
result = `Error running ${tc.name}: ${err.message}`;
|
|
829
|
+
ok = false;
|
|
830
|
+
}
|
|
831
|
+
this.ctx.lastResult = result;
|
|
832
|
+
// Plugin hook: onToolResult fires with the tool's output.
|
|
833
|
+
await runHooks('onToolResult', tc.name, result, this.ctx);
|
|
834
|
+
// SHELL HOOK: PostToolUse. Never blocks (the tool already ran); a
|
|
835
|
+
// failure is logged. This is where "format after every edit" lives.
|
|
836
|
+
await runShellHooks(this.hookConfig, 'PostToolUse',
|
|
837
|
+
{ toolName: tc.name, toolArgs: tc.args, toolResult: result }, this.ctx.cwd || this.cfg.workspace);
|
|
838
|
+
// SHELL HOOK: PostToolUseFailure. Fires only when the tool failed (an
|
|
839
|
+
// `Error`/`[error:` result, or a thrown run), so a hook can alert or
|
|
840
|
+
// retry without matching on every successful call.
|
|
841
|
+
if (typeof result === 'string' && /^(Error\b|\[error:)/.test(result.trim())) {
|
|
842
|
+
ok = false;
|
|
843
|
+
await runShellHooks(this.hookConfig, 'PostToolUseFailure',
|
|
508
844
|
{ toolName: tc.name, toolArgs: tc.args, toolResult: result }, this.ctx.cwd || this.cfg.workspace);
|
|
509
|
-
// Surface the live TODO list so the TUI can render its panel.
|
|
510
|
-
if (tc.name === 'TodoList') {
|
|
511
|
-
this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
|
|
512
|
-
}
|
|
513
845
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
//
|
|
519
|
-
//
|
|
846
|
+
// Surface the live TODO list so the TUI can render its panel.
|
|
847
|
+
if (tc.name === 'TodoList') {
|
|
848
|
+
this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
|
|
849
|
+
}
|
|
850
|
+
// A tool may return a STRUCTURED result instead of a string: ReadMediaFile
|
|
851
|
+
// returns `{ text, media }` so an image can travel as an image.
|
|
852
|
+
const editDiff = this.ctx.lastEditDiff || null;
|
|
853
|
+
const media = !!result && typeof result === 'object' && Array.isArray(result.media);
|
|
854
|
+
this.ctx.lastEditDiff = null;
|
|
855
|
+
return { ok, result, editDiff, media };
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
// Post one call's outcome: the model's `tool` message, then the TUI event.
|
|
859
|
+
const postToolResult = (tc, out) => {
|
|
860
|
+
if (!out.ok) allOk = false;
|
|
861
|
+
if (out.media) {
|
|
862
|
+
const forModel = out.editDiff
|
|
863
|
+
? { ...out.result, text: mediaResultText(out.result) + formatEditDiffForModel(out.editDiff) }
|
|
864
|
+
: out.result;
|
|
865
|
+
this.messages.push({ role: 'tool', toolCallId: tc.id, content: forModel });
|
|
866
|
+
this.onEvent({
|
|
867
|
+
type: 'tool_result', id: tc.id, name: tc.name,
|
|
868
|
+
content: out.result._receipt || out.result.text || '[media]',
|
|
869
|
+
editDiff: out.editDiff,
|
|
870
|
+
});
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
// The transcript keeps the full text; the MODEL may get a bounded version
|
|
874
|
+
// (a spill or a preview) so one huge read cannot inflate every subsequent
|
|
875
|
+
// request. See tool-result.js.
|
|
876
|
+
const rawForModel = out.editDiff ? out.result + formatEditDiffForModel(out.editDiff) : out.result;
|
|
877
|
+
const shaped = toolResultLimitsEnabled()
|
|
878
|
+
? shapeToolResult(rawForModel, { sessionId: this.cfg.sessionId, toolCallId: tc.id })
|
|
879
|
+
: { content: rawForModel, spilled: false };
|
|
880
|
+
this.messages.push({ role: 'tool', toolCallId: tc.id, content: shaped.content });
|
|
881
|
+
// Remember where the newest tool result landed: a trim must never elide the
|
|
882
|
+
// output the model was handed in the step it is about to reason about.
|
|
883
|
+
this._lastToolResultIndex = this.messages.length - 1;
|
|
520
884
|
this.onEvent({
|
|
521
|
-
type: 'tool_result', id: tc.id, name: tc.name, content: result,
|
|
522
|
-
editDiff:
|
|
885
|
+
type: 'tool_result', id: tc.id, name: tc.name, content: out.result,
|
|
886
|
+
editDiff: out.editDiff, spilled: shaped.spilled,
|
|
523
887
|
});
|
|
524
|
-
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
for (let i = 0; i < toolCalls.length; i++) {
|
|
892
|
+
if (this.stopRequested) break;
|
|
893
|
+
const tc = toolCalls[i];
|
|
894
|
+
// A RUN of consecutive read-only calls goes out in parallel. Non-
|
|
895
|
+
// destructive by construction (see READ_ONLY_TOOLS), so none of them can
|
|
896
|
+
// observe another's effects. The system prompt has always promised this
|
|
897
|
+
// ("Run independent tool calls in parallel; never parallelize dependent
|
|
898
|
+
// ones") but the loop executed every call one at a time, so a model that
|
|
899
|
+
// issued four Reads paid for four round-trips in sequence.
|
|
900
|
+
if (!READ_ONLY_TOOLS.has(tc.name)) {
|
|
901
|
+
postToolResult(tc, await prepareToolCall(tc));
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const batch = [tc];
|
|
905
|
+
while (i + 1 < toolCalls.length && READ_ONLY_TOOLS.has(toolCalls[i + 1].name)) batch.push(toolCalls[++i]);
|
|
906
|
+
if (batch.length === 1) {
|
|
907
|
+
postToolResult(tc, await prepareToolCall(tc));
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
// Results are posted in CALL order, never completion order.
|
|
911
|
+
const outs = await Promise.all(batch.map((b) => prepareToolCall(b)));
|
|
912
|
+
for (let k = 0; k < batch.length; k++) postToolResult(batch[k], outs[k]);
|
|
525
913
|
}
|
|
526
914
|
|
|
527
915
|
|
|
@@ -531,7 +919,7 @@ export class Agent {
|
|
|
531
919
|
|
|
532
920
|
// Report the context after this step's tool results were appended: that is
|
|
533
921
|
// exactly what the next request will carry, so the gauge grows with history.
|
|
534
|
-
this.onEvent({ type: 'context', tokens:
|
|
922
|
+
this.onEvent({ type: 'context', tokens: this.usedTokens(), max: maxCtx });
|
|
535
923
|
|
|
536
924
|
}
|
|
537
925
|
|