@hncode/hncode 0.3.2 → 0.3.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/README.md +43 -0
- package/package.json +1 -1
- package/src/agent-task.js +78 -0
- package/src/agent.js +150 -45
- package/src/cache.js +130 -0
- package/src/ci.js +244 -0
- package/src/colors.js +18 -0
- package/src/config.js +13 -0
- package/src/git.js +501 -0
- package/src/hooks.js +247 -0
- package/src/index.js +131 -17
- package/src/llm.js +14 -2
- package/src/mcp.js +384 -0
- package/src/plan.js +127 -0
- package/src/skills.js +150 -0
- package/src/subagent-types.js +1 -1
- package/src/tools/edit.js +17 -5
- package/src/tools/git.js +155 -0
- package/src/tools/index.js +2 -0
- package/src/tui.js +1256 -121
- package/src/update.js +35 -14
package/README.md
CHANGED
|
@@ -13,10 +13,53 @@ A cyan-blue terminal user interface (TUI) coding agent inspired by [Kimi Code](h
|
|
|
13
13
|
- **Cyan-blue TUI**: Raw terminal renderer for perfect Windows Terminal compatibility
|
|
14
14
|
- **Dual Protocol Support**: Works with both OpenAI-compatible APIs and Anthropic
|
|
15
15
|
- **Smart Context Management**: Auto-compaction at 85% usage, AI-summarized history retention
|
|
16
|
+
- **Prompt Caching**: Explicit cache breakpoints for the stable prefix (`/cache`)
|
|
16
17
|
- **MCP-style Toolbelt**: Read, Write, Edit, Bash, Glob, Grep, TodoList, WebSearch, etc.
|
|
18
|
+
- **MCP Client**: Connect real Model Context Protocol servers (stdio + HTTP) and use their tools
|
|
19
|
+
- **Skills**: Package reusable prompts as Markdown (`/import-skill`, `/skill:<name>` with tab-completion)
|
|
20
|
+
- **Shell Hooks**: Run your own commands at lifecycle events (`PreToolUse`, `PostToolUse`, …)
|
|
21
|
+
- **Git Workflow**: `/git`, `/commit`, `/branch`, `/worktree`, `/pr` plus a `Git` tool for the agent
|
|
22
|
+
- **Plan Review**: Review what a plan will touch, edit it, then approve before any file changes
|
|
17
23
|
- **Plugin System**: Extend with custom tools, commands, and hooks
|
|
18
24
|
- **Session Persistence**: Resume conversations across sessions
|
|
19
25
|
- **Permission Modes**: Ask, YOLO, or Auto modes for different workflows
|
|
26
|
+
- **Shell Passthrough**: type `!` on an empty prompt to switch the composer into shell mode — no model round-trip
|
|
27
|
+
- **CI / Scripting**: Headless `-p` with `json` / `stream-json` output and outcome-based exit codes
|
|
28
|
+
- **Remote Control**: `--control` exposes a local socket to prompt/status/interrupt a running session
|
|
29
|
+
|
|
30
|
+
### CI usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# One prompt, machine-readable result, exit code reflects the outcome.
|
|
34
|
+
hncode -p "fix the failing tests" --output-format json
|
|
35
|
+
# -> {"type":"result","ok":true,"reply":"…","toolCalls":["Bash"],"files":["src/a.js"],…}
|
|
36
|
+
|
|
37
|
+
# Exit codes: 0 ok · 1 failure · 2 config · 3 no answer · 4 interrupted
|
|
38
|
+
hncode -p "run the linter and fix everything" --output-format stream-json | jq -c 'select(.type=="result")'
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Shell passthrough
|
|
42
|
+
|
|
43
|
+
Type `!` on an **empty** prompt to switch the composer into shell mode — the `!`
|
|
44
|
+
becomes the prompt symbol and the line runs directly, with no model involved:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
! git add -A && git push
|
|
48
|
+
! npm test
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The `!` is a mode marker, not part of what you type, so it appears exactly once.
|
|
52
|
+
Press `Esc` or `Backspace` on an empty line to leave the mode. Output appears in
|
|
53
|
+
the transcript as a `↳` receipt.
|
|
54
|
+
|
|
55
|
+
### Remote control
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
hncode --control # in one terminal (TUI)
|
|
59
|
+
hncode control status # in another
|
|
60
|
+
hncode control prompt "run the tests" # queue a prompt on the running session
|
|
61
|
+
hncode control interrupt
|
|
62
|
+
```
|
|
20
63
|
|
|
21
64
|
---
|
|
22
65
|
|
package/package.json
CHANGED
package/src/agent-task.js
CHANGED
|
@@ -176,3 +176,81 @@ export function statusColor(status) {
|
|
|
176
176
|
if (status === 'completed') return 'gray';
|
|
177
177
|
return 'red';
|
|
178
178
|
}
|
|
179
|
+
|
|
180
|
+
// ---- background-task transcript card (mirrors kimi-code) --------------------
|
|
181
|
+
// A background task reports its lifecycle as a one-line card in the transcript:
|
|
182
|
+
//
|
|
183
|
+
// ● agent task completed in background (review the diff)
|
|
184
|
+
// ✗ bash task failed in background (npm test · exit 1)
|
|
185
|
+
//
|
|
186
|
+
// `phase` drives the bullet colour (started / completed / failed); `headline`
|
|
187
|
+
// and the dim `detail` carry the rest. Ported from kimi-code's
|
|
188
|
+
// formatBackgroundTaskTranscript (apps/kimi-code/src/tui/utils/background-task-status.ts)
|
|
189
|
+
// so the two tools read the same way.
|
|
190
|
+
|
|
191
|
+
const BG_DETAIL_MAX = 240;
|
|
192
|
+
|
|
193
|
+
function bgTruncate(value) {
|
|
194
|
+
if (value === undefined || value === null) return undefined;
|
|
195
|
+
const collapsed = String(value).trim().replace(/\s+/g, ' ');
|
|
196
|
+
if (!collapsed) return undefined;
|
|
197
|
+
return collapsed.length <= BG_DETAIL_MAX ? collapsed : collapsed.slice(0, BG_DETAIL_MAX - 1) + '…';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function backgroundTaskPhase(status) {
|
|
201
|
+
if (status === 'running') return 'started';
|
|
202
|
+
if (status === 'completed') return 'completed';
|
|
203
|
+
return 'failed'; // failed / timed_out / killed / lost
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function bgSubject(kind) {
|
|
207
|
+
if (kind === 'agent') return 'agent task';
|
|
208
|
+
if (kind === 'question') return 'question task';
|
|
209
|
+
return 'bash task';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function bgHeadline(status, kind) {
|
|
213
|
+
const subject = bgSubject(kind);
|
|
214
|
+
switch (status) {
|
|
215
|
+
case 'running': return `${subject} started in background`;
|
|
216
|
+
case 'completed': return `${subject} completed in background`;
|
|
217
|
+
case 'failed': return `${subject} failed in background`;
|
|
218
|
+
case 'timed_out': return `${subject} timed out`;
|
|
219
|
+
case 'killed': return `${subject} stopped`;
|
|
220
|
+
case 'lost': return `${subject} lost`;
|
|
221
|
+
default: return `${subject} ${status}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function bgDetail(task) {
|
|
226
|
+
const parts = [];
|
|
227
|
+
const description = bgTruncate(task.description);
|
|
228
|
+
if (description !== undefined) parts.push(description);
|
|
229
|
+
|
|
230
|
+
if (task.status === 'completed' || task.status === 'failed') {
|
|
231
|
+
if (task.kind === 'process' && task.exitCode !== null && task.exitCode !== undefined) {
|
|
232
|
+
parts.push(`exit ${task.exitCode}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (task.status === 'killed') {
|
|
236
|
+
const reason = bgTruncate(task.stopReason);
|
|
237
|
+
parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped');
|
|
238
|
+
}
|
|
239
|
+
if (task.status === 'failed') {
|
|
240
|
+
const reason = bgTruncate(task.stopReason);
|
|
241
|
+
if (reason !== undefined) parts.push(reason);
|
|
242
|
+
}
|
|
243
|
+
if (task.status === 'timed_out') parts.push('timed out');
|
|
244
|
+
if (task.status === 'lost') parts.push('session restarted before completion');
|
|
245
|
+
|
|
246
|
+
return parts.length ? parts.join(' · ') : undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Build the card payload for a task snapshot.
|
|
250
|
+
export function backgroundTaskCard(task) {
|
|
251
|
+
return {
|
|
252
|
+
phase: backgroundTaskPhase(task.status),
|
|
253
|
+
headline: bgHeadline(task.status, task.kind),
|
|
254
|
+
detail: bgDetail(task),
|
|
255
|
+
};
|
|
256
|
+
}
|
package/src/agent.js
CHANGED
|
@@ -7,22 +7,28 @@ import { tools, getTool, llmTools } from './tools/index.js';
|
|
|
7
7
|
import { DELEGATION_TOOLS } from './subagent-types.js';
|
|
8
8
|
import { estimateMessagesTokens } from './term.js';
|
|
9
9
|
import { runHooks } from './plugin.js';
|
|
10
|
+
import { loadHooks, runShellHooks } from './hooks.js';
|
|
10
11
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
// A short, stable key for the prompt cache, derived from the conversation's
|
|
13
|
+
// first user message. Stability is the whole point: the same conversation must
|
|
14
|
+
// produce the same key on every turn, so a cheap deterministic hash beats a
|
|
15
|
+
// random id. (Different conversations may collide — that is harmless, it only
|
|
16
|
+
// means the provider's routing key is shared.)
|
|
17
|
+
function hashKey(s) {
|
|
18
|
+
let h = 0x811c9dc5;
|
|
19
|
+
const str = String(s || '');
|
|
20
|
+
for (let i = 0; i < str.length; i++) {
|
|
21
|
+
h ^= str.charCodeAt(i);
|
|
22
|
+
h = (h * 0x01000193) >>> 0;
|
|
23
|
+
}
|
|
24
|
+
return h.toString(36) + '-' + str.length.toString(36);
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
|
|
28
|
+
// Final instruction appended to the system prompt (see SYSTEM_PROMPT).
|
|
29
|
+
// (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
|
|
30
|
+
|
|
31
|
+
|
|
26
32
|
// Final instruction appended to the system prompt (see SYSTEM_PROMPT).
|
|
27
33
|
// (The wording lives in SYSTEM_PROMPT itself, under "Finishing a turn".)
|
|
28
34
|
|
|
@@ -118,9 +124,28 @@ export class Agent {
|
|
|
118
124
|
// tool a live getter over this agent's own messages instead of a copy, so the
|
|
119
125
|
// snapshot is taken at spawn time and reflects everything up to that point.
|
|
120
126
|
this.ctx._parentMessages = this.messages;
|
|
127
|
+
// Prompt-cache key: identify the SESSION, not the request, so the cached
|
|
128
|
+
// prefix is reused across turns instead of thrashing. The caller may pass one
|
|
129
|
+
// (the TUI knows the session id); otherwise derive a stable value from the
|
|
130
|
+
// first user message so repeated runs of the same conversation still share a
|
|
131
|
+
// prefix. A subagent inherits the parent's key (same conversation family).
|
|
132
|
+
if (cfg.sessionId == null) {
|
|
133
|
+
const firstUser = (Array.isArray(messages) ? messages : []).find((m) => m.role === 'user' && typeof m.content === 'string');
|
|
134
|
+
cfg = { ...cfg, sessionId: firstUser ? hashKey(firstUser.content) : 'default' };
|
|
135
|
+
this.cfg = cfg;
|
|
136
|
+
}
|
|
121
137
|
this.llm = new LLM(cfg);
|
|
122
138
|
this.stopRequested = false;
|
|
123
139
|
this.onApproval = onApproval; // async (toolName, args) => boolean
|
|
140
|
+
// Shell hooks (Claude Code-style): user-configured commands run at lifecycle
|
|
141
|
+
// events. ALWAYS stored as a `{ hooks: {Event: [...]} }` config, which is the
|
|
142
|
+
// shape runShellHooks() reads. A caller may inject one (tests / the TUI,
|
|
143
|
+
// which reloads per turn); otherwise it is read from disk for this workspace.
|
|
144
|
+
this.hookConfig = cfg.hookConfig
|
|
145
|
+
? (cfg.hookConfig.hooks ? cfg.hookConfig : { hooks: cfg.hookConfig })
|
|
146
|
+
: { hooks: loadHooks(cfg.workspace).hooks };
|
|
147
|
+
// The turn's user prompt, for the UserPromptSubmit hook. Set by run().
|
|
148
|
+
this._turnPrompt = '';
|
|
124
149
|
// Messages the user types while the agent is streaming. They are injected
|
|
125
150
|
// as user messages right AFTER the current round of tool calls, so the
|
|
126
151
|
// model sees "tool callback / user steer" together on the next request —
|
|
@@ -147,11 +172,23 @@ export class Agent {
|
|
|
147
172
|
// block at the end of this method. Both are awaited and both swallow plugin
|
|
148
173
|
// errors (see runHooks), so a broken plugin cannot abort a turn.
|
|
149
174
|
await runHooks('onTurnStart', { messages: this.messages });
|
|
175
|
+
// SHELL HOOK: SessionStart fires once per agent run (the first turn of a
|
|
176
|
+
// session). A hook failure never blocks — it is a convenience signal.
|
|
177
|
+
await runShellHooks(this.hookConfig, 'SessionStart', { messages: this.messages }, this.cfg.workspace);
|
|
178
|
+
// SHELL HOOK: UserPromptSubmit fires with the latest user message. It cannot
|
|
179
|
+
// block (Claude Code's can add context; blocking the user's own prompt is a
|
|
180
|
+
// worse failure than a missing hook), so the result is only logged.
|
|
181
|
+
{
|
|
182
|
+
const lastUser = [...this.messages].reverse().find((m) => m.role === 'user' && typeof m.content === 'string');
|
|
183
|
+
if (lastUser) {
|
|
184
|
+
this._turnPrompt = lastUser.content;
|
|
185
|
+
await runShellHooks(this.hookConfig, 'UserPromptSubmit', { prompt: lastUser.content }, this.cfg.workspace);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
150
188
|
let text = '';
|
|
151
189
|
let toolCalls = [];
|
|
152
190
|
let emittedEnd = false;
|
|
153
191
|
let thought = '';
|
|
154
|
-
let nudges = 0;
|
|
155
192
|
// decide whether running out of steps actually left the turn unfinished.
|
|
156
193
|
let hadToolCallsOnFinalStep = false;
|
|
157
194
|
|
|
@@ -204,8 +241,6 @@ export class Agent {
|
|
|
204
241
|
// No step cap: the loop runs until the model produces a final answer, the
|
|
205
242
|
// user interrupts (Esc / Ctrl-C), or an error ends the turn. maxSteps is
|
|
206
243
|
// kept only for callers that explicitly request a bound (0/undefined = none).
|
|
207
|
-
// Runaway safety comes from the completion guard (MAX_NUDGES) and the user's
|
|
208
|
-
// ability to interrupt, not from a fixed tool-round budget.
|
|
209
244
|
const stepLimit = (this.maxSteps > 0) ? this.maxSteps : Infinity;
|
|
210
245
|
for (let step = 0; step < stepLimit; step++) {
|
|
211
246
|
// Process any steer messages collected during the previous step
|
|
@@ -230,32 +265,90 @@ export class Agent {
|
|
|
230
265
|
// preserved rather than simply discarded.
|
|
231
266
|
const maxCtx = this.cfg.maxContextTokens || 512000;
|
|
232
267
|
let usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
233
|
-
if (maxCtx > 0 && usedNow >= maxCtx * 0.85) {
|
|
268
|
+
if (this.cfg.autoCompact !== false && maxCtx > 0 && usedNow >= maxCtx * 0.85) {
|
|
234
269
|
const before = usedNow;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
//
|
|
240
|
-
//
|
|
270
|
+
// SHELL HOOK: PreCompact — a chance to back up the transcript before the
|
|
271
|
+
// older half is summarized away. Never blocks (the trim must go ahead).
|
|
272
|
+
await runShellHooks(this.hookConfig, 'PreCompact',
|
|
273
|
+
{ tokens: usedNow, messages: this.messages }, this.cfg.workspace);
|
|
274
|
+
// Tell the TUI the trim is under way. Summarizing is a full model
|
|
275
|
+
// round-trip, so without this the UI sat silent for seconds mid-turn
|
|
276
|
+
// with no sign anything was happening.
|
|
277
|
+
this.onEvent({ type: 'compacting', before: usedNow, keep: 0, dropped: 0 });
|
|
278
|
+
//
|
|
279
|
+
// ORDER: SUMMARIZE FIRST, THEN TRIM. The summary is generated from the
|
|
280
|
+
// FULL history so the summarizing model can see what just happened — the
|
|
281
|
+
// most recent exchange is what tells it what the session is doing now.
|
|
282
|
+
// Trimming first and summarizing the leftovers summarized only the OLD
|
|
283
|
+
// part and lost the information the summary needed most.
|
|
284
|
+
//
|
|
285
|
+
// The transcript fed to the summarizer is BOUNDED: the full history can
|
|
286
|
+
// itself exceed the window, in which case the call fails and the trim
|
|
287
|
+
// would drop everything with nothing to show for it. Keep the MOST RECENT
|
|
288
|
+
// messages within the budget — they carry the live state — and clip any
|
|
289
|
+
// single oversized message.
|
|
290
|
+
const SUMMARY_INPUT_CHARS = 120_000;
|
|
291
|
+
const perMsgCap = 8_000;
|
|
292
|
+
const summaryParts = [];
|
|
293
|
+
let summaryChars = 0;
|
|
294
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
295
|
+
const m = this.messages[i];
|
|
296
|
+
const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '');
|
|
297
|
+
const clipped = raw.length > perMsgCap ? raw.slice(0, perMsgCap) + '\n…[clipped]' : raw;
|
|
298
|
+
const line = `${m.role}: ${clipped}`;
|
|
299
|
+
if (summaryChars + line.length > SUMMARY_INPUT_CHARS) break;
|
|
300
|
+
summaryChars += line.length;
|
|
301
|
+
summaryParts.unshift(line);
|
|
302
|
+
}
|
|
241
303
|
let summary = '';
|
|
242
|
-
if (
|
|
304
|
+
if (this.messages.length > 0) {
|
|
243
305
|
const summaryPrompt = [
|
|
244
306
|
{ role: 'system', content: 'Summarize the conversation history that follows. Capture the user\'s goals, the key decisions made, files created/modified, and the current state of any ongoing work. Be concise but thorough.' },
|
|
245
|
-
|
|
307
|
+
{ role: 'user', content: summaryParts.join('\n\n') },
|
|
246
308
|
];
|
|
247
309
|
const cfgCopy = { ...this.cfg, maxOutputTokens: Math.min(this.cfg.maxOutputTokens || 4096, 2048) };
|
|
248
310
|
const llm = new LLM(cfgCopy);
|
|
249
311
|
try { summary = await llm.requestText(summaryPrompt); } catch { summary = ''; }
|
|
250
|
-
if (summary) {
|
|
251
|
-
this.messages = [{
|
|
252
|
-
role: 'system',
|
|
253
|
-
content: `[hncode] Earlier conversation context was auto-compacted to stay within the context window. Summary of what was trimmed:\n\n${summary}`,
|
|
254
|
-
}, ...this.messages];
|
|
255
|
-
}
|
|
256
312
|
}
|
|
313
|
+
// NOW trim. Keep under a TOKEN budget, not a message count: sizes vary by
|
|
314
|
+
// orders of magnitude (one Read result can dwarf fifty short turns), so a
|
|
315
|
+
// fixed 20%-of-messages slice could still leave the request above the
|
|
316
|
+
// threshold or throw away far more than needed. Walk backwards until the
|
|
317
|
+
// retained tail reaches ~30% of the window; always keep at least the last
|
|
318
|
+
// two so the newest exchange survives.
|
|
319
|
+
const keepBudget = Math.max(1, Math.floor(maxCtx * 0.3));
|
|
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--;
|
|
331
|
+
}
|
|
332
|
+
// BOUNDARY FIX: never let the kept slice START on a `tool` message whose
|
|
333
|
+
// assistant(toolCalls) was dropped — most APIs reject a tool result with
|
|
334
|
+
// no preceding call. Pull the boundary back over any leading tool rows.
|
|
335
|
+
while (keepFrom > 0 && keepFrom < this.messages.length && this.messages[keepFrom].role === 'tool') {
|
|
336
|
+
keepFrom--;
|
|
337
|
+
}
|
|
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];
|
|
257
350
|
usedNow = estimateMessagesTokens(this.messages, this.cfg);
|
|
258
|
-
this.onEvent({ type: 'compacted', before, after: usedNow, kept:
|
|
351
|
+
this.onEvent({ type: 'compacted', before, after: usedNow, kept: keep.length });
|
|
259
352
|
}
|
|
260
353
|
// Live context gauge: report the estimated size of the request about to be
|
|
261
354
|
// sent, so the TUI's context readout is refreshed at every step rather than
|
|
@@ -311,18 +404,8 @@ export class Agent {
|
|
|
311
404
|
for (const msg of steers) this.messages.push({ role: 'user', content: msg });
|
|
312
405
|
continue;
|
|
313
406
|
} else if (!hasText) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const why = thought.trim()
|
|
317
|
-
? 'your last output was reasoning/thinking only, with no answer text'
|
|
318
|
-
: 'your final message was empty';
|
|
319
|
-
this.onEvent({ type: 'nudge', reason: why, attempt: nudges, max: MAX_NUDGES });
|
|
320
|
-
const last = this.messages[this.messages.length - 1];
|
|
321
|
-
const body = nudgeMessage(why);
|
|
322
|
-
if (last && last.role === 'user' && typeof last.content === 'string') last.content += '\n\n' + body;
|
|
323
|
-
else this.messages.push({ role: 'user', content: body });
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
407
|
+
// No tool calls and no answer text: the model stopped without saying
|
|
408
|
+
// anything. End the turn.
|
|
326
409
|
break;
|
|
327
410
|
} else {
|
|
328
411
|
// The model produced its answer. A steer typed WHILE that answer was
|
|
@@ -389,6 +472,21 @@ export class Agent {
|
|
|
389
472
|
result = `Error: unknown tool "${tc.name}".`;
|
|
390
473
|
allOk = false;
|
|
391
474
|
} else {
|
|
475
|
+
// SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
|
|
476
|
+
// message goes back to the model in place of the tool result — that is
|
|
477
|
+
// the whole point of a pre-hook (e.g. refuse a write that fails a lint
|
|
478
|
+
// gate). Checked BEFORE approving/executing so a blocked call never
|
|
479
|
+
// touches the filesystem.
|
|
480
|
+
const pre = await runShellHooks(this.hookConfig, 'PreToolUse',
|
|
481
|
+
{ toolName: tc.name, toolArgs: tc.args }, this.ctx.cwd || this.cfg.workspace);
|
|
482
|
+
if (pre.blocked) {
|
|
483
|
+
result = `Blocked by a PreToolUse hook: ${pre.reason}`;
|
|
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
|
+
}
|
|
392
490
|
if (this.onToolStart) this.onToolStart(tc.name, tc.args);
|
|
393
491
|
this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
|
|
394
492
|
// Plugin hook: onToolExecute fires BEFORE the tool runs and receives the
|
|
@@ -404,6 +502,10 @@ export class Agent {
|
|
|
404
502
|
this.ctx.lastResult = result;
|
|
405
503
|
// Plugin hook: onToolResult fires with the tool's output.
|
|
406
504
|
await runHooks('onToolResult', tc.name, result, this.ctx);
|
|
505
|
+
// SHELL HOOK: PostToolUse. Never blocks (the tool already ran); a
|
|
506
|
+
// failure is logged. This is where "format after every edit" lives.
|
|
507
|
+
await runShellHooks(this.hookConfig, 'PostToolUse',
|
|
508
|
+
{ toolName: tc.name, toolArgs: tc.args, toolResult: result }, this.ctx.cwd || this.cfg.workspace);
|
|
407
509
|
// Surface the live TODO list so the TUI can render its panel.
|
|
408
510
|
if (tc.name === 'TodoList') {
|
|
409
511
|
this.onEvent({ type: 'todos', todos: this.ctx.todoState || [] });
|
|
@@ -441,7 +543,6 @@ export class Agent {
|
|
|
441
543
|
this.onEvent({
|
|
442
544
|
type: 'incomplete',
|
|
443
545
|
reason: 'the step budget was exhausted while tools were still being called',
|
|
444
|
-
usedNudges: nudges,
|
|
445
546
|
});
|
|
446
547
|
}
|
|
447
548
|
if (!emittedEnd && this.onEvent) this.onEvent({ type: 'done' });
|
|
@@ -453,5 +554,9 @@ export class Agent {
|
|
|
453
554
|
// Plugin lifecycle: the turn is over (normally, interrupted, or out of steps).
|
|
454
555
|
// In a finally-equivalent position — every exit path above reaches here.
|
|
455
556
|
await runHooks('onTurnEnd', { messages: this.messages, stopped: this.stopRequested });
|
|
557
|
+
// SHELL HOOK: Stop fires when the turn ends, whether it finished or was
|
|
558
|
+
// interrupted. Never blocks.
|
|
559
|
+
await runShellHooks(this.hookConfig, 'Stop',
|
|
560
|
+
{ messages: this.messages, stopped: this.stopRequested, prompt: this._turnPrompt }, this.cfg.workspace);
|
|
456
561
|
}
|
|
457
562
|
}
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Prompt caching — explicit cache breakpoints for long, stable prefixes.
|
|
2
|
+
//
|
|
3
|
+
// Why this is worth doing: an agent resends the whole conversation every turn,
|
|
4
|
+
// and the prefix (system prompt + tool definitions + early messages) is
|
|
5
|
+
// byte-identical across turns. Providers that support prompt caching bill those
|
|
6
|
+
// tokens at a fraction of the input price and skip the prefill compute. Without
|
|
7
|
+
// explicit breakpoints only the providers that cache automatically benefit.
|
|
8
|
+
//
|
|
9
|
+
// Protocol differences:
|
|
10
|
+
// * anthropic — explicit: mark up to N breakpoints with
|
|
11
|
+
// `cache_control: { type: "ephemeral" }`. Anthropic caches everything UP TO
|
|
12
|
+
// and INCLUDING a marked block, so the LAST breakpoint should sit at the end
|
|
13
|
+
// of the stable prefix. The minimum cacheable prefix is model-dependent
|
|
14
|
+
// (~1024 tokens); marking a shorter prefix is a no-op, not an error.
|
|
15
|
+
// * openai — automatic for prompts over ~1024 tokens on supporting models. A
|
|
16
|
+
// stable `prompt_cache_key` improves routing; keys must not vary per request.
|
|
17
|
+
// * everything else — no-op. Unknown providers are left untouched: sending an
|
|
18
|
+
// unsupported `cache_control` field can 400 some gateways.
|
|
19
|
+
//
|
|
20
|
+
// The cache is only useful when the prefix is STABLE, so the helpers here take
|
|
21
|
+
// care to mark the same positions every turn for the same conversation.
|
|
22
|
+
|
|
23
|
+
import { estimateTokens } from './term.js';
|
|
24
|
+
|
|
25
|
+
// Anthropic allows at most 4 cache breakpoints per request.
|
|
26
|
+
export const MAX_BREAKPOINTS = 4;
|
|
27
|
+
// Below this, a breakpoint is pointless: most providers will not cache a prefix
|
|
28
|
+
// this short, and the marker is wasted budget.
|
|
29
|
+
export const MIN_CACHEABLE_TOKENS = 1024;
|
|
30
|
+
|
|
31
|
+
export function supportsCaching(protocol) {
|
|
32
|
+
return protocol === 'anthropic' || protocol === 'openai';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Index (into `messages`) of the last message that belongs to the STABLE prefix.
|
|
36
|
+
//
|
|
37
|
+
// The prefix is only cacheable while it cannot change between turns. The system
|
|
38
|
+
// prompt and tool list are fixed for a session; the conversation then grows by
|
|
39
|
+
// APPENDING. So the prefix is "everything except the recent tail", and the tail
|
|
40
|
+
// must be excluded because it is what changes next turn.
|
|
41
|
+
//
|
|
42
|
+
// We keep the last `keepTail` messages out of the cached region: a tool result
|
|
43
|
+
// appended this turn is not part of the stable prefix next turn until it is
|
|
44
|
+
// itself superseded. Returns -1 when there is nothing worth marking.
|
|
45
|
+
export function stablePrefixEnd(messages, keepTail = 2) {
|
|
46
|
+
const n = Array.isArray(messages) ? messages.length : 0;
|
|
47
|
+
if (n === 0) return -1;
|
|
48
|
+
// Never mark the very last message: it is the newest content and is the most
|
|
49
|
+
// likely to be replaced/regenerated.
|
|
50
|
+
const end = Math.min(n - 1, n - 1 - Math.max(1, keepTail));
|
|
51
|
+
return end >= 0 ? end : -1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Attach Anthropic `cache_control` breakpoints to a request body IN PLACE-safe
|
|
55
|
+
// fashion (returns a new body). Breakpoints are placed:
|
|
56
|
+
// 1. on the LAST tool definition (caches the whole tool block),
|
|
57
|
+
// 2. on the system prompt (caches tools + system together),
|
|
58
|
+
// 3. on the last message of the stable prefix (caches the conversation head).
|
|
59
|
+
// Marking the tail first and working backwards keeps the total <= MAX_BREAKPOINTS.
|
|
60
|
+
export function withAnthropicCache(body, opts = {}) {
|
|
61
|
+
if (!body || typeof body !== 'object') return body;
|
|
62
|
+
const minTokens = opts.minTokens || MIN_CACHEABLE_TOKENS;
|
|
63
|
+
const out = { ...body };
|
|
64
|
+
const marker = { type: 'ephemeral' };
|
|
65
|
+
|
|
66
|
+
// Tools: marking the last one caches the entire tools array.
|
|
67
|
+
if (Array.isArray(out.tools) && out.tools.length) {
|
|
68
|
+
const tools = out.tools.slice();
|
|
69
|
+
tools[tools.length - 1] = { ...tools[tools.length - 1], cache_control: marker };
|
|
70
|
+
out.tools = tools;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// System: a plain string must become a text block to carry cache_control.
|
|
74
|
+
if (typeof out.system === 'string' && out.system.trim()) {
|
|
75
|
+
out.system = [{ type: 'text', text: out.system, cache_control: marker }];
|
|
76
|
+
} else if (Array.isArray(out.system) && out.system.length) {
|
|
77
|
+
const sys = out.system.slice();
|
|
78
|
+
const last = sys.length - 1;
|
|
79
|
+
sys[last] = { ...(typeof sys[last] === 'string' ? { type: 'text', text: sys[last] } : sys[last]), cache_control: marker };
|
|
80
|
+
out.system = sys;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Conversation head: mark the stable prefix's last message.
|
|
84
|
+
if (Array.isArray(out.messages) && out.messages.length) {
|
|
85
|
+
const end = stablePrefixEnd(out.messages, opts.keepTail);
|
|
86
|
+
if (end >= 0 && end < out.messages.length) {
|
|
87
|
+
// Only worth a breakpoint once the prefix is long enough to cache.
|
|
88
|
+
const prefixText = out.messages.slice(0, end + 1)
|
|
89
|
+
.map((m) => (typeof m.content === 'string' ? m.content
|
|
90
|
+
: Array.isArray(m.content) ? m.content.map((b) => b.text || '').join('') : ''))
|
|
91
|
+
.join('\n');
|
|
92
|
+
if (estimateTokens(prefixText) >= minTokens) {
|
|
93
|
+
const msgs = out.messages.slice();
|
|
94
|
+
const msg = { ...msgs[end] };
|
|
95
|
+
if (typeof msg.content === 'string') {
|
|
96
|
+
msg.content = [{ type: 'text', text: msg.content }];
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(msg.content) && msg.content.length) {
|
|
99
|
+
const c = msg.content.slice();
|
|
100
|
+
const li = c.length - 1;
|
|
101
|
+
c[li] = { ...c[li], cache_control: marker };
|
|
102
|
+
msg.content = c;
|
|
103
|
+
}
|
|
104
|
+
msgs[end] = msg;
|
|
105
|
+
out.messages = msgs;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// OpenAI-compatible: most providers cache automatically. A STABLE
|
|
113
|
+
// `prompt_cache_key` helps routing on the providers that accept it, and must not
|
|
114
|
+
// change between turns of the same conversation or the cache thrashes.
|
|
115
|
+
// `seed` should therefore identify the SESSION, not the request.
|
|
116
|
+
export function withOpenAiCache(body, opts = {}) {
|
|
117
|
+
if (!body || typeof body !== 'object') return body;
|
|
118
|
+
const key = opts.sessionKey;
|
|
119
|
+
if (!key) return body;
|
|
120
|
+
return { ...body, prompt_cache_key: String(key) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Apply the right caching strategy for `protocol`. Returns the body unchanged
|
|
124
|
+
// for protocols with no explicit support.
|
|
125
|
+
export function applyPromptCache(protocol, body, opts = {}) {
|
|
126
|
+
if (!opts.enabled) return body;
|
|
127
|
+
if (protocol === 'anthropic') return withAnthropicCache(body, opts);
|
|
128
|
+
if (protocol === 'openai') return withOpenAiCache(body, opts);
|
|
129
|
+
return body;
|
|
130
|
+
}
|