@bubblebrain-ai/bubble 0.0.43 → 0.0.45
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/dist/agent/change-tracker.d.ts +40 -0
- package/dist/agent/change-tracker.js +89 -0
- package/dist/agent/profiles.js +1 -1
- package/dist/agent.js +11 -3
- package/dist/context/prune.d.ts +17 -1
- package/dist/context/prune.js +32 -1
- package/dist/main.js +22 -0
- package/dist/model-pricing.d.ts +7 -0
- package/dist/model-pricing.js +54 -2
- package/dist/orchestrator/default-hooks.js +31 -7
- package/dist/orchestrator/hooks.d.ts +5 -0
- package/dist/print-output.d.ts +28 -0
- package/dist/print-output.js +14 -2
- package/dist/prompt/reminders.d.ts +8 -1
- package/dist/prompt/reminders.js +21 -6
- package/dist/prompt/task-reminders.js +1 -1
- package/dist/provider-anthropic.d.ts +26 -0
- package/dist/provider-anthropic.js +121 -2
- package/package.json +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-ground-truth change tracking for a single agent run.
|
|
3
|
+
*
|
|
4
|
+
* Tool-metadata bookkeeping (kind === "write" | "edit" | "patch") misses every
|
|
5
|
+
* other mutation channel: bash heredocs/sed -i, scripts, subagent worktree
|
|
6
|
+
* merges. Git sees them all. A baseline snapshot is taken at run start; the
|
|
7
|
+
* delta at completion time is the run's actual footprint.
|
|
8
|
+
*
|
|
9
|
+
* Everything here degrades to null outside a git repo or on any git error —
|
|
10
|
+
* callers fall back to metadata-based signals.
|
|
11
|
+
*/
|
|
12
|
+
export interface GitChangeBaseline {
|
|
13
|
+
/** Paths already dirty (or untracked) before the run started. */
|
|
14
|
+
dirtyAtStart: Set<string>;
|
|
15
|
+
}
|
|
16
|
+
export interface ModifiedExistingTest {
|
|
17
|
+
path: string;
|
|
18
|
+
deletedLines: number;
|
|
19
|
+
}
|
|
20
|
+
export interface RunChangeSummary {
|
|
21
|
+
/** Paths that became dirty during the run (not dirty at baseline). */
|
|
22
|
+
changedFiles: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Pre-existing (tracked) test files that were modified during the run,
|
|
25
|
+
* with how many lines the working tree deletes from them vs HEAD. New test
|
|
26
|
+
* files created by the run are NOT listed — creating tests is not suspect.
|
|
27
|
+
*/
|
|
28
|
+
modifiedExistingTests: ModifiedExistingTest[];
|
|
29
|
+
}
|
|
30
|
+
export declare function isTestFilePath(path: string): boolean;
|
|
31
|
+
export declare function captureGitBaseline(cwd: string): Promise<GitChangeBaseline | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Diff the current working tree against the run-start baseline.
|
|
34
|
+
*
|
|
35
|
+
* Known honest limits: a file already dirty at baseline that the run edited
|
|
36
|
+
* further is not attributed to the run (indistinguishable without content
|
|
37
|
+
* snapshots), and deleted-line counts for such files would mix user and agent
|
|
38
|
+
* edits — so they are excluded entirely.
|
|
39
|
+
*/
|
|
40
|
+
export declare function detectRunChanges(cwd: string, baseline: GitChangeBaseline | null): Promise<RunChangeSummary | null>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-ground-truth change tracking for a single agent run.
|
|
3
|
+
*
|
|
4
|
+
* Tool-metadata bookkeeping (kind === "write" | "edit" | "patch") misses every
|
|
5
|
+
* other mutation channel: bash heredocs/sed -i, scripts, subagent worktree
|
|
6
|
+
* merges. Git sees them all. A baseline snapshot is taken at run start; the
|
|
7
|
+
* delta at completion time is the run's actual footprint.
|
|
8
|
+
*
|
|
9
|
+
* Everything here degrades to null outside a git repo or on any git error —
|
|
10
|
+
* callers fall back to metadata-based signals.
|
|
11
|
+
*/
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
14
|
+
const TEST_PATH_PATTERN = /(^|\/)(tests?|__tests__|spec)(\/|$)|(\.|_|-)(test|tests|spec)\.[^/]+$|(^|\/)test_[^/]+$/i;
|
|
15
|
+
export function isTestFilePath(path) {
|
|
16
|
+
return TEST_PATH_PATTERN.test(path);
|
|
17
|
+
}
|
|
18
|
+
function git(cwd, args) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
execFile("git", args, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => {
|
|
21
|
+
resolve(error ? null : stdout);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** `git status --porcelain -z` → set of paths with any dirty/untracked state. */
|
|
26
|
+
async function dirtyPaths(cwd) {
|
|
27
|
+
const out = await git(cwd, ["status", "--porcelain", "-uall", "-z"]);
|
|
28
|
+
if (out === null)
|
|
29
|
+
return null;
|
|
30
|
+
const paths = new Set();
|
|
31
|
+
for (const entry of out.split("\0")) {
|
|
32
|
+
if (entry.length < 4)
|
|
33
|
+
continue;
|
|
34
|
+
// "XY path" (rename entries "XY new\0old" — the extra segment lacks the
|
|
35
|
+
// status prefix and is shorter than 4 chars only when degenerate; treat
|
|
36
|
+
// every segment with the status shape as an entry).
|
|
37
|
+
const path = entry.slice(3);
|
|
38
|
+
if (path)
|
|
39
|
+
paths.add(path);
|
|
40
|
+
}
|
|
41
|
+
return paths;
|
|
42
|
+
}
|
|
43
|
+
export async function captureGitBaseline(cwd) {
|
|
44
|
+
const dirty = await dirtyPaths(cwd);
|
|
45
|
+
if (dirty === null)
|
|
46
|
+
return null;
|
|
47
|
+
return { dirtyAtStart: dirty };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Diff the current working tree against the run-start baseline.
|
|
51
|
+
*
|
|
52
|
+
* Known honest limits: a file already dirty at baseline that the run edited
|
|
53
|
+
* further is not attributed to the run (indistinguishable without content
|
|
54
|
+
* snapshots), and deleted-line counts for such files would mix user and agent
|
|
55
|
+
* edits — so they are excluded entirely.
|
|
56
|
+
*/
|
|
57
|
+
export async function detectRunChanges(cwd, baseline) {
|
|
58
|
+
if (!baseline)
|
|
59
|
+
return null;
|
|
60
|
+
const dirtyNow = await dirtyPaths(cwd);
|
|
61
|
+
if (dirtyNow === null)
|
|
62
|
+
return null;
|
|
63
|
+
const changedFiles = [...dirtyNow].filter((path) => !baseline.dirtyAtStart.has(path)).sort();
|
|
64
|
+
if (changedFiles.length === 0) {
|
|
65
|
+
return { changedFiles, modifiedExistingTests: [] };
|
|
66
|
+
}
|
|
67
|
+
// Untracked paths are new files; only tracked files count as "existing".
|
|
68
|
+
const untrackedOut = await git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
69
|
+
const untracked = new Set((untrackedOut ?? "").split("\0").filter(Boolean));
|
|
70
|
+
const modifiedExistingTests = [];
|
|
71
|
+
const candidates = changedFiles.filter((path) => isTestFilePath(path) && !untracked.has(path));
|
|
72
|
+
if (candidates.length > 0) {
|
|
73
|
+
const numstat = await git(cwd, ["diff", "HEAD", "--numstat", "-z", "--", ...candidates]);
|
|
74
|
+
if (numstat !== null) {
|
|
75
|
+
// -z numstat entries: "added\tdeleted\tpath\0" (renames add a segment).
|
|
76
|
+
for (const entry of numstat.split("\0")) {
|
|
77
|
+
const parts = entry.split("\t");
|
|
78
|
+
if (parts.length < 3)
|
|
79
|
+
continue;
|
|
80
|
+
const deleted = Number(parts[1]);
|
|
81
|
+
const path = parts[2];
|
|
82
|
+
if (!path || Number.isNaN(deleted))
|
|
83
|
+
continue;
|
|
84
|
+
modifiedExistingTests.push({ path, deletedLines: deleted });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { changedFiles, modifiedExistingTests };
|
|
89
|
+
}
|
package/dist/agent/profiles.js
CHANGED
|
@@ -155,7 +155,7 @@ export function builtinAgentProfiles() {
|
|
|
155
155
|
"You are an implementer subagent working in your own isolated git worktree — the parent tree is never touched.",
|
|
156
156
|
"Your worktree forks from the last commit; changes the parent made after that commit are NOT present unless included in your briefing.",
|
|
157
157
|
"Make the smallest coherent edits that complete your assigned group. Stay strictly inside the file set you were given; if a needed change falls outside it, report that instead of editing.",
|
|
158
|
-
"Verify your changes
|
|
158
|
+
"Verify your changes when possible — run the affected module's existing tests, not only the cases you added — and end with a concise summary of files changed and verification results; the parent reviews and applies your worktree.",
|
|
159
159
|
].join("\n"),
|
|
160
160
|
},
|
|
161
161
|
];
|
package/dist/agent.js
CHANGED
|
@@ -13,7 +13,7 @@ import { buildContextUsageSnapshot } from "./context/usage.js";
|
|
|
13
13
|
import { isContextOverflowError } from "./context/overflow.js";
|
|
14
14
|
import { computeRetryDelayMs, isProviderStreamInterruption, MAX_STREAM_INTERRUPTION_RETRIES, sleepBeforeRetry, } from "./network/retry.js";
|
|
15
15
|
import { projectMessages } from "./context/projector.js";
|
|
16
|
-
import { aggressivePruneMessages, markStableCurrentToolResultsForCache } from "./context/prune.js";
|
|
16
|
+
import { aggressivePruneMessages, markStableCurrentToolResultsForCache, markToolResultCacheStable } from "./context/prune.js";
|
|
17
17
|
import { truncateToolOutputForModel } from "./context/tool-output-truncate.js";
|
|
18
18
|
import { buildDeferredToolsReminder, buildToolFreezeReminder, reminderForMode } from "./prompt/reminders.js";
|
|
19
19
|
import { HookBus } from "./orchestrator/hooks.js";
|
|
@@ -573,6 +573,10 @@ export class Agent {
|
|
|
573
573
|
// the redirected one.
|
|
574
574
|
hookState.exploredFiles?.clear();
|
|
575
575
|
hookState.largeTaskCheckpointDone = false;
|
|
576
|
+
// A steer is a new ask: the completion gate must be able to fire
|
|
577
|
+
// again for the follow-up's closing pass, or appended requirements
|
|
578
|
+
// never get a self-check (codeChanged stays — prior edits are real).
|
|
579
|
+
hookState.completionGateFired = false;
|
|
576
580
|
if (typeof input.content === "string" && input.content.trim()) {
|
|
577
581
|
hookState.taskType = classifyTask(input.content);
|
|
578
582
|
}
|
|
@@ -1308,13 +1312,17 @@ export class Agent {
|
|
|
1308
1312
|
limit: truncatedOutput.limit,
|
|
1309
1313
|
},
|
|
1310
1314
|
}, traceContext);
|
|
1311
|
-
|
|
1315
|
+
const toolMessage = {
|
|
1312
1316
|
role: "tool",
|
|
1313
1317
|
toolCallId: tc.id,
|
|
1314
1318
|
content: truncatedOutput.content,
|
|
1315
1319
|
metadata: result.metadata,
|
|
1316
1320
|
isError: result.isError,
|
|
1317
|
-
}
|
|
1321
|
+
};
|
|
1322
|
+
// Stamp before appending: appendMessage persists to the session log
|
|
1323
|
+
// synchronously, so a mark added on a later turn never reaches disk.
|
|
1324
|
+
markToolResultCacheStable(tc.name, toolMessage);
|
|
1325
|
+
this.appendMessage(toolMessage);
|
|
1318
1326
|
this.compactResidentHistory();
|
|
1319
1327
|
flushGovernorReminders();
|
|
1320
1328
|
this.onToolResult?.(tc.name, result);
|
package/dist/context/prune.d.ts
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
|
-
import type { Message } from "../types.js";
|
|
1
|
+
import type { Message, ToolMessage } from "../types.js";
|
|
2
2
|
export declare function pruneMessages<T extends Message>(messages: T[]): T[];
|
|
3
|
+
/**
|
|
4
|
+
* Stamp a tool result as "projected in full" at the moment it is appended.
|
|
5
|
+
*
|
|
6
|
+
* markStableCurrentToolResultsForCache does the same thing one turn later, which
|
|
7
|
+
* is too late for the session log: the message is serialized to disk when it is
|
|
8
|
+
* appended, so a mark added afterwards never reaches the file and a resumed
|
|
9
|
+
* session re-prunes history the model had already been shown in full.
|
|
10
|
+
*/
|
|
11
|
+
export declare function markToolResultCacheStable(toolName: string, message: ToolMessage): void;
|
|
3
12
|
export declare function markStableCurrentToolResultsForCache(messages: Message[]): void;
|
|
4
13
|
/**
|
|
5
14
|
* Aggressive variant of pruneMessages: drops the content of every prunable
|
|
6
15
|
* tool output except the latest unresolved tool turn that the model still
|
|
7
16
|
* needs to reason over. Used as a last-resort microcompact pass when a
|
|
8
17
|
* standard prune hasn't reclaimed enough budget.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately ignores `cacheStableProjection`, unlike pruneMessages. Honouring
|
|
20
|
+
* it here would make this a no-op — every result that has been sent once is
|
|
21
|
+
* marked — which is fine for the prefix cache and fatal for the two callers that
|
|
22
|
+
* depend on it: context-overflow (HTTP 400) recovery and the hard resident-memory
|
|
23
|
+
* limit. Losing the cache is the correct trade when the alternative is a run that
|
|
24
|
+
* cannot recover.
|
|
9
25
|
*/
|
|
10
26
|
export declare function aggressivePruneMessages<T extends Message>(messages: T[]): T[];
|
package/dist/context/prune.js
CHANGED
|
@@ -54,6 +54,22 @@ export function pruneMessages(messages) {
|
|
|
54
54
|
};
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Stamp a tool result as "projected in full" at the moment it is appended.
|
|
59
|
+
*
|
|
60
|
+
* markStableCurrentToolResultsForCache does the same thing one turn later, which
|
|
61
|
+
* is too late for the session log: the message is serialized to disk when it is
|
|
62
|
+
* appended, so a mark added afterwards never reaches the file and a resumed
|
|
63
|
+
* session re-prunes history the model had already been shown in full.
|
|
64
|
+
*/
|
|
65
|
+
export function markToolResultCacheStable(toolName, message) {
|
|
66
|
+
if (!shouldPruneToolResult(toolName, message.content))
|
|
67
|
+
return;
|
|
68
|
+
message.metadata = {
|
|
69
|
+
...message.metadata,
|
|
70
|
+
[CACHE_STABLE_PROJECTION_KEY]: CACHE_STABLE_FULL_PROJECTION,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
57
73
|
export function markStableCurrentToolResultsForCache(messages) {
|
|
58
74
|
const protectedToolCallIds = collectProtectedToolCallIds(messages);
|
|
59
75
|
if (protectedToolCallIds.size === 0)
|
|
@@ -101,6 +117,13 @@ function isCacheStableFullToolResult(message) {
|
|
|
101
117
|
* tool output except the latest unresolved tool turn that the model still
|
|
102
118
|
* needs to reason over. Used as a last-resort microcompact pass when a
|
|
103
119
|
* standard prune hasn't reclaimed enough budget.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately ignores `cacheStableProjection`, unlike pruneMessages. Honouring
|
|
122
|
+
* it here would make this a no-op — every result that has been sent once is
|
|
123
|
+
* marked — which is fine for the prefix cache and fatal for the two callers that
|
|
124
|
+
* depend on it: context-overflow (HTTP 400) recovery and the hard resident-memory
|
|
125
|
+
* limit. Losing the cache is the correct trade when the alternative is a run that
|
|
126
|
+
* cannot recover.
|
|
104
127
|
*/
|
|
105
128
|
export function aggressivePruneMessages(messages) {
|
|
106
129
|
const toolNameByCallId = new Map();
|
|
@@ -128,7 +151,15 @@ export function aggressivePruneMessages(messages) {
|
|
|
128
151
|
function collectProtectedToolCallIds(messages) {
|
|
129
152
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
130
153
|
const message = messages[index];
|
|
131
|
-
|
|
154
|
+
// A trailing user message is steered input (or a resumed session's new
|
|
155
|
+
// instruction), not the end of the tool batch. Stopping on it left the
|
|
156
|
+
// just-finished batch unmarked forever, so it stayed a live prune candidate
|
|
157
|
+
// and later batches rewrote its content mid-history — which both loses tool
|
|
158
|
+
// output the model may still need and invalidates the provider prefix cache.
|
|
159
|
+
if (message.role === "tool"
|
|
160
|
+
|| message.role === "system"
|
|
161
|
+
|| message.role === "meta"
|
|
162
|
+
|| message.role === "user") {
|
|
132
163
|
continue;
|
|
133
164
|
}
|
|
134
165
|
if (message.role === "assistant" && message.toolCalls && message.toolCalls.length > 0) {
|
package/dist/main.js
CHANGED
|
@@ -575,6 +575,26 @@ async function main() {
|
|
|
575
575
|
// mode is byte-for-byte the previous behavior.
|
|
576
576
|
const jsonOutput = args.outputFormat === "json";
|
|
577
577
|
const collector = new PrintRunCollector();
|
|
578
|
+
// Harness-side change footprint (git ground truth) — printed by the
|
|
579
|
+
// harness so the run cannot omit or misstate what it touched.
|
|
580
|
+
const changeBaseline = jsonOutput
|
|
581
|
+
? await (await import("./agent/change-tracker.js")).captureGitBaseline(args.cwd)
|
|
582
|
+
: null;
|
|
583
|
+
const collectChanges = async () => {
|
|
584
|
+
if (!changeBaseline)
|
|
585
|
+
return undefined;
|
|
586
|
+
const { detectRunChanges } = await import("./agent/change-tracker.js");
|
|
587
|
+
const detected = await detectRunChanges(args.cwd, changeBaseline).catch(() => null);
|
|
588
|
+
if (!detected)
|
|
589
|
+
return undefined;
|
|
590
|
+
return {
|
|
591
|
+
changed_files: detected.changedFiles.length,
|
|
592
|
+
modified_existing_tests: detected.modifiedExistingTests.map((t) => ({
|
|
593
|
+
path: t.path,
|
|
594
|
+
deleted_lines: t.deletedLines,
|
|
595
|
+
})),
|
|
596
|
+
};
|
|
597
|
+
};
|
|
578
598
|
const printSessionId = sessionManager
|
|
579
599
|
? basename(sessionManager.getSessionFile())
|
|
580
600
|
: undefined;
|
|
@@ -617,6 +637,7 @@ async function main() {
|
|
|
617
637
|
summary: collector.summary(),
|
|
618
638
|
sessionId: printSessionId,
|
|
619
639
|
compaction: agent.getCompactionStats(),
|
|
640
|
+
changes: await collectChanges(),
|
|
620
641
|
}) + "\n");
|
|
621
642
|
}
|
|
622
643
|
else {
|
|
@@ -633,6 +654,7 @@ async function main() {
|
|
|
633
654
|
summary: collector.summary(),
|
|
634
655
|
sessionId: printSessionId,
|
|
635
656
|
compaction: agent.getCompactionStats(),
|
|
657
|
+
changes: await collectChanges(),
|
|
636
658
|
}) + "\n");
|
|
637
659
|
process.exitCode = 1;
|
|
638
660
|
}
|
package/dist/model-pricing.d.ts
CHANGED
|
@@ -6,6 +6,13 @@ export interface ModelPricing {
|
|
|
6
6
|
currency: PricingCurrency;
|
|
7
7
|
inputCacheHitPerMillion: number;
|
|
8
8
|
inputCacheMissPerMillion: number;
|
|
9
|
+
/**
|
|
10
|
+
* Rate for tokens written INTO the cache, when the provider charges a premium
|
|
11
|
+
* for it (Anthropic: 1.25x base input for the 5-minute TTL). Optional —
|
|
12
|
+
* without it, cache writes are billed at the plain miss rate, which is what
|
|
13
|
+
* every provider modelled here did before Anthropic caching was enabled.
|
|
14
|
+
*/
|
|
15
|
+
inputCacheWritePerMillion?: number;
|
|
9
16
|
outputPerMillion: number;
|
|
10
17
|
effectiveUntil?: string;
|
|
11
18
|
original?: {
|
package/dist/model-pricing.js
CHANGED
|
@@ -1,12 +1,56 @@
|
|
|
1
1
|
export const MODEL_PRICING = [
|
|
2
|
+
// Anthropic cache multipliers off base input: read 0.1x, 5-minute write 1.25x.
|
|
2
3
|
{
|
|
3
4
|
providerId: "anthropic",
|
|
4
5
|
modelId: "claude-fable-5",
|
|
5
6
|
currency: "USD",
|
|
6
|
-
inputCacheHitPerMillion:
|
|
7
|
+
inputCacheHitPerMillion: 1,
|
|
7
8
|
inputCacheMissPerMillion: 10,
|
|
9
|
+
inputCacheWritePerMillion: 12.5,
|
|
8
10
|
outputPerMillion: 50,
|
|
9
11
|
},
|
|
12
|
+
{
|
|
13
|
+
providerId: "anthropic",
|
|
14
|
+
modelId: "claude-opus-4-8",
|
|
15
|
+
currency: "USD",
|
|
16
|
+
inputCacheHitPerMillion: 0.5,
|
|
17
|
+
inputCacheMissPerMillion: 5,
|
|
18
|
+
inputCacheWritePerMillion: 6.25,
|
|
19
|
+
outputPerMillion: 25,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
providerId: "anthropic",
|
|
23
|
+
modelId: "claude-sonnet-5",
|
|
24
|
+
currency: "USD",
|
|
25
|
+
inputCacheHitPerMillion: 0.2,
|
|
26
|
+
inputCacheMissPerMillion: 2,
|
|
27
|
+
inputCacheWritePerMillion: 2.5,
|
|
28
|
+
outputPerMillion: 10,
|
|
29
|
+
effectiveUntil: "2026-08-31T23:59:00Z",
|
|
30
|
+
original: {
|
|
31
|
+
inputCacheHitPerMillion: 0.3,
|
|
32
|
+
inputCacheMissPerMillion: 3,
|
|
33
|
+
outputPerMillion: 15,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
providerId: "anthropic",
|
|
38
|
+
modelId: "claude-sonnet-4-6",
|
|
39
|
+
currency: "USD",
|
|
40
|
+
inputCacheHitPerMillion: 0.3,
|
|
41
|
+
inputCacheMissPerMillion: 3,
|
|
42
|
+
inputCacheWritePerMillion: 3.75,
|
|
43
|
+
outputPerMillion: 15,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
providerId: "anthropic",
|
|
47
|
+
modelId: "claude-haiku-4-5",
|
|
48
|
+
currency: "USD",
|
|
49
|
+
inputCacheHitPerMillion: 0.1,
|
|
50
|
+
inputCacheMissPerMillion: 1,
|
|
51
|
+
inputCacheWritePerMillion: 1.25,
|
|
52
|
+
outputPerMillion: 5,
|
|
53
|
+
},
|
|
10
54
|
{
|
|
11
55
|
providerId: "deepseek",
|
|
12
56
|
modelId: "deepseek-v4-flash",
|
|
@@ -52,8 +96,16 @@ export function calculateUsageCost(providerId, modelId, usage) {
|
|
|
52
96
|
const miss = hasCacheBreakdown
|
|
53
97
|
? usage.promptCacheMissTokens ?? Math.max(0, usage.promptTokens - hit)
|
|
54
98
|
: usage.promptTokens;
|
|
99
|
+
// `miss` already contains the cache-write tokens (see mergeAnthropicUsage),
|
|
100
|
+
// so writes must be netted out before being re-priced — adding a write term
|
|
101
|
+
// on top of the untouched miss term would bill them at miss + write.
|
|
102
|
+
const write = pricing.inputCacheWritePerMillion !== undefined
|
|
103
|
+
? Math.min(usage.cacheCreationTokens ?? 0, miss)
|
|
104
|
+
: 0;
|
|
105
|
+
const missOnly = miss - write;
|
|
55
106
|
const cost = (hit / 1_000_000) * pricing.inputCacheHitPerMillion
|
|
56
|
-
+ (
|
|
107
|
+
+ (write / 1_000_000) * (pricing.inputCacheWritePerMillion ?? pricing.inputCacheMissPerMillion)
|
|
108
|
+
+ (missOnly / 1_000_000) * pricing.inputCacheMissPerMillion
|
|
57
109
|
+ (usage.completionTokens / 1_000_000) * pricing.outputPerMillion;
|
|
58
110
|
return {
|
|
59
111
|
currency: pricing.currency,
|
|
@@ -6,6 +6,7 @@ import { DiscoveryBarrier } from "../agent/discovery-barrier.js";
|
|
|
6
6
|
import { arbitrateToolCall } from "../agent/tool-arbiter.js";
|
|
7
7
|
import { buildCompletionSelfCheckReminder, buildEditRetryEscalationReminder, buildSmallTaskHint, buildWorkflowPhaseReminder, } from "../prompt/reminders.js";
|
|
8
8
|
import { largeImplementationTaskReminder, orchestrationRequestReminder, reminderForTaskType, userNamedModelReminder } from "../prompt/task-reminders.js";
|
|
9
|
+
import { captureGitBaseline, detectRunChanges } from "../agent/change-tracker.js";
|
|
9
10
|
import { formatCoverageSummary, resolveWorkflowPhase } from "./workflow.js";
|
|
10
11
|
import { resolve as resolvePath } from "node:path";
|
|
11
12
|
import { traceEvent } from "../debug-trace.js";
|
|
@@ -14,7 +15,13 @@ import { buildSubagentLifecycleReminder } from "../agent/subagent-lifecycle-remi
|
|
|
14
15
|
export function createDefaultHooks() {
|
|
15
16
|
return [
|
|
16
17
|
{
|
|
17
|
-
beforeTurn(ctx) {
|
|
18
|
+
async beforeTurn(ctx) {
|
|
19
|
+
// Git ground truth for the completion gate: tool-metadata bookkeeping
|
|
20
|
+
// misses bash-written files and subagent worktree merges; a dirty-state
|
|
21
|
+
// baseline at run start makes the run's real footprint observable.
|
|
22
|
+
if (ctx.state.gitBaseline === undefined) {
|
|
23
|
+
ctx.state.gitBaseline = await captureGitBaseline(ctx.cwd);
|
|
24
|
+
}
|
|
18
25
|
const taskType = classifyTask(ctx.input);
|
|
19
26
|
ctx.state.taskType = taskType;
|
|
20
27
|
ctx.state.governor = new ExecutionGovernor(taskType);
|
|
@@ -99,6 +106,7 @@ export function createDefaultHooks() {
|
|
|
99
106
|
},
|
|
100
107
|
{
|
|
101
108
|
beforeToolCall(ctx) {
|
|
109
|
+
ctx.state.toolUsed = true;
|
|
102
110
|
const arbitration = arbitrateToolCall(ctx.toolCall);
|
|
103
111
|
const toolCall = { ...arbitration.toolCall, ...(arbitration.note ? { arbiterNote: arbitration.note } : {}) };
|
|
104
112
|
ctx.replaceToolCall(toolCall);
|
|
@@ -220,7 +228,7 @@ export function createDefaultHooks() {
|
|
|
220
228
|
}
|
|
221
229
|
// Verification reminders intentionally removed. See afterToolCall.
|
|
222
230
|
},
|
|
223
|
-
afterTurn(ctx) {
|
|
231
|
+
async afterTurn(ctx) {
|
|
224
232
|
// Verification force-continuation removed. The model decides whether
|
|
225
233
|
// verification is meaningful for the task, per the system prompt.
|
|
226
234
|
//
|
|
@@ -232,21 +240,37 @@ export function createDefaultHooks() {
|
|
|
232
240
|
// "prove it" spiral that killed the old design cannot start.
|
|
233
241
|
if (ctx.state.completionGateFired)
|
|
234
242
|
return;
|
|
235
|
-
if (!ctx.state.codeChanged)
|
|
236
|
-
return;
|
|
237
243
|
// Tools are frozen: the model could not act on anything the check
|
|
238
244
|
// surfaces, so another turn is pure token burn.
|
|
239
245
|
if (ctx.state.forceTextOnlyReason)
|
|
240
246
|
return;
|
|
241
247
|
// Subagents hand off to a parent that does its own closing pass, and
|
|
242
248
|
// worktree profiles explicitly forbid the very actions a completion
|
|
243
|
-
// check would suggest.
|
|
249
|
+
// check would suggest. The parent's gate still covers their merged
|
|
250
|
+
// changes via the git baseline below.
|
|
244
251
|
if (ctx.agent.role === "subagent")
|
|
245
252
|
return;
|
|
253
|
+
// Change detection: tool metadata first (cheap), then git ground
|
|
254
|
+
// truth — which also catches bash-written files and subagent
|
|
255
|
+
// worktree merges. Gate only runs git when the run actually used
|
|
256
|
+
// tools, so external repo churn cannot hijack a pure Q&A turn.
|
|
257
|
+
let changes = null;
|
|
258
|
+
if (ctx.state.toolUsed && ctx.state.gitBaseline) {
|
|
259
|
+
changes = await detectRunChanges(ctx.cwd, ctx.state.gitBaseline);
|
|
260
|
+
}
|
|
261
|
+
const changedSomething = !!ctx.state.codeChanged || (changes !== null && changes.changedFiles.length > 0);
|
|
262
|
+
if (!changedSomething)
|
|
263
|
+
return;
|
|
246
264
|
ctx.state.completionGateFired = true;
|
|
247
265
|
ctx.state.forceContinuationReason = "completion_self_check";
|
|
248
|
-
ctx.queueReminder(buildCompletionSelfCheckReminder(
|
|
249
|
-
|
|
266
|
+
ctx.queueReminder(buildCompletionSelfCheckReminder({
|
|
267
|
+
modifiedExistingTests: changes?.modifiedExistingTests,
|
|
268
|
+
}));
|
|
269
|
+
traceEvent("completion_gate_fired", {
|
|
270
|
+
turnCount: ctx.state.turnCount,
|
|
271
|
+
changedFiles: changes?.changedFiles.length,
|
|
272
|
+
modifiedExistingTests: changes?.modifiedExistingTests.length,
|
|
273
|
+
});
|
|
250
274
|
},
|
|
251
275
|
},
|
|
252
276
|
];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Agent } from "../agent.js";
|
|
2
2
|
import type { ContentPart, ParsedToolCall, ToolRegistryEntry, ToolResult } from "../types.js";
|
|
3
|
+
import type { GitChangeBaseline } from "../agent/change-tracker.js";
|
|
3
4
|
import type { TaskType } from "../agent/task-classifier.js";
|
|
4
5
|
import type { ExecutionGovernor } from "../agent/execution-governor.js";
|
|
5
6
|
import type { EvidenceTracker } from "../agent/evidence-tracker.js";
|
|
@@ -18,6 +19,10 @@ export interface TurnHookState {
|
|
|
18
19
|
codeChanged?: boolean;
|
|
19
20
|
/** Completion self-check gate already fired this run (fires at most once). */
|
|
20
21
|
completionGateFired?: boolean;
|
|
22
|
+
/** Git dirty-state snapshot at run start (null = not a git repo / git failed). */
|
|
23
|
+
gitBaseline?: GitChangeBaseline | null;
|
|
24
|
+
/** Any tool executed this run — guards git-change attribution for pure Q&A turns. */
|
|
25
|
+
toolUsed?: boolean;
|
|
21
26
|
smallTaskHintSent?: boolean;
|
|
22
27
|
recentEditFailures?: string[];
|
|
23
28
|
editNoMatchReminderPaths?: string[];
|
package/dist/print-output.d.ts
CHANGED
|
@@ -10,9 +10,23 @@
|
|
|
10
10
|
import type { AgentEvent } from "./types.js";
|
|
11
11
|
export type PrintOutputFormat = "plain" | "json";
|
|
12
12
|
export declare function parseOutputFormat(raw: string | undefined): PrintOutputFormat | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Anthropic's usage decomposition, which the field names already claimed to
|
|
15
|
+
* follow: the four token buckets are DISJOINT and sum to `total_tokens`.
|
|
16
|
+
*
|
|
17
|
+
* input_tokens + cache_read_input_tokens + cache_creation_input_tokens
|
|
18
|
+
* + output_tokens === total_tokens
|
|
19
|
+
*
|
|
20
|
+
* `input_tokens` is therefore the uncached remainder only — it does NOT include
|
|
21
|
+
* cache reads. It used to carry the whole prompt total, which was harmless while
|
|
22
|
+
* cache reads were a flat few thousand tokens per turn, but message-level prompt
|
|
23
|
+
* caching makes reads 70-90% of the prompt, and a harness that priced
|
|
24
|
+
* `input_tokens` at the full input rate would then overstate cost ~2x.
|
|
25
|
+
*/
|
|
13
26
|
export interface PrintUsageSummary {
|
|
14
27
|
input_tokens: number;
|
|
15
28
|
cache_read_input_tokens: number;
|
|
29
|
+
cache_creation_input_tokens: number;
|
|
16
30
|
output_tokens: number;
|
|
17
31
|
reasoning_tokens: number;
|
|
18
32
|
total_tokens: number;
|
|
@@ -42,6 +56,18 @@ export declare class PrintRunCollector {
|
|
|
42
56
|
private addUsage;
|
|
43
57
|
summary(): PrintRunSummary;
|
|
44
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Harness-observed change footprint of the run (git ground truth). Printed
|
|
61
|
+
* by the harness, not the model — a run cannot omit or misstate what it
|
|
62
|
+
* touched ("no breaking changes" next to deleted test assertions).
|
|
63
|
+
*/
|
|
64
|
+
export interface PrintChangeSummary {
|
|
65
|
+
changed_files: number;
|
|
66
|
+
modified_existing_tests: Array<{
|
|
67
|
+
path: string;
|
|
68
|
+
deleted_lines: number;
|
|
69
|
+
}>;
|
|
70
|
+
}
|
|
45
71
|
/**
|
|
46
72
|
* Per-path counts of context compactions during the run. Lets benchmark
|
|
47
73
|
* harnesses correlate outcomes with "did the model ever lose sight of the
|
|
@@ -61,10 +87,12 @@ export declare function formatPrintJson(input: {
|
|
|
61
87
|
sessionId?: string;
|
|
62
88
|
stopReason?: "end_turn" | "error" | "cancelled";
|
|
63
89
|
compaction?: PrintCompactionStats;
|
|
90
|
+
changes?: PrintChangeSummary;
|
|
64
91
|
}): string;
|
|
65
92
|
export declare function formatPrintJsonError(input: {
|
|
66
93
|
message: string;
|
|
67
94
|
summary?: PrintRunSummary;
|
|
68
95
|
sessionId?: string;
|
|
69
96
|
compaction?: PrintCompactionStats;
|
|
97
|
+
changes?: PrintChangeSummary;
|
|
70
98
|
}): string;
|
package/dist/print-output.js
CHANGED
|
@@ -23,6 +23,7 @@ export class PrintRunCollector {
|
|
|
23
23
|
usage = {
|
|
24
24
|
input_tokens: 0,
|
|
25
25
|
cache_read_input_tokens: 0,
|
|
26
|
+
cache_creation_input_tokens: 0,
|
|
26
27
|
output_tokens: 0,
|
|
27
28
|
reasoning_tokens: 0,
|
|
28
29
|
total_tokens: 0,
|
|
@@ -57,8 +58,17 @@ export class PrintRunCollector {
|
|
|
57
58
|
}
|
|
58
59
|
addUsage(usage) {
|
|
59
60
|
this.usageReported = true;
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
const prompt = usage.promptTokens ?? 0;
|
|
62
|
+
const cacheRead = usage.promptCacheHitTokens ?? 0;
|
|
63
|
+
// promptCacheMissTokens already includes creation, so creation has to be
|
|
64
|
+
// netted out of the uncached remainder rather than added alongside it.
|
|
65
|
+
const cacheCreation = usage.cacheCreationTokens ?? 0;
|
|
66
|
+
const uncached = usage.promptCacheMissTokens !== undefined
|
|
67
|
+
? Math.max(0, usage.promptCacheMissTokens - cacheCreation)
|
|
68
|
+
: Math.max(0, prompt - cacheRead - cacheCreation);
|
|
69
|
+
this.usage.input_tokens += uncached;
|
|
70
|
+
this.usage.cache_read_input_tokens += cacheRead;
|
|
71
|
+
this.usage.cache_creation_input_tokens += cacheCreation;
|
|
62
72
|
this.usage.output_tokens += usage.completionTokens ?? 0;
|
|
63
73
|
this.usage.reasoning_tokens += usage.reasoningTokens ?? 0;
|
|
64
74
|
this.usage.total_tokens += usage.totalTokens
|
|
@@ -84,6 +94,7 @@ export function formatPrintJson(input) {
|
|
|
84
94
|
usage: input.summary.usage,
|
|
85
95
|
usage_reported: input.summary.usage_reported,
|
|
86
96
|
...(input.compaction ? { compaction: input.compaction } : {}),
|
|
97
|
+
...(input.changes ? { changes: input.changes } : {}),
|
|
87
98
|
});
|
|
88
99
|
}
|
|
89
100
|
export function formatPrintJsonError(input) {
|
|
@@ -101,5 +112,6 @@ export function formatPrintJsonError(input) {
|
|
|
101
112
|
}
|
|
102
113
|
: {}),
|
|
103
114
|
...(input.compaction ? { compaction: input.compaction } : {}),
|
|
115
|
+
...(input.changes ? { changes: input.changes } : {}),
|
|
104
116
|
});
|
|
105
117
|
}
|
|
@@ -23,7 +23,14 @@ export declare function buildInvestigationReminder(): string;
|
|
|
23
23
|
export declare function buildLoopWarningReminder(reason: string): string;
|
|
24
24
|
export declare function buildSearchFreezeReminder(reason: string): string;
|
|
25
25
|
export declare function buildExplorationFreezeReminder(reason: string): string;
|
|
26
|
-
export
|
|
26
|
+
export interface CompletionSelfCheckOptions {
|
|
27
|
+
/** Pre-existing test files this run modified, with deleted-line counts. */
|
|
28
|
+
modifiedExistingTests?: Array<{
|
|
29
|
+
path: string;
|
|
30
|
+
deletedLines: number;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
export declare function buildCompletionSelfCheckReminder(options?: CompletionSelfCheckOptions): string;
|
|
27
34
|
export declare function buildToolFreezeReminder(reason: string): string;
|
|
28
35
|
export declare function buildWorkflowPhaseReminder(input: {
|
|
29
36
|
phase: "investigate" | "correlate" | "conclude";
|
package/dist/prompt/reminders.js
CHANGED
|
@@ -116,15 +116,30 @@ You have enough context to act. Do not continue reading, searching, or delegatin
|
|
|
116
116
|
Choose one of:
|
|
117
117
|
1. Use edit/write to make the requested change.
|
|
118
118
|
2. If no safe change can be made from the gathered context, explain the concrete blocker.
|
|
119
|
-
3. If files were already changed, run the
|
|
119
|
+
3. If files were already changed, verify the change and run the affected module's existing tests, or finish with the result.
|
|
120
120
|
`);
|
|
121
121
|
}
|
|
122
|
-
export function buildCompletionSelfCheckReminder() {
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
export function buildCompletionSelfCheckReminder(options = {}) {
|
|
123
|
+
const sections = [];
|
|
124
|
+
const modifiedTests = options.modifiedExistingTests ?? [];
|
|
125
|
+
if (modifiedTests.length > 0) {
|
|
126
|
+
// Disclosure, not accusation: legitimate test updates (requirement
|
|
127
|
+
// changes, fixing a broken test, refactors) are normal engineering — the
|
|
128
|
+
// rule is that they must be declared, never silent. Confrontational
|
|
129
|
+
// wording risks a model rolling back a user-requested test update.
|
|
130
|
+
const listed = modifiedTests
|
|
131
|
+
.slice(0, 8)
|
|
132
|
+
.map((t) => `- ${t.path}${t.deletedLines > 0 ? ` (${t.deletedLines} line${t.deletedLines === 1 ? "" : "s"} removed)` : ""}`)
|
|
133
|
+
.join("\n");
|
|
134
|
+
sections.push(`This run modified pre-existing test files:
|
|
135
|
+
${listed}${modifiedTests.length > 8 ? `\n- …and ${modifiedTests.length - 8} more` : ""}
|
|
136
|
+
|
|
137
|
+
If a change you made caused an existing test to fail and you edited the test so the suite passes, restore the original behavior instead — weakening a test to make it pass is never acceptable. If the test changes are intentional (the request requires new expected behavior, or the test itself was wrong), keep them and state each modified test and the reason in your final summary.`);
|
|
138
|
+
}
|
|
139
|
+
sections.push(`You appear to be finishing. Before giving your final answer, re-read the user's original request in this conversation and check whether any explicit requirement in it remains unfulfilled — including requirements about how the work should be delivered or reported, not only whether the implementation works.
|
|
125
140
|
|
|
126
|
-
If something is missing, complete it now. If everything is done, give your final answer directly — do not repeat this check or re-verify work you have already verified
|
|
127
|
-
`);
|
|
141
|
+
If something is missing, complete it now. If everything is done, give your final answer directly — do not repeat this check or re-verify work you have already verified.`);
|
|
142
|
+
return wrapInSystemReminder(`\n${sections.join("\n\n")}\n`);
|
|
128
143
|
}
|
|
129
144
|
export function buildToolFreezeReminder(reason) {
|
|
130
145
|
return wrapInSystemReminder(`
|
|
@@ -24,7 +24,7 @@ Implementation workflow:
|
|
|
24
24
|
- Do not stop at a proposal when the user asked for a change.
|
|
25
25
|
- Inspect the relevant files first, then make the smallest coherent edit.
|
|
26
26
|
- Keep unrelated files and behavior out of scope.
|
|
27
|
-
-
|
|
27
|
+
- Verify the change, then run the existing tests of the affected module — not only the cases you added — or explain why they cannot be run.
|
|
28
28
|
`);
|
|
29
29
|
case "code_review":
|
|
30
30
|
return wrapInSystemReminder(`
|
|
@@ -36,6 +36,7 @@ interface AnthropicSystemBlock {
|
|
|
36
36
|
type AnthropicContentBlock = {
|
|
37
37
|
type: "text";
|
|
38
38
|
text: string;
|
|
39
|
+
cache_control?: AnthropicCacheControl;
|
|
39
40
|
} | {
|
|
40
41
|
type: "image";
|
|
41
42
|
source: {
|
|
@@ -46,6 +47,7 @@ type AnthropicContentBlock = {
|
|
|
46
47
|
media_type: string;
|
|
47
48
|
data: string;
|
|
48
49
|
};
|
|
50
|
+
cache_control?: AnthropicCacheControl;
|
|
49
51
|
} | {
|
|
50
52
|
type: "thinking";
|
|
51
53
|
thinking: string;
|
|
@@ -58,11 +60,13 @@ type AnthropicContentBlock = {
|
|
|
58
60
|
id: string;
|
|
59
61
|
name: string;
|
|
60
62
|
input: Record<string, unknown>;
|
|
63
|
+
cache_control?: AnthropicCacheControl;
|
|
61
64
|
} | {
|
|
62
65
|
type: "tool_result";
|
|
63
66
|
tool_use_id: string;
|
|
64
67
|
content: string;
|
|
65
68
|
is_error?: boolean;
|
|
69
|
+
cache_control?: AnthropicCacheControl;
|
|
66
70
|
};
|
|
67
71
|
interface AnthropicMessage {
|
|
68
72
|
role: "user" | "assistant";
|
|
@@ -83,6 +87,28 @@ export declare function buildAnthropicRequest(options: AnthropicProviderOptions,
|
|
|
83
87
|
thinkingLevel?: ThinkingLevel;
|
|
84
88
|
stream?: boolean;
|
|
85
89
|
}): AnthropicRequest;
|
|
90
|
+
/**
|
|
91
|
+
* Roll prompt-cache breakpoints along the conversation so each turn's context is
|
|
92
|
+
* read back on the next turn instead of being re-billed as fresh input.
|
|
93
|
+
*
|
|
94
|
+
* Two breakpoints, and both are load-bearing:
|
|
95
|
+
*
|
|
96
|
+
* - the **tail** (last block of the last message) writes the entry that the next
|
|
97
|
+
* request reads;
|
|
98
|
+
* - the **anchor** reads it. It goes on the message just before the final
|
|
99
|
+
* assistant message — which is exactly where the previous request ended, since
|
|
100
|
+
* consecutive agent-loop requests differ by one `[assistant, tool results]`
|
|
101
|
+
* step. Landing on the written position directly is what makes this correct
|
|
102
|
+
* for any turn size: Anthropic only looks back 20 content blocks from a
|
|
103
|
+
* breakpoint to find a prior write, and a turn with many parallel tool calls
|
|
104
|
+
* easily exceeds that (1 + 2N blocks for N calls). Measured on Opus 4.8 with
|
|
105
|
+
* 30 parallel calls: tail-only and a fixed-distance anchor both collapse to an
|
|
106
|
+
* 8.6% hit rate, while this anchor holds 77%.
|
|
107
|
+
*
|
|
108
|
+
* Mutates `messages` (built fresh per request by toAnthropicMessages) and
|
|
109
|
+
* returns how many breakpoints it used, never more than `budget`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function placeMessageCacheBreakpoints(messages: AnthropicMessage[], budget: number): number;
|
|
86
112
|
export declare function resolveAnthropicMaxTokens(options: AnthropicProviderOptions, model: string): number;
|
|
87
113
|
export declare function supportsAnthropicPromptCache(options: AnthropicProviderOptions, model: string): boolean;
|
|
88
114
|
export declare function toAnthropicMessages(messages: ProviderMessage[], echoThinking?: boolean): {
|
|
@@ -12,6 +12,8 @@ const ANTHROPIC_LONG_OUTPUT_MAX_TOKENS = 64000;
|
|
|
12
12
|
const MINIMAX_M3_MAX_TOKENS = 128000;
|
|
13
13
|
const MINIMAX_M2_MAX_TOKENS = 64000;
|
|
14
14
|
const ANTHROPIC_PROMPT_CACHE_CONTROL = { type: "ephemeral" };
|
|
15
|
+
/** Hard API limit: a 5th cache_control breakpoint is a 400, not a warning. */
|
|
16
|
+
const ANTHROPIC_MAX_CACHE_BREAKPOINTS = 4;
|
|
15
17
|
const MINIMAX_PROMPT_CACHE_MODELS = new Set([
|
|
16
18
|
"minimax-m2.7",
|
|
17
19
|
"minimax-m2.7-highspeed",
|
|
@@ -89,11 +91,30 @@ export function buildAnthropicRequest(options, messages, chatOptions) {
|
|
|
89
91
|
description: tool.description,
|
|
90
92
|
input_schema: tool.parameters,
|
|
91
93
|
}));
|
|
94
|
+
// Anthropic caps a request at 4 cache_control breakpoints and rejects the
|
|
95
|
+
// fifth outright, so the static ones are counted and the message region only
|
|
96
|
+
// ever gets what is left.
|
|
97
|
+
let staticBreakpoints = 0;
|
|
92
98
|
if (enablePromptCache && tools && tools.length > 0) {
|
|
93
99
|
tools[tools.length - 1] = {
|
|
94
100
|
...tools[tools.length - 1],
|
|
95
101
|
cache_control: ANTHROPIC_PROMPT_CACHE_CONTROL,
|
|
96
102
|
};
|
|
103
|
+
staticBreakpoints += 1;
|
|
104
|
+
}
|
|
105
|
+
if (enablePromptCache && system) {
|
|
106
|
+
staticBreakpoints += 1;
|
|
107
|
+
}
|
|
108
|
+
// Message-region breakpoints go to the official endpoint only. `baseURL` is
|
|
109
|
+
// user-editable persisted config, so a corporate gateway can keep
|
|
110
|
+
// providerId "anthropic" while pointing elsewhere — the URL check is the only
|
|
111
|
+
// trustworthy signal. Streaming only: complete() has no next turn to read the
|
|
112
|
+
// entry back.
|
|
113
|
+
if (enablePromptCache
|
|
114
|
+
&& chatOptions.stream === true
|
|
115
|
+
&& isOfficialAnthropicBaseUrl(options.baseURL)
|
|
116
|
+
&& !messageCacheKillSwitchEnabled()) {
|
|
117
|
+
placeMessageCacheBreakpoints(anthropicMessages, ANTHROPIC_MAX_CACHE_BREAKPOINTS - staticBreakpoints);
|
|
97
118
|
}
|
|
98
119
|
const effectiveThinkingLevel = normalizeThinkingLevel(chatOptions.thinkingLevel ?? options.thinkingLevel ?? "off", getAvailableThinkingLevels(options.providerId || "", chatOptions.model));
|
|
99
120
|
const body = {
|
|
@@ -126,6 +147,95 @@ export function buildAnthropicRequest(options, messages, chatOptions) {
|
|
|
126
147
|
}
|
|
127
148
|
return body;
|
|
128
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Attach `cache_control` to the last cacheable block of `message`.
|
|
152
|
+
*
|
|
153
|
+
* Returns false when the message has nothing markable (empty block array, or a
|
|
154
|
+
* body made only of thinking blocks, where `cache_control` is not permitted).
|
|
155
|
+
*/
|
|
156
|
+
function markLastCacheableBlock(message) {
|
|
157
|
+
if (typeof message.content === "string") {
|
|
158
|
+
if (!message.content)
|
|
159
|
+
return false;
|
|
160
|
+
// A breakpoint needs a block to sit on. Wrapping is safe: the API keys the
|
|
161
|
+
// cache on canonicalised content, so the same message reverting to a bare
|
|
162
|
+
// string on a later turn still matches this entry.
|
|
163
|
+
message.content = [{
|
|
164
|
+
type: "text",
|
|
165
|
+
text: message.content,
|
|
166
|
+
cache_control: ANTHROPIC_PROMPT_CACHE_CONTROL,
|
|
167
|
+
}];
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
const blocks = message.content;
|
|
171
|
+
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
172
|
+
const block = blocks[index];
|
|
173
|
+
if (block.type === "thinking" || block.type === "redacted_thinking")
|
|
174
|
+
continue;
|
|
175
|
+
blocks[index] = { ...block, cache_control: ANTHROPIC_PROMPT_CACHE_CONTROL };
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Roll prompt-cache breakpoints along the conversation so each turn's context is
|
|
182
|
+
* read back on the next turn instead of being re-billed as fresh input.
|
|
183
|
+
*
|
|
184
|
+
* Two breakpoints, and both are load-bearing:
|
|
185
|
+
*
|
|
186
|
+
* - the **tail** (last block of the last message) writes the entry that the next
|
|
187
|
+
* request reads;
|
|
188
|
+
* - the **anchor** reads it. It goes on the message just before the final
|
|
189
|
+
* assistant message — which is exactly where the previous request ended, since
|
|
190
|
+
* consecutive agent-loop requests differ by one `[assistant, tool results]`
|
|
191
|
+
* step. Landing on the written position directly is what makes this correct
|
|
192
|
+
* for any turn size: Anthropic only looks back 20 content blocks from a
|
|
193
|
+
* breakpoint to find a prior write, and a turn with many parallel tool calls
|
|
194
|
+
* easily exceeds that (1 + 2N blocks for N calls). Measured on Opus 4.8 with
|
|
195
|
+
* 30 parallel calls: tail-only and a fixed-distance anchor both collapse to an
|
|
196
|
+
* 8.6% hit rate, while this anchor holds 77%.
|
|
197
|
+
*
|
|
198
|
+
* Mutates `messages` (built fresh per request by toAnthropicMessages) and
|
|
199
|
+
* returns how many breakpoints it used, never more than `budget`.
|
|
200
|
+
*/
|
|
201
|
+
export function placeMessageCacheBreakpoints(messages, budget) {
|
|
202
|
+
// One-shot prompts (complete(): compaction, titles, memory) are excluded by
|
|
203
|
+
// the caller's streaming gate, not here — a single-message first turn is a
|
|
204
|
+
// real agent turn and marking its tail costs nothing, since writes are billed
|
|
205
|
+
// from the highest hit to the last breakpoint regardless of how many sit in
|
|
206
|
+
// between.
|
|
207
|
+
if (budget <= 0 || messages.length === 0)
|
|
208
|
+
return 0;
|
|
209
|
+
let used = 0;
|
|
210
|
+
const tailIndex = messages.length - 1;
|
|
211
|
+
if (budget - used >= 2) {
|
|
212
|
+
let lastAssistantIndex = -1;
|
|
213
|
+
for (let index = tailIndex; index >= 0; index--) {
|
|
214
|
+
if (messages[index].role === "assistant") {
|
|
215
|
+
lastAssistantIndex = index;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const anchorIndex = lastAssistantIndex - 1;
|
|
220
|
+
// An assistant at the anchor position means consecutive assistant messages
|
|
221
|
+
// merged, so this is not the previous request's boundary and the distance
|
|
222
|
+
// back is unbounded — skip rather than guess.
|
|
223
|
+
if (anchorIndex >= 0
|
|
224
|
+
&& anchorIndex < tailIndex
|
|
225
|
+
&& messages[anchorIndex].role !== "assistant"
|
|
226
|
+
&& markLastCacheableBlock(messages[anchorIndex])) {
|
|
227
|
+
used += 1;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (budget - used >= 1 && markLastCacheableBlock(messages[tailIndex])) {
|
|
231
|
+
used += 1;
|
|
232
|
+
}
|
|
233
|
+
return used;
|
|
234
|
+
}
|
|
235
|
+
function messageCacheKillSwitchEnabled() {
|
|
236
|
+
const raw = process.env.BUBBLE_ANTHROPIC_MESSAGE_CACHE;
|
|
237
|
+
return raw === "0" || raw === "false";
|
|
238
|
+
}
|
|
129
239
|
export function resolveAnthropicMaxTokens(options, model) {
|
|
130
240
|
// MiniMax needs a large output budget so thinking doesn't starve the answer.
|
|
131
241
|
if (isMiniMaxProvider(options)) {
|
|
@@ -650,9 +760,18 @@ function mergeAnthropicUsage(current, raw) {
|
|
|
650
760
|
let promptCacheMissTokens = current?.promptCacheMissTokens;
|
|
651
761
|
let cacheCreationTokens = current?.cacheCreationTokens;
|
|
652
762
|
if (hasPromptUsage) {
|
|
653
|
-
|
|
763
|
+
// Every field carries forward: a message_delta may report one of the three
|
|
764
|
+
// without the others, and defaulting a missing one to 0 would deflate
|
|
765
|
+
// promptTokens — which is the compaction anchor (agent lastInputTokens),
|
|
766
|
+
// so an undercount makes the agent compact later than it should.
|
|
767
|
+
// promptCacheMissTokens already includes cache creation, so the prior
|
|
768
|
+
// uncached remainder has to net it back out before being reused.
|
|
769
|
+
const priorUncachedInput = promptCacheMissTokens !== undefined
|
|
770
|
+
? Math.max(0, promptCacheMissTokens - (cacheCreationTokens ?? 0))
|
|
771
|
+
: promptTokens;
|
|
772
|
+
const inputTokens = rawInput ?? priorUncachedInput;
|
|
654
773
|
const cacheRead = rawCacheRead ?? promptCacheHitTokens ?? 0;
|
|
655
|
-
const cacheCreation = rawCacheCreation ?? 0;
|
|
774
|
+
const cacheCreation = rawCacheCreation ?? cacheCreationTokens ?? 0;
|
|
656
775
|
promptTokens = inputTokens + cacheRead + cacheCreation;
|
|
657
776
|
promptCacheHitTokens = cacheRead;
|
|
658
777
|
promptCacheMissTokens = inputTokens + cacheCreation;
|