llm_meta_widget 0.12.0 → 0.12.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +1 -1
- data/app/assets/javascripts/llm_meta_widget/element.js +84 -24
- data/app/assets/javascripts/llm_meta_widget/llm-meta-widget.js +99 -30
- data/app/assets/javascripts/llm_meta_widget/orchestrator.js +109 -11
- data/lib/llm_meta_widget/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d3172a4471dd7326d3455cbb4d39ed5f19092df52e81828b98d71a0c541da57
|
|
4
|
+
data.tar.gz: 1ec235057d668445bcb40cdb6b584cc73c1c579ee54c2cf36b7bcef699efb5b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1dcda012841f3a4c1f253c837d1149292f0c19cdc6a5396cb7ab7064bfb0ac0dbae5414bc0e33529fb925a4d5b15de28a78cda13bba26bf4856fca84f13a94e2
|
|
7
|
+
data.tar.gz: 338955619e9f6712e6889bbe62a295de75b7cf9cb6571d7cd9aab0ac227b52caf5d6c891b14ef136bcc115e6da0f721f441b4a9a7bd3be96248d55cde441870e
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,88 @@
|
|
|
3
3
|
This file starts at 0.8.0, the first release with a breaking change worth
|
|
4
4
|
calling out. Earlier releases are in the git history (`git log --oneline v0.7.6`).
|
|
5
5
|
|
|
6
|
+
## 0.12.2
|
|
7
|
+
|
|
8
|
+
### Changed: one oversized tool result is shortened, not paid for by the others
|
|
9
|
+
|
|
10
|
+
A single tool result may now use at most 40% of the model's context window. A
|
|
11
|
+
result above that is cut to the cap, with a note in the result itself telling
|
|
12
|
+
the model how much was shown and to call the tool again with a narrower query.
|
|
13
|
+
|
|
14
|
+
This replaces giving up older results as the first response to a turn that will
|
|
15
|
+
not fit. Dropping the oldest results is a sound rule when results are of
|
|
16
|
+
similar size, and a poor one otherwise: on a real turn (2026-10-17) it gave up
|
|
17
|
+
two results of 80 tokens each — 160 tokens against a shortfall of about 2,600 —
|
|
18
|
+
and left untouched the one result that was causing the problem, at 120,667
|
|
19
|
+
tokens, or 94% of the window. The two results it discarded were ones the model
|
|
20
|
+
had asked for.
|
|
21
|
+
|
|
22
|
+
Measured over 179 real tool results: the median is 2.1% of the window, so the
|
|
23
|
+
cap touches 4.5% of results. All six over-budget requests in that sample were
|
|
24
|
+
caused by one oversized result, and the cap clears every one of them. On the
|
|
25
|
+
turn above it frees about 69,000 tokens and nothing has to be given up at all.
|
|
26
|
+
|
|
27
|
+
Giving up older results remains, for a conversation that is too long in
|
|
28
|
+
aggregate rather than because of one result.
|
|
29
|
+
|
|
30
|
+
The panel reports both, with the numbers: what was shortened or given up, how
|
|
31
|
+
many tokens the turn needed, and how many the model holds.
|
|
32
|
+
|
|
33
|
+
### Fixed: scrolling the panel after a turn with thinking blocks (Chrome)
|
|
34
|
+
|
|
35
|
+
After a turn that showed thinking blocks, the panel could not be scrolled. The
|
|
36
|
+
scrollbar moved a few tens of pixels and stopped, by wheel, trackpad and
|
|
37
|
+
keyboard alike, while `scrollTop` set from script still worked and held. This
|
|
38
|
+
was reported from two different deployments and took several wrong diagnoses
|
|
39
|
+
from us before the mechanism was found.
|
|
40
|
+
|
|
41
|
+
A thinking block is a `<details>` holding its own scroller. Closing one with
|
|
42
|
+
`open = false`, at a moment when that block is the only reason the message list
|
|
43
|
+
overflows, leaves Chrome with a stale wheel range — the list's overflow from
|
|
44
|
+
that instant, often only tens of pixels. `scrollTop` and `scrollHeight` remain
|
|
45
|
+
correct, which is why nothing in the page looked wrong. Chrome 154 reproduces
|
|
46
|
+
it with no widget code involved at all; Firefox 158 does not, and it has been
|
|
47
|
+
reported upstream.
|
|
48
|
+
|
|
49
|
+
The widget now takes the scroller off the inner box before closing it, and
|
|
50
|
+
closes on the second animation frame, because one frame is not enough for
|
|
51
|
+
Chrome to pick up the new overflow. Measured on a real deployment: 6 of 6 turns
|
|
52
|
+
healthy after the change, against 3 of 4 faulting before, with the triggering
|
|
53
|
+
condition confirmed present 2 to 4 times in every passing run.
|
|
54
|
+
|
|
55
|
+
One consequence: a finished thinking block that a reader reopens is shown at
|
|
56
|
+
full height rather than as a 10em scrolling box. Putting the scroller back when
|
|
57
|
+
it reopens would let a manual close trigger the same Chrome state again.
|
|
58
|
+
|
|
59
|
+
## 0.12.1
|
|
60
|
+
|
|
61
|
+
### Fixed: a tool result is counted as JSON, not as prose
|
|
62
|
+
|
|
63
|
+
The turn was sized at roughly four characters per token, which is right for
|
|
64
|
+
prose and badly wrong for a tool result. A request of 289,101 characters that
|
|
65
|
+
the provider counted as 135,291 tokens was estimated at 72,275 — 53% of its
|
|
66
|
+
real size — so nothing was given up and the provider refused the turn with its
|
|
67
|
+
own error text. That request was 33% letters, 16% digits and 45% punctuation:
|
|
68
|
+
JSON, which tokenizes about twice as densely as prose.
|
|
69
|
+
|
|
70
|
+
Characters are now counted in three classes — letters and spaces, CJK, and
|
|
71
|
+
everything else — which puts that request at 106% of its real size. Erring
|
|
72
|
+
high is deliberate here: underestimating ends the turn, while overestimating
|
|
73
|
+
gives up an old tool result slightly early. Tool schemas are counted the same
|
|
74
|
+
way, since a schema is JSON too.
|
|
75
|
+
|
|
76
|
+
### Fixed: following the output no longer fights a reader who scrolls
|
|
77
|
+
|
|
78
|
+
0.12.0 decided whether to follow the output from the `scroll` event. A browser
|
|
79
|
+
dispatches that event a frame after the position changes, while deltas arrive
|
|
80
|
+
every few milliseconds, so a delta could pin the view before the listener
|
|
81
|
+
heard that the reader had moved — and the pin put them back at the end, so the
|
|
82
|
+
decision never flipped. The question is now asked synchronously, immediately
|
|
83
|
+
before anything is added, which has no such window.
|
|
84
|
+
|
|
85
|
+
A separate report of the panel jumping to the TOP after a turn ends had a
|
|
86
|
+
different cause, fixed in 0.12.2 below.
|
|
87
|
+
|
|
6
88
|
## 0.12.0
|
|
7
89
|
|
|
8
90
|
Nothing a page must provide changes in this release: no new attribute, no
|
data/README.md
CHANGED
|
@@ -168,15 +168,17 @@ function boot(cfg) {
|
|
|
168
168
|
// The reader's own scrolling says which they are doing: leave the bottom
|
|
169
169
|
// and we stop following; come back to it and we resume.
|
|
170
170
|
var SCROLL_PIN_SLACK = 40; // px from the end still counts as "at the end"
|
|
171
|
-
|
|
171
|
+
|
|
172
|
+
// Rounded to the nearest thousand and grouped, so the numbers read as a
|
|
173
|
+
// scale rather than as a precise count the estimate cannot support.
|
|
174
|
+
function thousands(n) {
|
|
175
|
+
return (Math.round(n / 1000) * 1000).toLocaleString("en-US");
|
|
176
|
+
}
|
|
172
177
|
|
|
173
178
|
function atBottom() {
|
|
174
179
|
return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
|
|
175
180
|
}
|
|
176
181
|
function pinToBottom() { historyEl.scrollTop = historyEl.scrollHeight; }
|
|
177
|
-
// Called wherever content is added mid-turn.
|
|
178
|
-
function pinIfFollowing() { if (followingOutput) pinToBottom(); }
|
|
179
|
-
historyEl.addEventListener("scroll", function() { followingOutput = atBottom(); });
|
|
180
182
|
var formEl = root.querySelector(".lmw-form");
|
|
181
183
|
var inputEl = root.querySelector(".lmw-input");
|
|
182
184
|
var clearBtn = root.querySelector(".lmw-clear");
|
|
@@ -935,6 +937,8 @@ function boot(cfg) {
|
|
|
935
937
|
var pendingTurnLabel = null;
|
|
936
938
|
|
|
937
939
|
function appendTurn(role, text, turnLabel) {
|
|
940
|
+
// Captured BEFORE anything is added: see atBottom.
|
|
941
|
+
var following = atBottom(); // a question always wins
|
|
938
942
|
// Class names mirror llm_meta_chat's chats/_message.html.erb —
|
|
939
943
|
// `.message.<role>`, `.message-role`, `.message-content` — so the
|
|
940
944
|
// shared conversation.css styles apply directly (see the <link>
|
|
@@ -973,7 +977,9 @@ function boot(cfg) {
|
|
|
973
977
|
div.appendChild(document.createTextNode(" "));
|
|
974
978
|
div.appendChild(body);
|
|
975
979
|
historyEl.appendChild(div);
|
|
976
|
-
|
|
980
|
+
// A question the visitor just asked always goes to the end; anything
|
|
981
|
+
// else respects where they were reading.
|
|
982
|
+
if (role === "user" || following) pinToBottom();
|
|
977
983
|
body.roleLabel = label; // so a turn in flight can show it is working
|
|
978
984
|
return body;
|
|
979
985
|
}
|
|
@@ -1061,6 +1067,8 @@ function boot(cfg) {
|
|
|
1061
1067
|
}
|
|
1062
1068
|
|
|
1063
1069
|
function announceToolCall(toolCall) {
|
|
1070
|
+
// Captured BEFORE anything is added: see atBottom.
|
|
1071
|
+
var following = atBottom();
|
|
1064
1072
|
var row = chipRow();
|
|
1065
1073
|
if (!row) return;
|
|
1066
1074
|
var chip = document.createElement("span");
|
|
@@ -1069,7 +1077,7 @@ function boot(cfg) {
|
|
|
1069
1077
|
chip.dataset.tool = toolCall.name;
|
|
1070
1078
|
row.appendChild(chip);
|
|
1071
1079
|
pendingChips.push(chip);
|
|
1072
|
-
|
|
1080
|
+
if (following) pinToBottom();
|
|
1073
1081
|
}
|
|
1074
1082
|
|
|
1075
1083
|
function resolveToolCall(outcome) {
|
|
@@ -1091,6 +1099,8 @@ function boot(cfg) {
|
|
|
1091
1099
|
var activeAssistantBody = null;
|
|
1092
1100
|
|
|
1093
1101
|
function ensureThinkingBlock() {
|
|
1102
|
+
// Captured BEFORE anything is added: see atBottom.
|
|
1103
|
+
var following = atBottom();
|
|
1094
1104
|
if (currentThinkingBody) return currentThinkingBody;
|
|
1095
1105
|
// Class names match llm_meta_chat's .message-thinking pattern so
|
|
1096
1106
|
// the shared conversation.css applies directly.
|
|
@@ -1117,21 +1127,51 @@ function boot(cfg) {
|
|
|
1117
1127
|
} else {
|
|
1118
1128
|
historyEl.appendChild(details);
|
|
1119
1129
|
}
|
|
1120
|
-
|
|
1130
|
+
if (following) pinToBottom();
|
|
1121
1131
|
currentThinkingBlock = details;
|
|
1122
1132
|
currentThinkingBody = body;
|
|
1123
1133
|
return body;
|
|
1124
1134
|
}
|
|
1125
1135
|
|
|
1136
|
+
// Collapsing a thinking block is what broke scrolling in Chrome.
|
|
1137
|
+
//
|
|
1138
|
+
// A thinking block is a <details> holding its own scroller (max-height +
|
|
1139
|
+
// overflow-y on .message-thinking-content). Closing it with `open = false`
|
|
1140
|
+
// at a moment when that block is the only reason .lmw-messages overflows
|
|
1141
|
+
// makes the list briefly non-scrollable. When later content makes it
|
|
1142
|
+
// scrollable again, Chrome keeps the WHEEL range from that moment — tens of
|
|
1143
|
+
// pixels — while scrollTop and scrollHeight stay correct. So the reader can
|
|
1144
|
+
// scroll a few pixels and no further, by any input method, while
|
|
1145
|
+
// `scrollTop = 600` set from script still works. Chrome 154 reproduces it
|
|
1146
|
+
// with no widget code at all; Firefox 158 never does (reported 2026-10-17).
|
|
1147
|
+
//
|
|
1148
|
+
// Taking the scroller off the inner box BEFORE the close avoids the state.
|
|
1149
|
+
// Two frames, because one is not enough for Chrome to pick up the new
|
|
1150
|
+
// overflow before the close lands.
|
|
1151
|
+
//
|
|
1152
|
+
// The box stays a non-scroller afterwards, so a reader who reopens a
|
|
1153
|
+
// finished block sees it at full height instead of a 10em window. Putting
|
|
1154
|
+
// the scroller back on reopen would let a manual close trigger the bug
|
|
1155
|
+
// again, which is a worse trade.
|
|
1126
1156
|
function collapseThinkingBlock() {
|
|
1127
|
-
|
|
1128
|
-
currentThinkingBlock.classList.remove("thinking-active");
|
|
1129
|
-
currentThinkingBlock.open = false;
|
|
1130
|
-
var summary = currentThinkingBlock.querySelector("summary");
|
|
1131
|
-
if (summary) summary.textContent = "🤔 thinking (finished)";
|
|
1132
|
-
}
|
|
1157
|
+
var block = currentThinkingBlock;
|
|
1133
1158
|
currentThinkingBlock = null;
|
|
1134
1159
|
currentThinkingBody = null;
|
|
1160
|
+
if (!block) return;
|
|
1161
|
+
|
|
1162
|
+
block.classList.remove("thinking-active");
|
|
1163
|
+
var summary = block.querySelector("summary");
|
|
1164
|
+
if (summary) summary.textContent = "🤔 thinking (finished)";
|
|
1165
|
+
|
|
1166
|
+
var body = block.querySelector(".message-thinking-content");
|
|
1167
|
+
if (body) {
|
|
1168
|
+
body.style.overflow = "visible";
|
|
1169
|
+
body.style.maxHeight = "none";
|
|
1170
|
+
}
|
|
1171
|
+
if (typeof requestAnimationFrame !== "function") { block.open = false; return; }
|
|
1172
|
+
requestAnimationFrame(function() {
|
|
1173
|
+
requestAnimationFrame(function() { block.open = false; });
|
|
1174
|
+
});
|
|
1135
1175
|
}
|
|
1136
1176
|
|
|
1137
1177
|
// Every declaration channel the widget hands the model carries a
|
|
@@ -1266,9 +1306,6 @@ function boot(cfg) {
|
|
|
1266
1306
|
var userText = inputEl.value.trim();
|
|
1267
1307
|
if (!userText) return;
|
|
1268
1308
|
inputEl.value = "";
|
|
1269
|
-
// Asking something is a request to see the answer: whatever the reader was
|
|
1270
|
-
// looking at before, they are following the output again from here.
|
|
1271
|
-
followingOutput = true;
|
|
1272
1309
|
appendTurn("user", userText, pendingTurnLabel);
|
|
1273
1310
|
pendingTurnLabel = null;
|
|
1274
1311
|
|
|
@@ -1316,12 +1353,31 @@ function boot(cfg) {
|
|
|
1316
1353
|
contextWindow: modelWindow,
|
|
1317
1354
|
provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
|
|
1318
1355
|
signal: currentAbort.signal,
|
|
1319
|
-
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
|
|
1323
|
-
var
|
|
1324
|
-
appendTurn("system", "
|
|
1356
|
+
onContextTruncated: function(list) {
|
|
1357
|
+
// A shortened result is not a failure, but the reader has to know the
|
|
1358
|
+
// model answered from part of it — and the numbers say whether a narrower
|
|
1359
|
+
// question would help.
|
|
1360
|
+
var cut = list[list.length - 1] || {};
|
|
1361
|
+
appendTurn("system", "Shortened an oversized " + (cut.name || "tool") + " result to fit the context: " +
|
|
1362
|
+
thousands(cut.fromTokens || 0) + " tokens, where one result may use at most " +
|
|
1363
|
+
thousands(cut.toTokens || 0) + ". Ask again with a narrower question for the complete result.");
|
|
1364
|
+
},
|
|
1365
|
+
onContextEvicted: function(info) {
|
|
1366
|
+
// Said out loud, with the numbers. A context that quietly thins out makes
|
|
1367
|
+
// a later answer look inexplicably worse, and "an old result was dropped"
|
|
1368
|
+
// alone does not tell a reader whether to retry, shorten the question, or
|
|
1369
|
+
// pick a model with more room.
|
|
1370
|
+
var dropped = info.dropped || [];
|
|
1371
|
+
var names = dropped.map(function(d) { return d.name || "a tool"; });
|
|
1372
|
+
var what = names.length === 1
|
|
1373
|
+
? "the earliest tool result (" + names[0] + ")"
|
|
1374
|
+
: "the " + names.length + " earliest tool results (" + names.join(", ") + ")";
|
|
1375
|
+
var sizes = (info.needed && info.window)
|
|
1376
|
+
? " About " + thousands(info.needed) + " tokens were needed and " +
|
|
1377
|
+
thousands(info.window) + " fit in " + MODEL + "'s context."
|
|
1378
|
+
: "";
|
|
1379
|
+
appendTurn("system", "Dropped " + what + " to make room." + sizes +
|
|
1380
|
+
" Ask again, or narrow the question, for an answer based on complete tool results.");
|
|
1325
1381
|
},
|
|
1326
1382
|
onToolCall: function(toolCall) {
|
|
1327
1383
|
announceToolCall(toolCall);
|
|
@@ -1356,19 +1412,23 @@ function boot(cfg) {
|
|
|
1356
1412
|
collapseThinkingBlock();
|
|
1357
1413
|
},
|
|
1358
1414
|
onThinkingDelta: function(delta) {
|
|
1415
|
+
// Captured BEFORE anything is added: see atBottom.
|
|
1416
|
+
var following = atBottom();
|
|
1359
1417
|
var body = ensureThinkingBlock();
|
|
1360
1418
|
body.appendChild(document.createTextNode(delta));
|
|
1361
1419
|
body.scrollTop = body.scrollHeight;
|
|
1362
|
-
|
|
1420
|
+
if (following) pinToBottom();
|
|
1363
1421
|
},
|
|
1364
1422
|
onTextDelta: function(delta) {
|
|
1423
|
+
// Captured BEFORE anything is added: see atBottom.
|
|
1424
|
+
var following = atBottom();
|
|
1365
1425
|
markDone(assistantBody.roleLabel);
|
|
1366
1426
|
collapseThinkingBlock();
|
|
1367
1427
|
assistantMarkdown += delta;
|
|
1368
1428
|
renderMarkdownInto(assistantBody, assistantMarkdown);
|
|
1369
1429
|
liveTurn.content = assistantMarkdown;
|
|
1370
1430
|
persistSoon();
|
|
1371
|
-
|
|
1431
|
+
if (following) pinToBottom();
|
|
1372
1432
|
}
|
|
1373
1433
|
});
|
|
1374
1434
|
// The turn was recorded here until 0.11.1, after the loop returned — which
|
|
@@ -178,6 +178,8 @@ async function runChatLoop(opts) {
|
|
|
178
178
|
// Called when a tool result is dropped to make room, so the panel can say
|
|
179
179
|
// so rather than the context quietly thinning.
|
|
180
180
|
onContextEvicted,
|
|
181
|
+
// Called when one oversized result is shortened, so the panel can say so.
|
|
182
|
+
onContextTruncated,
|
|
181
183
|
// "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
|
|
182
184
|
// Ollama, with no server component of ours in between. The loop is the
|
|
183
185
|
// same either way — only the call is swapped.
|
|
@@ -211,6 +213,7 @@ async function runChatLoop(opts) {
|
|
|
211
213
|
const mergedLocalTools = Object.values(inlineByName);
|
|
212
214
|
const rounds = [];
|
|
213
215
|
const evicted = [];
|
|
216
|
+
const truncatedResults = [];
|
|
214
217
|
const toolsTokens = estimateToolsTokens([].concat(
|
|
215
218
|
singleOpts.localTools || [],
|
|
216
219
|
remoteTools || [],
|
|
@@ -246,6 +249,15 @@ async function runChatLoop(opts) {
|
|
|
246
249
|
for (let round = 0; round < maxRounds; round++) {
|
|
247
250
|
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
|
|
248
251
|
onRoundStart?.(round);
|
|
252
|
+
const capped = truncateOversizedToolResults({
|
|
253
|
+
messages,
|
|
254
|
+
window: contextWindow,
|
|
255
|
+
truncated: truncatedResults
|
|
256
|
+
});
|
|
257
|
+
if (capped.messages !== messages) {
|
|
258
|
+
messages = capped.messages;
|
|
259
|
+
onContextTruncated?.(truncatedResults.slice());
|
|
260
|
+
}
|
|
249
261
|
const room = evictOldToolResults({
|
|
250
262
|
messages,
|
|
251
263
|
window: contextWindow,
|
|
@@ -255,7 +267,7 @@ async function runChatLoop(opts) {
|
|
|
255
267
|
});
|
|
256
268
|
if (room.messages !== messages) {
|
|
257
269
|
messages = room.messages;
|
|
258
|
-
onContextEvicted?.(evicted.slice());
|
|
270
|
+
onContextEvicted?.({ dropped: evicted.slice(), needed: room.needed, window: room.window });
|
|
259
271
|
}
|
|
260
272
|
const call = provider === "ollama" ? ollamaChatCall : singleLlmCall;
|
|
261
273
|
const turnResult = await call({
|
|
@@ -589,17 +601,29 @@ function parseSseFrame(raw) {
|
|
|
589
601
|
var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
|
|
590
602
|
var PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json";
|
|
591
603
|
var RESOURCE_SHARE_OF_WINDOW = 0.0625;
|
|
604
|
+
var RESOURCE_BYTES_PER_TOKEN = 4;
|
|
592
605
|
function resourceBudgetBytes(window2, { floor = 8e3, cap = 64e3 } = {}) {
|
|
593
606
|
if (!window2 || !Number.isFinite(window2)) return floor;
|
|
594
|
-
const share = Math.round(window2 * RESOURCE_SHARE_OF_WINDOW *
|
|
607
|
+
const share = Math.round(window2 * RESOURCE_SHARE_OF_WINDOW * RESOURCE_BYTES_PER_TOKEN);
|
|
595
608
|
return Math.min(cap, Math.max(floor, share));
|
|
596
609
|
}
|
|
597
|
-
var
|
|
610
|
+
var PROSE_CHARS_PER_TOKEN = 3.5;
|
|
611
|
+
var DENSE_CHARS_PER_TOKEN = 1.6;
|
|
612
|
+
var CJK_CHARS_PER_TOKEN = 1;
|
|
598
613
|
var MESSAGE_OVERHEAD_TOKENS = 4;
|
|
614
|
+
var CJK_PATTERN = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
|
|
615
|
+
var PROSE_PATTERN = /[A-Za-z \n\t]/g;
|
|
616
|
+
function estimateTextTokens(text) {
|
|
617
|
+
const str = typeof text === "string" ? text : String(text ?? "");
|
|
618
|
+
const cjk = (str.match(CJK_PATTERN) || []).length;
|
|
619
|
+
const prose = (str.match(PROSE_PATTERN) || []).length;
|
|
620
|
+
const dense = str.length - cjk - prose;
|
|
621
|
+
return Math.ceil(cjk / CJK_CHARS_PER_TOKEN + prose / PROSE_CHARS_PER_TOKEN + dense / DENSE_CHARS_PER_TOKEN);
|
|
622
|
+
}
|
|
599
623
|
function estimateTokens(messages) {
|
|
600
624
|
return (messages || []).reduce((sum, m) => {
|
|
601
625
|
const content = typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? "");
|
|
602
|
-
return sum +
|
|
626
|
+
return sum + estimateTextTokens(content) + MESSAGE_OVERHEAD_TOKENS;
|
|
603
627
|
}, 0);
|
|
604
628
|
}
|
|
605
629
|
function estimateToolsTokens(tools) {
|
|
@@ -608,12 +632,36 @@ function estimateToolsTokens(tools) {
|
|
|
608
632
|
description: t?.description,
|
|
609
633
|
schema: t?.input_schema || t?.inputSchema
|
|
610
634
|
})).join("");
|
|
611
|
-
return
|
|
635
|
+
return estimateTextTokens(text);
|
|
636
|
+
}
|
|
637
|
+
var MAX_TOOL_RESULT_SHARE = 0.4;
|
|
638
|
+
function truncateOversizedToolResults({
|
|
639
|
+
messages,
|
|
640
|
+
window: window2,
|
|
641
|
+
share = MAX_TOOL_RESULT_SHARE,
|
|
642
|
+
truncated = []
|
|
643
|
+
} = {}) {
|
|
644
|
+
if (!window2 || !Number.isFinite(window2)) return { messages, truncated };
|
|
645
|
+
const cap = Math.floor(window2 * share);
|
|
646
|
+
let out = null;
|
|
647
|
+
(messages || []).forEach((m, i) => {
|
|
648
|
+
if (m?.role !== "tool" || typeof m.content !== "string") return;
|
|
649
|
+
const tokens = estimateTextTokens(m.content);
|
|
650
|
+
if (tokens <= cap) return;
|
|
651
|
+
const keep = Math.max(1, Math.floor(m.content.length * cap / tokens));
|
|
652
|
+
out = out || messages.slice();
|
|
653
|
+
truncated.push({ name: m.name, fromTokens: tokens, toTokens: cap });
|
|
654
|
+
out[i] = Object.assign({}, m, {
|
|
655
|
+
content: m.content.slice(0, keep) + "\n\n[truncated: " + keep + " of " + m.content.length + " characters shown. One tool result may use at most " + Math.round(share * 100) + "% of the context window. Call the tool again with a narrower query to see more.]"
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
return { messages: out || messages, truncated };
|
|
612
659
|
}
|
|
613
660
|
function evictOldToolResults({ messages, window: window2, reserve = 0, dropped = [] }) {
|
|
614
661
|
if (!window2 || !Number.isFinite(window2)) return { messages, dropped };
|
|
615
662
|
const budget = window2 - reserve;
|
|
616
|
-
|
|
663
|
+
const needed = estimateTokens(messages) + reserve;
|
|
664
|
+
if (estimateTokens(messages) <= budget) return { messages, dropped, needed, window: window2 };
|
|
617
665
|
const out = messages.slice();
|
|
618
666
|
for (let i = 0; i < out.length; i++) {
|
|
619
667
|
if (estimateTokens(out) <= budget) break;
|
|
@@ -625,7 +673,7 @@ function evictOldToolResults({ messages, window: window2, reserve = 0, dropped =
|
|
|
625
673
|
content: "[dropped to make room in the context window. Call the tool again if you still need it, with a narrower query.]"
|
|
626
674
|
});
|
|
627
675
|
}
|
|
628
|
-
return { messages: out, dropped };
|
|
676
|
+
return { messages: out, dropped, needed, window: window2 };
|
|
629
677
|
}
|
|
630
678
|
function modelWindowFrom(payload, modelId) {
|
|
631
679
|
for (const family of payload && payload.llms || []) {
|
|
@@ -3940,19 +3988,15 @@ function boot(cfg) {
|
|
|
3940
3988
|
var toggleBtn = document.getElementById("llm-meta-widget-toggle");
|
|
3941
3989
|
var historyEl = root.querySelector(".lmw-messages");
|
|
3942
3990
|
var SCROLL_PIN_SLACK = 40;
|
|
3943
|
-
|
|
3991
|
+
function thousands(n) {
|
|
3992
|
+
return (Math.round(n / 1e3) * 1e3).toLocaleString("en-US");
|
|
3993
|
+
}
|
|
3944
3994
|
function atBottom() {
|
|
3945
3995
|
return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
|
|
3946
3996
|
}
|
|
3947
3997
|
function pinToBottom() {
|
|
3948
3998
|
historyEl.scrollTop = historyEl.scrollHeight;
|
|
3949
3999
|
}
|
|
3950
|
-
function pinIfFollowing() {
|
|
3951
|
-
if (followingOutput) pinToBottom();
|
|
3952
|
-
}
|
|
3953
|
-
historyEl.addEventListener("scroll", function() {
|
|
3954
|
-
followingOutput = atBottom();
|
|
3955
|
-
});
|
|
3956
4000
|
var formEl = root.querySelector(".lmw-form");
|
|
3957
4001
|
var inputEl = root.querySelector(".lmw-input");
|
|
3958
4002
|
var clearBtn = root.querySelector(".lmw-clear");
|
|
@@ -4505,6 +4549,7 @@ function boot(cfg) {
|
|
|
4505
4549
|
}
|
|
4506
4550
|
var pendingTurnLabel = null;
|
|
4507
4551
|
function appendTurn(role, text, turnLabel) {
|
|
4552
|
+
var following = atBottom();
|
|
4508
4553
|
var welcome = historyEl.querySelector(".lmw-welcome");
|
|
4509
4554
|
if (welcome) welcome.remove();
|
|
4510
4555
|
var div = document.createElement("div");
|
|
@@ -4531,7 +4576,7 @@ function boot(cfg) {
|
|
|
4531
4576
|
div.appendChild(document.createTextNode(" "));
|
|
4532
4577
|
div.appendChild(body);
|
|
4533
4578
|
historyEl.appendChild(div);
|
|
4534
|
-
|
|
4579
|
+
if (role === "user" || following) pinToBottom();
|
|
4535
4580
|
body.roleLabel = label;
|
|
4536
4581
|
return body;
|
|
4537
4582
|
}
|
|
@@ -4581,6 +4626,7 @@ function boot(cfg) {
|
|
|
4581
4626
|
return liveChips;
|
|
4582
4627
|
}
|
|
4583
4628
|
function announceToolCall(toolCall) {
|
|
4629
|
+
var following = atBottom();
|
|
4584
4630
|
var row = chipRow();
|
|
4585
4631
|
if (!row) return;
|
|
4586
4632
|
var chip = document.createElement("span");
|
|
@@ -4589,7 +4635,7 @@ function boot(cfg) {
|
|
|
4589
4635
|
chip.dataset.tool = toolCall.name;
|
|
4590
4636
|
row.appendChild(chip);
|
|
4591
4637
|
pendingChips.push(chip);
|
|
4592
|
-
|
|
4638
|
+
if (following) pinToBottom();
|
|
4593
4639
|
}
|
|
4594
4640
|
function resolveToolCall(outcome) {
|
|
4595
4641
|
var idx = pendingChips.findIndex(function(c) {
|
|
@@ -4605,6 +4651,7 @@ function boot(cfg) {
|
|
|
4605
4651
|
var currentThinkingBody = null;
|
|
4606
4652
|
var activeAssistantBody = null;
|
|
4607
4653
|
function ensureThinkingBlock() {
|
|
4654
|
+
var following = atBottom();
|
|
4608
4655
|
if (currentThinkingBody) return currentThinkingBody;
|
|
4609
4656
|
var details = document.createElement("details");
|
|
4610
4657
|
details.className = "message-thinking";
|
|
@@ -4629,20 +4676,33 @@ function boot(cfg) {
|
|
|
4629
4676
|
} else {
|
|
4630
4677
|
historyEl.appendChild(details);
|
|
4631
4678
|
}
|
|
4632
|
-
|
|
4679
|
+
if (following) pinToBottom();
|
|
4633
4680
|
currentThinkingBlock = details;
|
|
4634
4681
|
currentThinkingBody = body;
|
|
4635
4682
|
return body;
|
|
4636
4683
|
}
|
|
4637
4684
|
function collapseThinkingBlock() {
|
|
4638
|
-
|
|
4639
|
-
currentThinkingBlock.classList.remove("thinking-active");
|
|
4640
|
-
currentThinkingBlock.open = false;
|
|
4641
|
-
var summary = currentThinkingBlock.querySelector("summary");
|
|
4642
|
-
if (summary) summary.textContent = "\u{1F914} thinking (finished)";
|
|
4643
|
-
}
|
|
4685
|
+
var block2 = currentThinkingBlock;
|
|
4644
4686
|
currentThinkingBlock = null;
|
|
4645
4687
|
currentThinkingBody = null;
|
|
4688
|
+
if (!block2) return;
|
|
4689
|
+
block2.classList.remove("thinking-active");
|
|
4690
|
+
var summary = block2.querySelector("summary");
|
|
4691
|
+
if (summary) summary.textContent = "\u{1F914} thinking (finished)";
|
|
4692
|
+
var body = block2.querySelector(".message-thinking-content");
|
|
4693
|
+
if (body) {
|
|
4694
|
+
body.style.overflow = "visible";
|
|
4695
|
+
body.style.maxHeight = "none";
|
|
4696
|
+
}
|
|
4697
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
4698
|
+
block2.open = false;
|
|
4699
|
+
return;
|
|
4700
|
+
}
|
|
4701
|
+
requestAnimationFrame(function() {
|
|
4702
|
+
requestAnimationFrame(function() {
|
|
4703
|
+
block2.open = false;
|
|
4704
|
+
});
|
|
4705
|
+
});
|
|
4646
4706
|
}
|
|
4647
4707
|
function stateReaders() {
|
|
4648
4708
|
var declared = window[STATE_GLOBAL] || {};
|
|
@@ -4746,7 +4806,6 @@ function boot(cfg) {
|
|
|
4746
4806
|
var userText = inputEl.value.trim();
|
|
4747
4807
|
if (!userText) return;
|
|
4748
4808
|
inputEl.value = "";
|
|
4749
|
-
followingOutput = true;
|
|
4750
4809
|
appendTurn("user", userText, pendingTurnLabel);
|
|
4751
4810
|
pendingTurnLabel = null;
|
|
4752
4811
|
if (currentAbort) {
|
|
@@ -4786,10 +4845,18 @@ function boot(cfg) {
|
|
|
4786
4845
|
contextWindow: modelWindow,
|
|
4787
4846
|
provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
|
|
4788
4847
|
signal: currentAbort.signal,
|
|
4789
|
-
|
|
4790
|
-
var
|
|
4791
|
-
|
|
4792
|
-
|
|
4848
|
+
onContextTruncated: function(list2) {
|
|
4849
|
+
var cut = list2[list2.length - 1] || {};
|
|
4850
|
+
appendTurn("system", "Shortened an oversized " + (cut.name || "tool") + " result to fit the context: " + thousands(cut.fromTokens || 0) + " tokens, where one result may use at most " + thousands(cut.toTokens || 0) + ". Ask again with a narrower question for the complete result.");
|
|
4851
|
+
},
|
|
4852
|
+
onContextEvicted: function(info) {
|
|
4853
|
+
var dropped = info.dropped || [];
|
|
4854
|
+
var names = dropped.map(function(d) {
|
|
4855
|
+
return d.name || "a tool";
|
|
4856
|
+
});
|
|
4857
|
+
var what = names.length === 1 ? "the earliest tool result (" + names[0] + ")" : "the " + names.length + " earliest tool results (" + names.join(", ") + ")";
|
|
4858
|
+
var sizes = info.needed && info.window ? " About " + thousands(info.needed) + " tokens were needed and " + thousands(info.window) + " fit in " + MODEL + "'s context." : "";
|
|
4859
|
+
appendTurn("system", "Dropped " + what + " to make room." + sizes + " Ask again, or narrow the question, for an answer based on complete tool results.");
|
|
4793
4860
|
},
|
|
4794
4861
|
onToolCall: function(toolCall) {
|
|
4795
4862
|
announceToolCall(toolCall);
|
|
@@ -4809,19 +4876,21 @@ function boot(cfg) {
|
|
|
4809
4876
|
collapseThinkingBlock();
|
|
4810
4877
|
},
|
|
4811
4878
|
onThinkingDelta: function(delta) {
|
|
4879
|
+
var following = atBottom();
|
|
4812
4880
|
var body = ensureThinkingBlock();
|
|
4813
4881
|
body.appendChild(document.createTextNode(delta));
|
|
4814
4882
|
body.scrollTop = body.scrollHeight;
|
|
4815
|
-
|
|
4883
|
+
if (following) pinToBottom();
|
|
4816
4884
|
},
|
|
4817
4885
|
onTextDelta: function(delta) {
|
|
4886
|
+
var following = atBottom();
|
|
4818
4887
|
markDone(assistantBody.roleLabel);
|
|
4819
4888
|
collapseThinkingBlock();
|
|
4820
4889
|
assistantMarkdown += delta;
|
|
4821
4890
|
renderMarkdownInto(assistantBody, assistantMarkdown);
|
|
4822
4891
|
liveTurn.content = assistantMarkdown;
|
|
4823
4892
|
persistSoon();
|
|
4824
|
-
|
|
4893
|
+
if (following) pinToBottom();
|
|
4825
4894
|
}
|
|
4826
4895
|
});
|
|
4827
4896
|
liveTurn.content = result.content;
|
|
@@ -335,6 +335,8 @@ export async function runChatLoop(opts) {
|
|
|
335
335
|
// Called when a tool result is dropped to make room, so the panel can say
|
|
336
336
|
// so rather than the context quietly thinning.
|
|
337
337
|
onContextEvicted,
|
|
338
|
+
// Called when one oversized result is shortened, so the panel can say so.
|
|
339
|
+
onContextTruncated,
|
|
338
340
|
// "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
|
|
339
341
|
// Ollama, with no server component of ours in between. The loop is the
|
|
340
342
|
// same either way — only the call is swapped.
|
|
@@ -377,6 +379,7 @@ export async function runChatLoop(opts) {
|
|
|
377
379
|
|
|
378
380
|
const rounds = []
|
|
379
381
|
const evicted = []
|
|
382
|
+
const truncatedResults = []
|
|
380
383
|
// Fixed for the turn: the declared tool set does not change between rounds.
|
|
381
384
|
const toolsTokens = estimateToolsTokens([].concat(
|
|
382
385
|
singleOpts.localTools || [], remoteTools || [], hostWideTools || []))
|
|
@@ -422,6 +425,16 @@ export async function runChatLoop(opts) {
|
|
|
422
425
|
onRoundStart?.(round)
|
|
423
426
|
|
|
424
427
|
// The loop is the same whoever answers; only the call differs.
|
|
428
|
+
// One oversized result is capped first: it frees hundreds of times more
|
|
429
|
+
// than dropping old results, and keeps what the model asked for.
|
|
430
|
+
const capped = truncateOversizedToolResults({
|
|
431
|
+
messages, window: contextWindow, truncated: truncatedResults
|
|
432
|
+
});
|
|
433
|
+
if (capped.messages !== messages) {
|
|
434
|
+
messages = capped.messages;
|
|
435
|
+
onContextTruncated?.(truncatedResults.slice());
|
|
436
|
+
}
|
|
437
|
+
|
|
425
438
|
// Make room before sending, rather than after being refused. Only old
|
|
426
439
|
// tool results are given up; see evictOldToolResults.
|
|
427
440
|
const room = evictOldToolResults({
|
|
@@ -431,7 +444,7 @@ export async function runChatLoop(opts) {
|
|
|
431
444
|
});
|
|
432
445
|
if (room.messages !== messages) {
|
|
433
446
|
messages = room.messages;
|
|
434
|
-
onContextEvicted?.(evicted.slice());
|
|
447
|
+
onContextEvicted?.({ dropped: evicted.slice(), needed: room.needed, window: room.window });
|
|
435
448
|
}
|
|
436
449
|
|
|
437
450
|
const call = provider === "ollama" ? ollamaChatCall : singleLlmCall
|
|
@@ -937,23 +950,53 @@ export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
|
|
|
937
950
|
// is already generous and a 1M-token model does not need 250 KB of it.
|
|
938
951
|
export const RESOURCE_SHARE_OF_WINDOW = 0.0625
|
|
939
952
|
|
|
953
|
+
// Bytes per token for SIZING that budget — not the estimator above, which
|
|
954
|
+
// measures real text. Kept at four so the budget stays exactly what it was
|
|
955
|
+
// (8 KB on a 32k model): how many tokens those bytes become depends on the
|
|
956
|
+
// resource, and re-sizing budgets is not this constant's job.
|
|
957
|
+
export const RESOURCE_BYTES_PER_TOKEN = 4
|
|
958
|
+
|
|
940
959
|
export function resourceBudgetBytes(window, { floor = 8000, cap = 64_000 } = {}) {
|
|
941
960
|
if (!window || !Number.isFinite(window)) return floor
|
|
942
|
-
const share = Math.round(window * RESOURCE_SHARE_OF_WINDOW *
|
|
961
|
+
const share = Math.round(window * RESOURCE_SHARE_OF_WINDOW * RESOURCE_BYTES_PER_TOKEN)
|
|
943
962
|
return Math.min(cap, Math.max(floor, share))
|
|
944
963
|
}
|
|
945
964
|
|
|
946
|
-
// A rough token count, the same
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
|
|
965
|
+
// A rough token count, the same three classes the hub's estimator uses.
|
|
966
|
+
//
|
|
967
|
+
// One ratio cannot describe both prose and a tool result. Letters and spaces
|
|
968
|
+
// tokenize at roughly 3.5 characters each; digits, punctuation and symbols go
|
|
969
|
+
// much denser, and a JSON tool result is mostly those.
|
|
970
|
+
//
|
|
971
|
+
// Measured against a real refusal (2026-10-17): a 289,101-character request
|
|
972
|
+
// the provider counted as 135,291 tokens was 33% letters, 16% digits and 45%
|
|
973
|
+
// punctuation. At a flat four characters per token that is 72,275 — 53% of the
|
|
974
|
+
// truth — so nothing was evicted and the provider refused the turn instead.
|
|
975
|
+
// These classes put it at 106%: high, which is the safe direction here, since
|
|
976
|
+
// underestimating means the turn dies while overestimating only gives up an
|
|
977
|
+
// old tool result slightly early.
|
|
978
|
+
export const PROSE_CHARS_PER_TOKEN = 3.5
|
|
979
|
+
export const DENSE_CHARS_PER_TOKEN = 1.6
|
|
980
|
+
export const CJK_CHARS_PER_TOKEN = 1
|
|
951
981
|
export const MESSAGE_OVERHEAD_TOKENS = 4
|
|
952
982
|
|
|
983
|
+
const CJK_PATTERN = /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g
|
|
984
|
+
const PROSE_PATTERN = /[A-Za-z \n\t]/g
|
|
985
|
+
|
|
986
|
+
export function estimateTextTokens(text) {
|
|
987
|
+
const str = typeof text === "string" ? text : String(text ?? "")
|
|
988
|
+
const cjk = (str.match(CJK_PATTERN) || []).length
|
|
989
|
+
const prose = (str.match(PROSE_PATTERN) || []).length
|
|
990
|
+
const dense = str.length - cjk - prose
|
|
991
|
+
return Math.ceil(cjk / CJK_CHARS_PER_TOKEN +
|
|
992
|
+
prose / PROSE_CHARS_PER_TOKEN +
|
|
993
|
+
dense / DENSE_CHARS_PER_TOKEN)
|
|
994
|
+
}
|
|
995
|
+
|
|
953
996
|
export function estimateTokens(messages) {
|
|
954
997
|
return (messages || []).reduce((sum, m) => {
|
|
955
998
|
const content = typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? "")
|
|
956
|
-
return sum +
|
|
999
|
+
return sum + estimateTextTokens(content) + MESSAGE_OVERHEAD_TOKENS
|
|
957
1000
|
}, 0)
|
|
958
1001
|
}
|
|
959
1002
|
|
|
@@ -969,7 +1012,57 @@ export function estimateToolsTokens(tools) {
|
|
|
969
1012
|
const text = (tools || []).map((t) => JSON.stringify({
|
|
970
1013
|
name: t?.name, description: t?.description, schema: t?.input_schema || t?.inputSchema
|
|
971
1014
|
})).join("")
|
|
972
|
-
|
|
1015
|
+
// Counted with the same classes: a schema is JSON, which is the dense case.
|
|
1016
|
+
return estimateTextTokens(text)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// A single tool result may use at most this share of the window.
|
|
1020
|
+
//
|
|
1021
|
+
// Measured over 179 real tool results (2026-10-17): the median is 2.1% of the
|
|
1022
|
+
// window and the 90th percentile 20%, but one `togovar_search_variant` result
|
|
1023
|
+
// came to 94% — 120,667 tokens — and alone could not coexist with a
|
|
1024
|
+
// conversation. All six over-budget requests in that sample were caused by one
|
|
1025
|
+
// oversized result, so capping it clears every one of them while touching only
|
|
1026
|
+
// 4.5% of results at all.
|
|
1027
|
+
//
|
|
1028
|
+
// 40% keeps a useful prefix of even the worst result (42% of it) and still
|
|
1029
|
+
// leaves most of the window for the conversation, the schemas and the answer.
|
|
1030
|
+
export const MAX_TOOL_RESULT_SHARE = 0.4
|
|
1031
|
+
|
|
1032
|
+
// Shortens any single tool result that exceeds that share.
|
|
1033
|
+
//
|
|
1034
|
+
// This runs BEFORE eviction, because it is strictly the better move: on the
|
|
1035
|
+
// audited turn it frees ~69,000 tokens from one result, where dropping the
|
|
1036
|
+
// oldest results freed 160 and lost two results the model had asked for. The
|
|
1037
|
+
// model is told what happened, in the result itself, so it can narrow its
|
|
1038
|
+
// query instead of assuming it received everything.
|
|
1039
|
+
export function truncateOversizedToolResults({ messages, window,
|
|
1040
|
+
share = MAX_TOOL_RESULT_SHARE,
|
|
1041
|
+
truncated = [] } = {}) {
|
|
1042
|
+
if (!window || !Number.isFinite(window)) return { messages, truncated }
|
|
1043
|
+
|
|
1044
|
+
const cap = Math.floor(window * share)
|
|
1045
|
+
let out = null
|
|
1046
|
+
|
|
1047
|
+
;(messages || []).forEach((m, i) => {
|
|
1048
|
+
if (m?.role !== "tool" || typeof m.content !== "string") return
|
|
1049
|
+
const tokens = estimateTextTokens(m.content)
|
|
1050
|
+
if (tokens <= cap) return
|
|
1051
|
+
|
|
1052
|
+
// Scale the cut by the measured ratio rather than a fixed chars-per-token,
|
|
1053
|
+
// so a dense result is cut by what it actually costs.
|
|
1054
|
+
const keep = Math.max(1, Math.floor(m.content.length * cap / tokens))
|
|
1055
|
+
out = out || messages.slice()
|
|
1056
|
+
truncated.push({ name: m.name, fromTokens: tokens, toTokens: cap })
|
|
1057
|
+
out[i] = Object.assign({}, m, {
|
|
1058
|
+
content: m.content.slice(0, keep) +
|
|
1059
|
+
"\n\n[truncated: " + keep + " of " + m.content.length + " characters shown. " +
|
|
1060
|
+
"One tool result may use at most " + Math.round(share * 100) +
|
|
1061
|
+
"% of the context window. Call the tool again with a narrower query to see more.]"
|
|
1062
|
+
})
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1065
|
+
return { messages: out || messages, truncated }
|
|
973
1066
|
}
|
|
974
1067
|
|
|
975
1068
|
// Makes room for the next round by dropping the OLDEST tool results, which is
|
|
@@ -990,7 +1083,12 @@ export function evictOldToolResults({ messages, window, reserve = 0, dropped = [
|
|
|
990
1083
|
if (!window || !Number.isFinite(window)) return { messages, dropped }
|
|
991
1084
|
|
|
992
1085
|
const budget = window - reserve
|
|
993
|
-
|
|
1086
|
+
// What the whole turn would have needed, against what the model holds. The
|
|
1087
|
+
// panel shows both, because "an old result was dropped" on its own tells a
|
|
1088
|
+
// reader nothing about whether to retry, shorten the question, or change
|
|
1089
|
+
// model.
|
|
1090
|
+
const needed = estimateTokens(messages) + reserve
|
|
1091
|
+
if (estimateTokens(messages) <= budget) return { messages, dropped, needed, window }
|
|
994
1092
|
|
|
995
1093
|
const out = messages.slice()
|
|
996
1094
|
for (let i = 0; i < out.length; i++) {
|
|
@@ -1005,7 +1103,7 @@ export function evictOldToolResults({ messages, window, reserve = 0, dropped = [
|
|
|
1005
1103
|
"if you still need it, with a narrower query.]"
|
|
1006
1104
|
})
|
|
1007
1105
|
}
|
|
1008
|
-
return { messages: out, dropped }
|
|
1106
|
+
return { messages: out, dropped, needed, window }
|
|
1009
1107
|
}
|
|
1010
1108
|
|
|
1011
1109
|
export function modelWindowFrom(payload, modelId) {
|