openzoo 0.48.75 → 0.48.77
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/lib/models.js +30 -7
- package/lib/proxy.js +18 -4
- package/lib/spill.js +270 -29
- package/package.json +1 -1
package/lib/models.js
CHANGED
|
@@ -269,16 +269,35 @@ export function rewritablePath(method, url) {
|
|
|
269
269
|
*/
|
|
270
270
|
export const REASONING_MODEL_RE = /(deepseek|grok|o[134](-|$)|reasoner|thinking|-pro\b|sol-pro|qwq)/i;
|
|
271
271
|
|
|
272
|
-
/** Claude Code auto-mode classify is max_tokens=16
|
|
273
|
-
|
|
272
|
+
/** Claude Code auto-mode classify is max_tokens=16. 0.48.75 used 64 and
|
|
273
|
+
* missed grok nubs at 128 / 2000 on a 1–2 message body (dead-steady ~3¢
|
|
274
|
+
* from the reasoning floor). Anything in (0, 256] on a short transcript
|
|
275
|
+
* is a nub; a fat grok chat still uses the 4000 floor. */
|
|
276
|
+
export const CLASSIFY_MAX_TOKENS = 256;
|
|
277
|
+
export const CLASSIFY_MAX_MSGS = 3;
|
|
278
|
+
export const CLASSIFY_MAX_BODY = 65_536;
|
|
274
279
|
|
|
275
280
|
const CLASSIFIER_PREFS = ['google/gemini-3.7-flash', 'anthropic/claude-haiku-4.5'];
|
|
276
281
|
|
|
282
|
+
function messageHasToolCalls(m) {
|
|
283
|
+
return Boolean(
|
|
284
|
+
(Array.isArray(m?.tool_calls) && m.tool_calls.length)
|
|
285
|
+
|| m?.function_call
|
|
286
|
+
|| m?.role === 'tool',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
277
290
|
/**
|
|
278
|
-
* Tiny
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
291
|
+
* Tiny classify / grok nub: pin to flash, never apply the reasoning floor.
|
|
292
|
+
*
|
|
293
|
+
* 0.48.75 required max_tokens ≤ 64 AND body < BIND_MIN (16k). Live 3¢
|
|
294
|
+
* asks missed that (max_tokens 128 or 2000, and/or body ≥ 16k from a
|
|
295
|
+
* tools schema) and then ate the 4000 grok floor. Widen:
|
|
296
|
+
* - max_tokens ≤ 256 on a short transcript (≤ 6 msgs, no tool_calls)
|
|
297
|
+
* - OR few messages, no tool_calls, body under a few tens of KB
|
|
298
|
+
* (even when the caller asked for 2000 tokens)
|
|
299
|
+
* A real grok chat (max_tokens 2000+ AND a long / tool-using transcript)
|
|
300
|
+
* still returns false so the floor can fire.
|
|
282
301
|
*
|
|
283
302
|
* `body` may be the raw Buffer/string or a parsed object. An optional
|
|
284
303
|
* `bodyLen` overrides stringify length when the caller still has the wire
|
|
@@ -296,7 +315,11 @@ export function isTinyClassify(body, bodyLen) {
|
|
|
296
315
|
try { len = Buffer.byteLength(JSON.stringify(body)); } catch { return false; }
|
|
297
316
|
}
|
|
298
317
|
const mt = Number(parsed?.max_tokens);
|
|
299
|
-
|
|
318
|
+
if (!Number.isFinite(mt) || mt <= 0) return false;
|
|
319
|
+
const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
|
|
320
|
+
if (messages.some(messageHasToolCalls)) return false;
|
|
321
|
+
if (mt <= CLASSIFY_MAX_TOKENS && messages.length <= 6) return true;
|
|
322
|
+
return messages.length <= CLASSIFY_MAX_MSGS && len < CLASSIFY_MAX_BODY;
|
|
300
323
|
}
|
|
301
324
|
|
|
302
325
|
/**
|
package/lib/proxy.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
|
|
17
17
|
createSpillStats, corpusCharsForSend, applySpillCut, msgText,
|
|
18
18
|
} from './spill.js';
|
|
19
|
-
import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds } from './models.js';
|
|
19
|
+
import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
|
|
20
20
|
import { forgetContext } from './contexts.js';
|
|
21
21
|
import { injectBrief } from './brief.js';
|
|
22
22
|
import { withNamespace } from './namespace.js';
|
|
@@ -440,6 +440,18 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
440
440
|
});
|
|
441
441
|
if (adapted.cut <= adapted.firstSpillable) {
|
|
442
442
|
bindFilesInBackground('no severable cut, files only');
|
|
443
|
+
// Still forward stubbed/trimmed bodies so an un-severable storm is not
|
|
444
|
+
// shipped at full size just because cutTranscript could not move.
|
|
445
|
+
if (adapted.stubbed?.dropped || adapted.stubbed?.stubbed) {
|
|
446
|
+
return {
|
|
447
|
+
body: Buffer.from(JSON.stringify({ ...body, messages: adapted.stubbed.messages })),
|
|
448
|
+
corpus: '',
|
|
449
|
+
reused: false,
|
|
450
|
+
savedBytes: 0,
|
|
451
|
+
sent: adapted.stubbed.messages.length,
|
|
452
|
+
msgs: msgs.length,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
443
455
|
return null;
|
|
444
456
|
}
|
|
445
457
|
const { cut, firstSpillable } = adapted;
|
|
@@ -1182,9 +1194,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1182
1194
|
// temporarily unavailable (timed out), so auto mode cannot determine
|
|
1183
1195
|
// the safety of WebSearch". Even staying on sonnet-5 is too slow
|
|
1184
1196
|
// (402 handshake behind a long Grok stream). Pin to a fast
|
|
1185
|
-
// non-reasoning catalog id and leave max_tokens
|
|
1186
|
-
// Grok/DeepSeek chats (max_tokens
|
|
1197
|
+
// non-reasoning catalog id and leave max_tokens alone. Real
|
|
1198
|
+
// Grok/DeepSeek chats (max_tokens 2000+ AND a long transcript)
|
|
1187
1199
|
// still get the 4000 floor � those still go blank without it.
|
|
1200
|
+
// 0.48.75 missed grok nubs at max_tokens 128/2000 on a 1�2
|
|
1201
|
+
// message body (~3� from the floor, no classifier log).
|
|
1188
1202
|
let ids = [];
|
|
1189
1203
|
try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
|
|
1190
1204
|
const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
|
|
@@ -1266,7 +1280,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1266
1280
|
const tiny = policy.tiny
|
|
1267
1281
|
|| (bodyBuf.length < BIND_MIN_CHARS
|
|
1268
1282
|
&& Number(parsed?.max_tokens ?? 0) > 0
|
|
1269
|
-
&& Number(parsed?.max_tokens) <=
|
|
1283
|
+
&& Number(parsed?.max_tokens) <= CLASSIFY_MAX_TOKENS);
|
|
1270
1284
|
const briefed = tiny ? null : injectBrief(parsed, selfUrl);
|
|
1271
1285
|
if (briefed) parsed = briefed;
|
|
1272
1286
|
// SYSTEM MESSAGES BELONG AT THE FRONT, OR GOOGLE 400s.
|
package/lib/spill.js
CHANGED
|
@@ -495,6 +495,58 @@ export function looksLikeFileView(command) {
|
|
|
495
495
|
/** Tool result larger than this is "fat" — stub it in the forwarded tail. */
|
|
496
496
|
export const FAT_TOOL_CHARS = 400;
|
|
497
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Bound-file matching used to stub any size, including a 461-byte Read the
|
|
500
|
+
* model had just fetched. Stay under this floor unless we are over budget
|
|
501
|
+
* *and* the result is not this turn's current tool round.
|
|
502
|
+
*/
|
|
503
|
+
export const KEEP_TOOL_CHARS = 4096;
|
|
504
|
+
|
|
505
|
+
function toolCallIds(m) {
|
|
506
|
+
if (!m || typeof m !== 'object') return [];
|
|
507
|
+
const ids = [];
|
|
508
|
+
if (Array.isArray(m.tool_calls)) {
|
|
509
|
+
for (const c of m.tool_calls) {
|
|
510
|
+
const id = c?.id || c?.tool_call_id;
|
|
511
|
+
if (id) ids.push(id);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (m.function_call) {
|
|
515
|
+
const id = m.function_call.id || m.function_call.tool_call_id;
|
|
516
|
+
if (id) ids.push(id);
|
|
517
|
+
}
|
|
518
|
+
if (Array.isArray(m.content)) {
|
|
519
|
+
for (const b of m.content) {
|
|
520
|
+
if (b?.type === 'tool_use' && b.id) ids.push(b.id);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return ids;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Last assistant that issued tool_calls / tool_use, plus those ids.
|
|
528
|
+
*
|
|
529
|
+
* Claude Code / OpenAI put *this turn's* Read/Bash/WebSearch results in
|
|
530
|
+
* `role:tool` (or tool_result blocks) AFTER the last user ask. Those are
|
|
531
|
+
* in-flight — the model's current eyes — and must never be stubbed, even
|
|
532
|
+
* when large. A completed round (tools, then a follow-up ask) may still
|
|
533
|
+
* be stubbed; that is the 0.48.76 storm savings. The last round's small
|
|
534
|
+
* bodies still use KEEP_TOOL_CHARS so a 461-byte Read survives.
|
|
535
|
+
*/
|
|
536
|
+
export function currentToolRound(msgs, { lastUser = -1 } = {}) {
|
|
537
|
+
let index = -1;
|
|
538
|
+
const ids = new Set();
|
|
539
|
+
if (!Array.isArray(msgs)) return { index, ids, inFlight: false };
|
|
540
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
541
|
+
const here = toolCallIds(msgs[i]);
|
|
542
|
+
if (!here.length) continue;
|
|
543
|
+
index = i;
|
|
544
|
+
ids.clear();
|
|
545
|
+
for (const id of here) ids.add(id);
|
|
546
|
+
}
|
|
547
|
+
return { index, ids, inFlight: index >= 0 && index > lastUser };
|
|
548
|
+
}
|
|
549
|
+
|
|
498
550
|
export function fileBoundStub(paths, n) {
|
|
499
551
|
const list = [...new Set((paths || []).filter(Boolean))].join(' ');
|
|
500
552
|
const mark = Number.isFinite(n) && n > 0 ? `[bound, ${n} chars]` : '[bound]';
|
|
@@ -508,6 +560,38 @@ export function toolResultStub(n, paths) {
|
|
|
508
560
|
return list ? `FILE ${list} ${mark}` : mark;
|
|
509
561
|
}
|
|
510
562
|
|
|
563
|
+
function toolCallArgLength(tc) {
|
|
564
|
+
const a = tc?.function?.arguments ?? tc?.arguments;
|
|
565
|
+
if (typeof a === 'string') return a.length;
|
|
566
|
+
if (a && typeof a === 'object') return JSON.stringify(a).length;
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Keep path/query/url/command so pairing still names the call; drop the
|
|
572
|
+
* fat payload (Write contents, Edit hunks, pasted Bash, tool JSON).
|
|
573
|
+
*/
|
|
574
|
+
export function slimToolCallArgs(tc, n) {
|
|
575
|
+
const args = parseArgs(tc?.function?.arguments ?? tc?.arguments);
|
|
576
|
+
const slim = {};
|
|
577
|
+
for (const k of PATH_KEYS) {
|
|
578
|
+
if (typeof args[k] === 'string' && args[k]) slim[k] = args[k];
|
|
579
|
+
}
|
|
580
|
+
if (typeof args.query === 'string') {
|
|
581
|
+
slim.query = args.query.length > 240 ? `${args.query.slice(0, 240)}…` : args.query;
|
|
582
|
+
}
|
|
583
|
+
if (typeof args.url === 'string') {
|
|
584
|
+
slim.url = args.url.length > 300 ? `${args.url.slice(0, 300)}…` : args.url;
|
|
585
|
+
}
|
|
586
|
+
if (typeof args.command === 'string') {
|
|
587
|
+
slim.command = args.command.length > 240 ? `${args.command.slice(0, 240)}…` : args.command;
|
|
588
|
+
}
|
|
589
|
+
slim._stub = `[bound, ${Number(n) || 0} chars]`;
|
|
590
|
+
const encoded = JSON.stringify(slim);
|
|
591
|
+
if (tc?.function) return { ...tc, function: { ...tc.function, arguments: encoded } };
|
|
592
|
+
return { ...tc, arguments: encoded };
|
|
593
|
+
}
|
|
594
|
+
|
|
511
595
|
function isStubText(content) {
|
|
512
596
|
if (typeof content === 'string') return /\[bound(?:, \d+ chars)?\]/.test(content);
|
|
513
597
|
if (Array.isArray(content)) return content.some((b) => isStubText(typeof b === 'string' ? b : b?.text ?? b?.content));
|
|
@@ -545,11 +629,21 @@ function resolveBoundPath(raw, cwd, boundAbs) {
|
|
|
545
629
|
* tail `budget` is set. Those two are what beat a WebSearch/Fetch/Bash
|
|
546
630
|
* storm: the 800-byte floor cannot move `cut` inside a tool chain
|
|
547
631
|
* (`isSeverable` is false), so the byte budget has to win by stubbing
|
|
548
|
-
* bodies
|
|
632
|
+
* bodies (and fat tool_call JSON) instead of orphaning a pairing.
|
|
633
|
+
*
|
|
634
|
+
* `keepTail` is the vote cutTranscript cannot cast on an un-severable
|
|
635
|
+
* chain: drop older complete tool_call + tool_result pairs (rewrite the
|
|
636
|
+
* assistant's tool_calls, do not leave orphans). Live 0.48.75: keep 16/8/6/2
|
|
637
|
+
* all cut at the same index and left ~728k after file-only stubs.
|
|
549
638
|
*
|
|
550
639
|
* `fromIndex` limits the rewrite to the forwarded tail so the spilled prefix
|
|
551
640
|
* that becomes the conversation corpus is unchanged. The last user ask is
|
|
552
|
-
* never rewritten.
|
|
641
|
+
* never rewritten. The current tool round — last assistant with tool_calls
|
|
642
|
+
* / tool_use and every matching role:tool / tool_result — is never rewritten
|
|
643
|
+
* when that assistant sits after the last user ask (this turn's eyes).
|
|
644
|
+
* Bodies under KEEP_TOOL_CHARS on that last round also stay, even when the
|
|
645
|
+
* ask follows the results, so a 461-byte Read is not replaced with
|
|
646
|
+
* `[bound, 461 chars]`.
|
|
553
647
|
*/
|
|
554
648
|
export function stubBoundFileResults(msgs, {
|
|
555
649
|
boundFiles,
|
|
@@ -558,16 +652,22 @@ export function stubBoundFileResults(msgs, {
|
|
|
558
652
|
fromIndex = 0,
|
|
559
653
|
// When the live tuner is below target, stub file-view results even if
|
|
560
654
|
// this turn has not yet recorded them in boundAbs (first-read bodies).
|
|
561
|
-
// Also stubs fat non-file tools (WebSearch / Fetch / Bash)
|
|
655
|
+
// Also stubs fat non-file tools (WebSearch / Fetch / Bash) and fat
|
|
656
|
+
// tool_call argument JSON.
|
|
562
657
|
aggressive = false,
|
|
563
658
|
// When the forwarded tail is over this many chars, stub older tool_result
|
|
564
|
-
// bodies (oldest first) until it fits. Pairing
|
|
659
|
+
// bodies and fat tool_call JSON (oldest first) until it fits. Pairing
|
|
660
|
+
// stays; the ask stays. If still over, drop older complete pairs.
|
|
565
661
|
budget = null,
|
|
662
|
+
// How many complete tool pairs to keep at the end of an un-severable
|
|
663
|
+
// assistant(tool_calls)+results chain. Ignored when the tail is severable.
|
|
664
|
+
keepTail = null,
|
|
566
665
|
fatChars = FAT_TOOL_CHARS,
|
|
567
666
|
} = {}) {
|
|
568
667
|
const absSet = boundAbs || boundAbsFromKeys(boundFiles);
|
|
569
668
|
const wantBudget = budget != null && Number.isFinite(Number(budget));
|
|
570
|
-
|
|
669
|
+
const wantKeep = keepTail != null && Number.isFinite(Number(keepTail));
|
|
670
|
+
if (!Array.isArray(msgs) || (!absSet.size && !aggressive && !wantBudget && !wantKeep)) {
|
|
571
671
|
return { messages: msgs, stubbed: 0, dropped: 0 };
|
|
572
672
|
}
|
|
573
673
|
|
|
@@ -613,24 +713,46 @@ export function stubBoundFileResults(msgs, {
|
|
|
613
713
|
}
|
|
614
714
|
}
|
|
615
715
|
|
|
616
|
-
const
|
|
716
|
+
const firstSpillable = firstSpillableIndex(msgs);
|
|
717
|
+
let lastUser = lastUserAskIndex(msgs, firstSpillable);
|
|
617
718
|
const fatLimit = Number.isFinite(Number(fatChars)) ? Number(fatChars) : FAT_TOOL_CHARS;
|
|
719
|
+
const keepFloor = KEEP_TOOL_CHARS;
|
|
720
|
+
const slimArgs = aggressive || wantBudget;
|
|
721
|
+
let round = currentToolRound(msgs, { lastUser });
|
|
618
722
|
|
|
619
723
|
const stubFor = (id, n) => {
|
|
620
724
|
const paths = idPaths.get(id);
|
|
621
725
|
return paths?.length ? fileBoundStub(paths, n) : toolResultStub(n);
|
|
622
726
|
};
|
|
623
727
|
|
|
624
|
-
const shouldStubBody = (id, n) => {
|
|
728
|
+
const shouldStubBody = (id, n, { overBudget = false } = {}) => {
|
|
625
729
|
if (!n || isStubText(typeof n === 'number' ? '' : n)) return false;
|
|
730
|
+
if (id && round.inFlight && round.ids.has(id)) return false;
|
|
731
|
+
if (n < keepFloor && (!overBudget || (id && round.ids.has(id)))) return false;
|
|
626
732
|
if (stubIds.has(id)) return true;
|
|
627
|
-
if (
|
|
733
|
+
if (slimArgs && n >= fatLimit) return true;
|
|
628
734
|
return false;
|
|
629
735
|
};
|
|
630
736
|
|
|
631
737
|
let stubbed = 0;
|
|
632
738
|
let dropped = 0;
|
|
633
|
-
let messages = msgs
|
|
739
|
+
let messages = msgs;
|
|
740
|
+
|
|
741
|
+
// keepTail votes here because cutTranscript cannot move inside the chain.
|
|
742
|
+
if (wantKeep) {
|
|
743
|
+
messages = trimUnseverablePairs(messages, {
|
|
744
|
+
fromIndex,
|
|
745
|
+
keepTail: Number(keepTail),
|
|
746
|
+
lastUser,
|
|
747
|
+
firstSpillable,
|
|
748
|
+
});
|
|
749
|
+
lastUser = lastUserAskIndex(messages, firstSpillableIndex(messages));
|
|
750
|
+
round = currentToolRound(messages, { lastUser });
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const skipSlimCalls = (i) => round.inFlight && i === round.index;
|
|
754
|
+
|
|
755
|
+
messages = messages.map((m, i) => {
|
|
634
756
|
if (i < fromIndex || !m || i === lastUser) return m;
|
|
635
757
|
if (m.role === 'tool') {
|
|
636
758
|
const n = toolContentLength(m.content);
|
|
@@ -639,9 +761,32 @@ export function stubBoundFileResults(msgs, {
|
|
|
639
761
|
stubbed += 1;
|
|
640
762
|
return { ...m, content: stubFor(m.tool_call_id, n) };
|
|
641
763
|
}
|
|
642
|
-
|
|
764
|
+
let next = m;
|
|
765
|
+
if (Array.isArray(m.tool_calls) && slimArgs && !skipSlimCalls(i)) {
|
|
766
|
+
let changed = false;
|
|
767
|
+
const calls = m.tool_calls.map((tc) => {
|
|
768
|
+
const n = toolCallArgLength(tc);
|
|
769
|
+
const raw = tc?.function?.arguments ?? tc?.arguments;
|
|
770
|
+
if (n < fatLimit || isStubText(typeof raw === 'string' ? raw : '')) return tc;
|
|
771
|
+
dropped += n;
|
|
772
|
+
stubbed += 1;
|
|
773
|
+
changed = true;
|
|
774
|
+
return slimToolCallArgs(tc, n);
|
|
775
|
+
});
|
|
776
|
+
if (changed) next = { ...next, tool_calls: calls };
|
|
777
|
+
}
|
|
778
|
+
if (next.function_call && slimArgs && !skipSlimCalls(i)) {
|
|
779
|
+
const n = toolCallArgLength(next.function_call);
|
|
780
|
+
const raw = next.function_call.arguments;
|
|
781
|
+
if (n >= fatLimit && !isStubText(typeof raw === 'string' ? raw : '')) {
|
|
782
|
+
dropped += n;
|
|
783
|
+
stubbed += 1;
|
|
784
|
+
next = { ...next, function_call: slimToolCallArgs(next.function_call, n) };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (!Array.isArray(next.content)) return next;
|
|
643
788
|
let changed = false;
|
|
644
|
-
const blocks =
|
|
789
|
+
const blocks = next.content.map((b) => {
|
|
645
790
|
if (b?.type !== 'tool_result') return b;
|
|
646
791
|
const n = toolContentLength(b.content);
|
|
647
792
|
if (!shouldStubBody(b.tool_use_id, n) || isStubText(b.content)) return b;
|
|
@@ -650,13 +795,14 @@ export function stubBoundFileResults(msgs, {
|
|
|
650
795
|
changed = true;
|
|
651
796
|
return { ...b, content: stubFor(b.tool_use_id, n) };
|
|
652
797
|
});
|
|
653
|
-
return changed ? { ...
|
|
798
|
+
return changed ? { ...next, content: blocks } : next;
|
|
654
799
|
});
|
|
655
800
|
|
|
656
801
|
// Byte budget wins inside a tool chain. cutTranscript cannot move tailStart
|
|
657
802
|
// past assistant(tool_calls) / role:tool (pairing 400s the provider), so a
|
|
658
|
-
//
|
|
659
|
-
// bodies first
|
|
803
|
+
// 300-result storm used to ride in at ~728k after file-only stubs. Stub
|
|
804
|
+
// bodies first, then fat tool_call JSON; if still over, drop older pairs.
|
|
805
|
+
// Never drop the ask. Never orphan a remaining tool_result.
|
|
660
806
|
if (wantBudget) {
|
|
661
807
|
const cap = Number(budget);
|
|
662
808
|
let used = sliceChars(messages, fromIndex);
|
|
@@ -669,7 +815,7 @@ export function stubBoundFileResults(msgs, {
|
|
|
669
815
|
if (m.role === 'tool') {
|
|
670
816
|
if (isStubText(m.content)) continue;
|
|
671
817
|
const n = toolContentLength(m.content);
|
|
672
|
-
if (!n) continue;
|
|
818
|
+
if (!shouldStubBody(m.tool_call_id, n, { overBudget: true })) continue;
|
|
673
819
|
const stub = stubFor(m.tool_call_id, n);
|
|
674
820
|
if (stub.length >= n) continue;
|
|
675
821
|
used = used - n + stub.length;
|
|
@@ -678,12 +824,31 @@ export function stubBoundFileResults(msgs, {
|
|
|
678
824
|
dropped += n;
|
|
679
825
|
continue;
|
|
680
826
|
}
|
|
681
|
-
if (
|
|
827
|
+
if (Array.isArray(m.tool_calls) && !skipSlimCalls(i)) {
|
|
828
|
+
let changed = false;
|
|
829
|
+
const calls = m.tool_calls.map((tc) => {
|
|
830
|
+
if (used <= cap) return tc;
|
|
831
|
+
const n = toolCallArgLength(tc);
|
|
832
|
+
const raw = tc?.function?.arguments ?? tc?.arguments;
|
|
833
|
+
if (n < fatLimit || isStubText(typeof raw === 'string' ? raw : '')) return tc;
|
|
834
|
+
const slim = slimToolCallArgs(tc, n);
|
|
835
|
+
const after = toolCallArgLength(slim);
|
|
836
|
+
if (after >= n) return tc;
|
|
837
|
+
used = used - n + after;
|
|
838
|
+
stubbed += 1;
|
|
839
|
+
dropped += n;
|
|
840
|
+
changed = true;
|
|
841
|
+
return slim;
|
|
842
|
+
});
|
|
843
|
+
if (changed) next[i] = { ...m, tool_calls: calls };
|
|
844
|
+
}
|
|
845
|
+
if (!Array.isArray((next[i] || m).content)) continue;
|
|
846
|
+
const cur = next[i];
|
|
682
847
|
let changed = false;
|
|
683
|
-
const blocks =
|
|
848
|
+
const blocks = cur.content.map((b) => {
|
|
684
849
|
if (b?.type !== 'tool_result' || used <= cap || isStubText(b.content)) return b;
|
|
685
850
|
const n = toolContentLength(b.content);
|
|
686
|
-
if (!n) return b;
|
|
851
|
+
if (!shouldStubBody(b.tool_use_id, n, { overBudget: true })) return b;
|
|
687
852
|
const stub = stubFor(b.tool_use_id, n);
|
|
688
853
|
if (stub.length >= n) return b;
|
|
689
854
|
used = used - n + stub.length;
|
|
@@ -692,9 +857,18 @@ export function stubBoundFileResults(msgs, {
|
|
|
692
857
|
changed = true;
|
|
693
858
|
return { ...b, content: stub };
|
|
694
859
|
});
|
|
695
|
-
if (changed) next[i] = { ...
|
|
860
|
+
if (changed) next[i] = { ...cur, content: blocks };
|
|
696
861
|
}
|
|
697
862
|
messages = next;
|
|
863
|
+
used = sliceChars(messages, fromIndex);
|
|
864
|
+
if (used > cap) {
|
|
865
|
+
messages = trimUnseverablePairs(messages, {
|
|
866
|
+
fromIndex,
|
|
867
|
+
keepTail: 1,
|
|
868
|
+
lastUser,
|
|
869
|
+
firstSpillable: firstSpillableIndex(messages),
|
|
870
|
+
});
|
|
871
|
+
}
|
|
698
872
|
}
|
|
699
873
|
}
|
|
700
874
|
|
|
@@ -744,15 +918,19 @@ export function messageChars(m) {
|
|
|
744
918
|
else if (Array.isArray(c)) {
|
|
745
919
|
for (const b of c) {
|
|
746
920
|
if (typeof b === 'string') n += b.length;
|
|
747
|
-
else
|
|
921
|
+
else if (b && typeof b === 'object') {
|
|
922
|
+
if (typeof b.text === 'string') n += b.text.length;
|
|
923
|
+
else if (typeof b.content === 'string') n += b.content.length;
|
|
924
|
+
else if (b.content != null) n += toolContentLength(b.content);
|
|
925
|
+
else n += JSON.stringify(b).length;
|
|
926
|
+
}
|
|
748
927
|
}
|
|
749
928
|
} else if (c && typeof c === 'object') n += JSON.stringify(c).length;
|
|
750
929
|
if (Array.isArray(m.tool_calls)) {
|
|
751
|
-
for (const tc of m.tool_calls)
|
|
752
|
-
const a = tc?.function?.arguments ?? tc?.arguments;
|
|
753
|
-
n += typeof a === 'string' ? a.length : (a ? JSON.stringify(a).length : 0);
|
|
754
|
-
}
|
|
930
|
+
for (const tc of m.tool_calls) n += toolCallArgLength(tc);
|
|
755
931
|
}
|
|
932
|
+
const legacy = m.function_call;
|
|
933
|
+
if (legacy) n += toolCallArgLength(legacy);
|
|
756
934
|
return n;
|
|
757
935
|
}
|
|
758
936
|
|
|
@@ -998,6 +1176,58 @@ function isSeverable(msgs, i, firstSpillable) {
|
|
|
998
1176
|
return msgs[i].role !== 'tool';
|
|
999
1177
|
}
|
|
1000
1178
|
|
|
1179
|
+
/**
|
|
1180
|
+
* On an un-severable assistant(tool_calls)+results chain, keepTail cannot
|
|
1181
|
+
* move `cut`. Drop older complete pairs (rewrite tool_calls, drop their
|
|
1182
|
+
* results) so keep 16 vs 2 actually differ and the byte budget can land.
|
|
1183
|
+
*/
|
|
1184
|
+
export function trimUnseverablePairs(msgs, {
|
|
1185
|
+
fromIndex = 0,
|
|
1186
|
+
keepTail = 2,
|
|
1187
|
+
lastUser = -1,
|
|
1188
|
+
firstSpillable = 0,
|
|
1189
|
+
} = {}) {
|
|
1190
|
+
if (!Array.isArray(msgs) || !msgs.length) return msgs;
|
|
1191
|
+
const keep = Math.max(1, Math.round(Number(keepTail) || 2));
|
|
1192
|
+
const end = lastUser > fromIndex ? lastUser : msgs.length;
|
|
1193
|
+
let asstIdx = -1;
|
|
1194
|
+
for (let i = fromIndex; i < end; i++) {
|
|
1195
|
+
if (Array.isArray(msgs[i]?.tool_calls) && msgs[i].tool_calls.length) {
|
|
1196
|
+
asstIdx = i;
|
|
1197
|
+
break;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (asstIdx < 0) return msgs;
|
|
1201
|
+
for (let i = asstIdx + 1; i < end; i++) {
|
|
1202
|
+
if (isSeverable(msgs, i, firstSpillable)) return msgs;
|
|
1203
|
+
}
|
|
1204
|
+
const calls = msgs[asstIdx].tool_calls;
|
|
1205
|
+
if (calls.length <= keep) return msgs;
|
|
1206
|
+
const keptCalls = calls.slice(-keep);
|
|
1207
|
+
const keptIds = new Set(keptCalls.map((c) => c.id).filter(Boolean));
|
|
1208
|
+
const out = [];
|
|
1209
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
1210
|
+
const m = msgs[i];
|
|
1211
|
+
if (i === asstIdx) {
|
|
1212
|
+
out.push({ ...m, tool_calls: keptCalls });
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (i > asstIdx && i !== lastUser && m?.role === 'tool' && m.tool_call_id && !keptIds.has(m.tool_call_id)) {
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
if (i > asstIdx && i !== lastUser && Array.isArray(m?.content)) {
|
|
1219
|
+
const blocks = m.content.filter((b) => b?.type !== 'tool_result' || keptIds.has(b.tool_use_id));
|
|
1220
|
+
if (blocks.length !== m.content.length) {
|
|
1221
|
+
if (!blocks.length && m.role !== 'assistant') continue;
|
|
1222
|
+
out.push({ ...m, content: blocks });
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
out.push(m);
|
|
1227
|
+
}
|
|
1228
|
+
return out;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1001
1231
|
/**
|
|
1002
1232
|
* Pick a severable cut: keep a recent tail, honour the byte budget, floor
|
|
1003
1233
|
* at minTurns of user/assistant, and never drop the last user ask.
|
|
@@ -1069,9 +1299,10 @@ function stubForCut(msgs, cut, opts) {
|
|
|
1069
1299
|
boundFiles: opts.boundFiles,
|
|
1070
1300
|
boundAbs: opts.boundAbs,
|
|
1071
1301
|
cwd: opts.cwd,
|
|
1072
|
-
fromIndex: cut,
|
|
1302
|
+
fromIndex: Math.max(0, cut),
|
|
1073
1303
|
aggressive: Boolean(opts.aggressive),
|
|
1074
1304
|
budget: opts.budget,
|
|
1305
|
+
keepTail: opts.keepTail,
|
|
1075
1306
|
});
|
|
1076
1307
|
}
|
|
1077
1308
|
|
|
@@ -1108,9 +1339,15 @@ export function applySpillCut(msgs, {
|
|
|
1108
1339
|
action: 'hold',
|
|
1109
1340
|
};
|
|
1110
1341
|
if (plan.cut <= plan.firstSpillable) {
|
|
1111
|
-
|
|
1342
|
+
// No severable index — still stub/trim the un-severable tail so a
|
|
1343
|
+
// 300-result storm is not forwarded at full size.
|
|
1344
|
+
const stubFrom = plan.firstSpillable >= 0 ? plan.firstSpillable : 0;
|
|
1345
|
+
const stubbed = stubForCut(msgs, stubFrom, {
|
|
1346
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1347
|
+
});
|
|
1348
|
+
const sentChars = sliceChars(stubbed.messages, stubFrom);
|
|
1112
1349
|
const corpus = Math.max(Number(corpusChars) || 0, sentChars);
|
|
1113
|
-
return { ...empty, sentChars, ratio: spillRatio(corpus, sentChars) };
|
|
1350
|
+
return { ...empty, stubbed, sentChars, ratio: spillRatio(corpus, sentChars) };
|
|
1114
1351
|
}
|
|
1115
1352
|
|
|
1116
1353
|
const measure = (cut, stubbed, knobsNow) => {
|
|
@@ -1126,7 +1363,9 @@ export function applySpillCut(msgs, {
|
|
|
1126
1363
|
};
|
|
1127
1364
|
};
|
|
1128
1365
|
|
|
1129
|
-
let stubbed = stubForCut(msgs, plan.cut, {
|
|
1366
|
+
let stubbed = stubForCut(msgs, plan.cut, {
|
|
1367
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1368
|
+
});
|
|
1130
1369
|
let stats = measure(plan.cut, stubbed, k);
|
|
1131
1370
|
let action = 'hold';
|
|
1132
1371
|
|
|
@@ -1142,7 +1381,9 @@ export function applySpillCut(msgs, {
|
|
|
1142
1381
|
if (decision.recut) {
|
|
1143
1382
|
plan = cutTranscript(msgs, k);
|
|
1144
1383
|
if (plan.cut > plan.firstSpillable) {
|
|
1145
|
-
stubbed = stubForCut(msgs, plan.cut, {
|
|
1384
|
+
stubbed = stubForCut(msgs, plan.cut, {
|
|
1385
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1386
|
+
});
|
|
1146
1387
|
stats = measure(plan.cut, stubbed, k);
|
|
1147
1388
|
}
|
|
1148
1389
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.77",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|