openzoo 0.48.74 → 0.48.76
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 +27 -6
- package/lib/spill.js +297 -27
- 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;
|
|
@@ -749,6 +761,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
749
761
|
mkdirSync(path.dirname(sayFile), { recursive: true });
|
|
750
762
|
} catch { sayFile = null; }
|
|
751
763
|
}
|
|
764
|
+
// Spill/adapt/classifier lines must use this channel: `log` is a no-op when
|
|
765
|
+
// `openzoo claude` starts us with silent:true, and printing them on stdout
|
|
766
|
+
// corrupts the Claude Code TTY. say() writes ~/.openzoo/proxy.log then.
|
|
752
767
|
const say = (...a) => {
|
|
753
768
|
const line = a.join(' ');
|
|
754
769
|
if (sayFile) { try { appendFileSync(sayFile, line + '\n'); return; } catch { /* fall through */ } }
|
|
@@ -1179,15 +1194,19 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1179
1194
|
// temporarily unavailable (timed out), so auto mode cannot determine
|
|
1180
1195
|
// the safety of WebSearch". Even staying on sonnet-5 is too slow
|
|
1181
1196
|
// (402 handshake behind a long Grok stream). Pin to a fast
|
|
1182
|
-
// non-reasoning catalog id and leave max_tokens
|
|
1183
|
-
// 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)
|
|
1184
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).
|
|
1185
1202
|
let ids = [];
|
|
1186
1203
|
try { ids = await zooModelIds(); } catch { /* catalog miss: still skip the floor on a tiny classify */ }
|
|
1187
1204
|
const policy = rewriteChatModel(parsed, ids, { bodyLen: bodyBuf.length });
|
|
1188
1205
|
parsed = policy.parsed;
|
|
1189
1206
|
if (policy.tiny) {
|
|
1190
|
-
|
|
1207
|
+
// `openzoo claude` starts us silent � `log` is a no-op then. say()
|
|
1208
|
+
// is the proxy.log channel and never the Claude Code TTY.
|
|
1209
|
+
say(`classifier tiny max_tokens=${Number(parsed?.max_tokens)} "${policy.from}" -> ${policy.to} (no reasoning floor)`);
|
|
1191
1210
|
} else {
|
|
1192
1211
|
// SAY WHETHER THE OVERRIDE IS ACTUALLY SET, and name it.
|
|
1193
1212
|
//
|
|
@@ -1261,7 +1280,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1261
1280
|
const tiny = policy.tiny
|
|
1262
1281
|
|| (bodyBuf.length < BIND_MIN_CHARS
|
|
1263
1282
|
&& Number(parsed?.max_tokens ?? 0) > 0
|
|
1264
|
-
&& Number(parsed?.max_tokens) <=
|
|
1283
|
+
&& Number(parsed?.max_tokens) <= CLASSIFY_MAX_TOKENS);
|
|
1265
1284
|
const briefed = tiny ? null : injectBrief(parsed, selfUrl);
|
|
1266
1285
|
if (briefed) parsed = briefed;
|
|
1267
1286
|
// SYSTEM MESSAGES BELONG AT THE FRONT, OR GOOGLE 400s.
|
|
@@ -1372,7 +1391,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
1372
1391
|
try {
|
|
1373
1392
|
let cached = null;
|
|
1374
1393
|
try {
|
|
1375
|
-
|
|
1394
|
+
// Spill/adapt diagnostics (adapt, file-stub, sending N/M) must hit
|
|
1395
|
+
// ~/.openzoo/proxy.log when we are silent. `log` is a no-op then.
|
|
1396
|
+
cached = await maybeCacheCorpus(req, bodyBuf, say, spill);
|
|
1376
1397
|
} catch (err) {
|
|
1377
1398
|
log(`context cache skipped for this call: ${err.message}`);
|
|
1378
1399
|
}
|
package/lib/spill.js
CHANGED
|
@@ -492,9 +492,58 @@ export function looksLikeFileView(command) {
|
|
|
492
492
|
return sawView;
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
-
|
|
495
|
+
/** Tool result larger than this is "fat" — stub it in the forwarded tail. */
|
|
496
|
+
export const FAT_TOOL_CHARS = 400;
|
|
497
|
+
|
|
498
|
+
export function fileBoundStub(paths, n) {
|
|
499
|
+
const list = [...new Set((paths || []).filter(Boolean))].join(' ');
|
|
500
|
+
const mark = Number.isFinite(n) && n > 0 ? `[bound, ${n} chars]` : '[bound]';
|
|
501
|
+
return list ? `FILE ${list} ${mark}` : `FILE ${mark}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Generic stub for WebSearch / Fetch / Bash / any fat tool_result. */
|
|
505
|
+
export function toolResultStub(n, paths) {
|
|
496
506
|
const list = [...new Set((paths || []).filter(Boolean))].join(' ');
|
|
497
|
-
|
|
507
|
+
const mark = `[bound, ${Number(n) || 0} chars]`;
|
|
508
|
+
return list ? `FILE ${list} ${mark}` : mark;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function toolCallArgLength(tc) {
|
|
512
|
+
const a = tc?.function?.arguments ?? tc?.arguments;
|
|
513
|
+
if (typeof a === 'string') return a.length;
|
|
514
|
+
if (a && typeof a === 'object') return JSON.stringify(a).length;
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Keep path/query/url/command so pairing still names the call; drop the
|
|
520
|
+
* fat payload (Write contents, Edit hunks, pasted Bash, tool JSON).
|
|
521
|
+
*/
|
|
522
|
+
export function slimToolCallArgs(tc, n) {
|
|
523
|
+
const args = parseArgs(tc?.function?.arguments ?? tc?.arguments);
|
|
524
|
+
const slim = {};
|
|
525
|
+
for (const k of PATH_KEYS) {
|
|
526
|
+
if (typeof args[k] === 'string' && args[k]) slim[k] = args[k];
|
|
527
|
+
}
|
|
528
|
+
if (typeof args.query === 'string') {
|
|
529
|
+
slim.query = args.query.length > 240 ? `${args.query.slice(0, 240)}…` : args.query;
|
|
530
|
+
}
|
|
531
|
+
if (typeof args.url === 'string') {
|
|
532
|
+
slim.url = args.url.length > 300 ? `${args.url.slice(0, 300)}…` : args.url;
|
|
533
|
+
}
|
|
534
|
+
if (typeof args.command === 'string') {
|
|
535
|
+
slim.command = args.command.length > 240 ? `${args.command.slice(0, 240)}…` : args.command;
|
|
536
|
+
}
|
|
537
|
+
slim._stub = `[bound, ${Number(n) || 0} chars]`;
|
|
538
|
+
const encoded = JSON.stringify(slim);
|
|
539
|
+
if (tc?.function) return { ...tc, function: { ...tc.function, arguments: encoded } };
|
|
540
|
+
return { ...tc, arguments: encoded };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function isStubText(content) {
|
|
544
|
+
if (typeof content === 'string') return /\[bound(?:, \d+ chars)?\]/.test(content);
|
|
545
|
+
if (Array.isArray(content)) return content.some((b) => isStubText(typeof b === 'string' ? b : b?.text ?? b?.content));
|
|
546
|
+
return false;
|
|
498
547
|
}
|
|
499
548
|
|
|
500
549
|
function toolContentLength(content) {
|
|
@@ -524,10 +573,20 @@ function resolveBoundPath(raw, cwd, boundAbs) {
|
|
|
524
573
|
* (live: 5MB filebind, lastSend 13/107, savingX 1.22 instead of ~7x).
|
|
525
574
|
*
|
|
526
575
|
* Cheap rewrite — no disk I/O. First-read results (not yet in boundAbs) and
|
|
527
|
-
* non-file tool output
|
|
576
|
+
* non-file tool output stay verbatim UNLESS `aggressive` / `stubMore` or a
|
|
577
|
+
* tail `budget` is set. Those two are what beat a WebSearch/Fetch/Bash
|
|
578
|
+
* storm: the 800-byte floor cannot move `cut` inside a tool chain
|
|
579
|
+
* (`isSeverable` is false), so the byte budget has to win by stubbing
|
|
580
|
+
* bodies (and fat tool_call JSON) instead of orphaning a pairing.
|
|
581
|
+
*
|
|
582
|
+
* `keepTail` is the vote cutTranscript cannot cast on an un-severable
|
|
583
|
+
* chain: drop older complete tool_call + tool_result pairs (rewrite the
|
|
584
|
+
* assistant's tool_calls, do not leave orphans). Live 0.48.75: keep 16/8/6/2
|
|
585
|
+
* all cut at the same index and left ~728k after file-only stubs.
|
|
528
586
|
*
|
|
529
587
|
* `fromIndex` limits the rewrite to the forwarded tail so the spilled prefix
|
|
530
|
-
* that becomes the conversation corpus is unchanged.
|
|
588
|
+
* that becomes the conversation corpus is unchanged. The last user ask is
|
|
589
|
+
* never rewritten.
|
|
531
590
|
*/
|
|
532
591
|
export function stubBoundFileResults(msgs, {
|
|
533
592
|
boundFiles,
|
|
@@ -536,10 +595,22 @@ export function stubBoundFileResults(msgs, {
|
|
|
536
595
|
fromIndex = 0,
|
|
537
596
|
// When the live tuner is below target, stub file-view results even if
|
|
538
597
|
// this turn has not yet recorded them in boundAbs (first-read bodies).
|
|
598
|
+
// Also stubs fat non-file tools (WebSearch / Fetch / Bash) and fat
|
|
599
|
+
// tool_call argument JSON.
|
|
539
600
|
aggressive = false,
|
|
601
|
+
// When the forwarded tail is over this many chars, stub older tool_result
|
|
602
|
+
// bodies and fat tool_call JSON (oldest first) until it fits. Pairing
|
|
603
|
+
// stays; the ask stays. If still over, drop older complete pairs.
|
|
604
|
+
budget = null,
|
|
605
|
+
// How many complete tool pairs to keep at the end of an un-severable
|
|
606
|
+
// assistant(tool_calls)+results chain. Ignored when the tail is severable.
|
|
607
|
+
keepTail = null,
|
|
608
|
+
fatChars = FAT_TOOL_CHARS,
|
|
540
609
|
} = {}) {
|
|
541
610
|
const absSet = boundAbs || boundAbsFromKeys(boundFiles);
|
|
542
|
-
|
|
611
|
+
const wantBudget = budget != null && Number.isFinite(Number(budget));
|
|
612
|
+
const wantKeep = keepTail != null && Number.isFinite(Number(keepTail));
|
|
613
|
+
if (!Array.isArray(msgs) || (!absSet.size && !aggressive && !wantBudget && !wantKeep)) {
|
|
543
614
|
return { messages: msgs, stubbed: 0, dropped: 0 };
|
|
544
615
|
}
|
|
545
616
|
|
|
@@ -585,32 +656,158 @@ export function stubBoundFileResults(msgs, {
|
|
|
585
656
|
}
|
|
586
657
|
}
|
|
587
658
|
|
|
588
|
-
|
|
659
|
+
const firstSpillable = firstSpillableIndex(msgs);
|
|
660
|
+
let lastUser = lastUserAskIndex(msgs, firstSpillable);
|
|
661
|
+
const fatLimit = Number.isFinite(Number(fatChars)) ? Number(fatChars) : FAT_TOOL_CHARS;
|
|
662
|
+
const slimArgs = aggressive || wantBudget;
|
|
663
|
+
|
|
664
|
+
const stubFor = (id, n) => {
|
|
665
|
+
const paths = idPaths.get(id);
|
|
666
|
+
return paths?.length ? fileBoundStub(paths, n) : toolResultStub(n);
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const shouldStubBody = (id, n) => {
|
|
670
|
+
if (!n || isStubText(typeof n === 'number' ? '' : n)) return false;
|
|
671
|
+
if (stubIds.has(id)) return true;
|
|
672
|
+
if (slimArgs && n >= fatLimit) return true;
|
|
673
|
+
return false;
|
|
674
|
+
};
|
|
589
675
|
|
|
590
676
|
let stubbed = 0;
|
|
591
677
|
let dropped = 0;
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
678
|
+
let messages = msgs;
|
|
679
|
+
|
|
680
|
+
// keepTail votes here because cutTranscript cannot move inside the chain.
|
|
681
|
+
if (wantKeep) {
|
|
682
|
+
messages = trimUnseverablePairs(messages, {
|
|
683
|
+
fromIndex,
|
|
684
|
+
keepTail: Number(keepTail),
|
|
685
|
+
lastUser,
|
|
686
|
+
firstSpillable,
|
|
687
|
+
});
|
|
688
|
+
lastUser = lastUserAskIndex(messages, firstSpillableIndex(messages));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
messages = messages.map((m, i) => {
|
|
692
|
+
if (i < fromIndex || !m || i === lastUser) return m;
|
|
693
|
+
if (m.role === 'tool') {
|
|
595
694
|
const n = toolContentLength(m.content);
|
|
596
|
-
if (!n) return m;
|
|
695
|
+
if (!shouldStubBody(m.tool_call_id, n) || isStubText(m.content)) return m;
|
|
597
696
|
dropped += n;
|
|
598
697
|
stubbed += 1;
|
|
599
|
-
return { ...m, content:
|
|
698
|
+
return { ...m, content: stubFor(m.tool_call_id, n) };
|
|
699
|
+
}
|
|
700
|
+
let next = m;
|
|
701
|
+
if (Array.isArray(m.tool_calls) && slimArgs) {
|
|
702
|
+
let changed = false;
|
|
703
|
+
const calls = m.tool_calls.map((tc) => {
|
|
704
|
+
const n = toolCallArgLength(tc);
|
|
705
|
+
const raw = tc?.function?.arguments ?? tc?.arguments;
|
|
706
|
+
if (n < fatLimit || isStubText(typeof raw === 'string' ? raw : '')) return tc;
|
|
707
|
+
dropped += n;
|
|
708
|
+
stubbed += 1;
|
|
709
|
+
changed = true;
|
|
710
|
+
return slimToolCallArgs(tc, n);
|
|
711
|
+
});
|
|
712
|
+
if (changed) next = { ...next, tool_calls: calls };
|
|
713
|
+
}
|
|
714
|
+
if (next.function_call && slimArgs) {
|
|
715
|
+
const n = toolCallArgLength(next.function_call);
|
|
716
|
+
const raw = next.function_call.arguments;
|
|
717
|
+
if (n >= fatLimit && !isStubText(typeof raw === 'string' ? raw : '')) {
|
|
718
|
+
dropped += n;
|
|
719
|
+
stubbed += 1;
|
|
720
|
+
next = { ...next, function_call: slimToolCallArgs(next.function_call, n) };
|
|
721
|
+
}
|
|
600
722
|
}
|
|
601
|
-
if (!Array.isArray(
|
|
723
|
+
if (!Array.isArray(next.content)) return next;
|
|
602
724
|
let changed = false;
|
|
603
|
-
const blocks =
|
|
604
|
-
if (b?.type !== 'tool_result'
|
|
725
|
+
const blocks = next.content.map((b) => {
|
|
726
|
+
if (b?.type !== 'tool_result') return b;
|
|
605
727
|
const n = toolContentLength(b.content);
|
|
606
|
-
if (!n) return b;
|
|
728
|
+
if (!shouldStubBody(b.tool_use_id, n) || isStubText(b.content)) return b;
|
|
607
729
|
dropped += n;
|
|
608
730
|
stubbed += 1;
|
|
609
731
|
changed = true;
|
|
610
|
-
return { ...b, content:
|
|
732
|
+
return { ...b, content: stubFor(b.tool_use_id, n) };
|
|
611
733
|
});
|
|
612
|
-
return changed ? { ...
|
|
734
|
+
return changed ? { ...next, content: blocks } : next;
|
|
613
735
|
});
|
|
736
|
+
|
|
737
|
+
// Byte budget wins inside a tool chain. cutTranscript cannot move tailStart
|
|
738
|
+
// past assistant(tool_calls) / role:tool (pairing 400s the provider), so a
|
|
739
|
+
// 300-result storm used to ride in at ~728k after file-only stubs. Stub
|
|
740
|
+
// bodies first, then fat tool_call JSON; if still over, drop older pairs.
|
|
741
|
+
// Never drop the ask. Never orphan a remaining tool_result.
|
|
742
|
+
if (wantBudget) {
|
|
743
|
+
const cap = Number(budget);
|
|
744
|
+
let used = sliceChars(messages, fromIndex);
|
|
745
|
+
if (used > cap) {
|
|
746
|
+
const next = messages.slice();
|
|
747
|
+
for (let i = fromIndex; i < next.length && used > cap; i++) {
|
|
748
|
+
if (i === lastUser) continue;
|
|
749
|
+
const m = next[i];
|
|
750
|
+
if (!m) continue;
|
|
751
|
+
if (m.role === 'tool') {
|
|
752
|
+
if (isStubText(m.content)) continue;
|
|
753
|
+
const n = toolContentLength(m.content);
|
|
754
|
+
if (!n) continue;
|
|
755
|
+
const stub = stubFor(m.tool_call_id, n);
|
|
756
|
+
if (stub.length >= n) continue;
|
|
757
|
+
used = used - n + stub.length;
|
|
758
|
+
next[i] = { ...m, content: stub };
|
|
759
|
+
stubbed += 1;
|
|
760
|
+
dropped += n;
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (Array.isArray(m.tool_calls)) {
|
|
764
|
+
let changed = false;
|
|
765
|
+
const calls = m.tool_calls.map((tc) => {
|
|
766
|
+
if (used <= cap) return tc;
|
|
767
|
+
const n = toolCallArgLength(tc);
|
|
768
|
+
const raw = tc?.function?.arguments ?? tc?.arguments;
|
|
769
|
+
if (n < fatLimit || isStubText(typeof raw === 'string' ? raw : '')) return tc;
|
|
770
|
+
const slim = slimToolCallArgs(tc, n);
|
|
771
|
+
const after = toolCallArgLength(slim);
|
|
772
|
+
if (after >= n) return tc;
|
|
773
|
+
used = used - n + after;
|
|
774
|
+
stubbed += 1;
|
|
775
|
+
dropped += n;
|
|
776
|
+
changed = true;
|
|
777
|
+
return slim;
|
|
778
|
+
});
|
|
779
|
+
if (changed) next[i] = { ...m, tool_calls: calls };
|
|
780
|
+
}
|
|
781
|
+
if (!Array.isArray((next[i] || m).content)) continue;
|
|
782
|
+
const cur = next[i];
|
|
783
|
+
let changed = false;
|
|
784
|
+
const blocks = cur.content.map((b) => {
|
|
785
|
+
if (b?.type !== 'tool_result' || used <= cap || isStubText(b.content)) return b;
|
|
786
|
+
const n = toolContentLength(b.content);
|
|
787
|
+
if (!n) return b;
|
|
788
|
+
const stub = stubFor(b.tool_use_id, n);
|
|
789
|
+
if (stub.length >= n) return b;
|
|
790
|
+
used = used - n + stub.length;
|
|
791
|
+
stubbed += 1;
|
|
792
|
+
dropped += n;
|
|
793
|
+
changed = true;
|
|
794
|
+
return { ...b, content: stub };
|
|
795
|
+
});
|
|
796
|
+
if (changed) next[i] = { ...cur, content: blocks };
|
|
797
|
+
}
|
|
798
|
+
messages = next;
|
|
799
|
+
used = sliceChars(messages, fromIndex);
|
|
800
|
+
if (used > cap) {
|
|
801
|
+
messages = trimUnseverablePairs(messages, {
|
|
802
|
+
fromIndex,
|
|
803
|
+
keepTail: 1,
|
|
804
|
+
lastUser,
|
|
805
|
+
firstSpillable: firstSpillableIndex(messages),
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
614
811
|
return { messages, stubbed, dropped };
|
|
615
812
|
}
|
|
616
813
|
|
|
@@ -657,15 +854,19 @@ export function messageChars(m) {
|
|
|
657
854
|
else if (Array.isArray(c)) {
|
|
658
855
|
for (const b of c) {
|
|
659
856
|
if (typeof b === 'string') n += b.length;
|
|
660
|
-
else
|
|
857
|
+
else if (b && typeof b === 'object') {
|
|
858
|
+
if (typeof b.text === 'string') n += b.text.length;
|
|
859
|
+
else if (typeof b.content === 'string') n += b.content.length;
|
|
860
|
+
else if (b.content != null) n += toolContentLength(b.content);
|
|
861
|
+
else n += JSON.stringify(b).length;
|
|
862
|
+
}
|
|
661
863
|
}
|
|
662
864
|
} else if (c && typeof c === 'object') n += JSON.stringify(c).length;
|
|
663
865
|
if (Array.isArray(m.tool_calls)) {
|
|
664
|
-
for (const tc of m.tool_calls)
|
|
665
|
-
const a = tc?.function?.arguments ?? tc?.arguments;
|
|
666
|
-
n += typeof a === 'string' ? a.length : (a ? JSON.stringify(a).length : 0);
|
|
667
|
-
}
|
|
866
|
+
for (const tc of m.tool_calls) n += toolCallArgLength(tc);
|
|
668
867
|
}
|
|
868
|
+
const legacy = m.function_call;
|
|
869
|
+
if (legacy) n += toolCallArgLength(legacy);
|
|
669
870
|
return n;
|
|
670
871
|
}
|
|
671
872
|
|
|
@@ -911,6 +1112,58 @@ function isSeverable(msgs, i, firstSpillable) {
|
|
|
911
1112
|
return msgs[i].role !== 'tool';
|
|
912
1113
|
}
|
|
913
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* On an un-severable assistant(tool_calls)+results chain, keepTail cannot
|
|
1117
|
+
* move `cut`. Drop older complete pairs (rewrite tool_calls, drop their
|
|
1118
|
+
* results) so keep 16 vs 2 actually differ and the byte budget can land.
|
|
1119
|
+
*/
|
|
1120
|
+
export function trimUnseverablePairs(msgs, {
|
|
1121
|
+
fromIndex = 0,
|
|
1122
|
+
keepTail = 2,
|
|
1123
|
+
lastUser = -1,
|
|
1124
|
+
firstSpillable = 0,
|
|
1125
|
+
} = {}) {
|
|
1126
|
+
if (!Array.isArray(msgs) || !msgs.length) return msgs;
|
|
1127
|
+
const keep = Math.max(1, Math.round(Number(keepTail) || 2));
|
|
1128
|
+
const end = lastUser > fromIndex ? lastUser : msgs.length;
|
|
1129
|
+
let asstIdx = -1;
|
|
1130
|
+
for (let i = fromIndex; i < end; i++) {
|
|
1131
|
+
if (Array.isArray(msgs[i]?.tool_calls) && msgs[i].tool_calls.length) {
|
|
1132
|
+
asstIdx = i;
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
if (asstIdx < 0) return msgs;
|
|
1137
|
+
for (let i = asstIdx + 1; i < end; i++) {
|
|
1138
|
+
if (isSeverable(msgs, i, firstSpillable)) return msgs;
|
|
1139
|
+
}
|
|
1140
|
+
const calls = msgs[asstIdx].tool_calls;
|
|
1141
|
+
if (calls.length <= keep) return msgs;
|
|
1142
|
+
const keptCalls = calls.slice(-keep);
|
|
1143
|
+
const keptIds = new Set(keptCalls.map((c) => c.id).filter(Boolean));
|
|
1144
|
+
const out = [];
|
|
1145
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
1146
|
+
const m = msgs[i];
|
|
1147
|
+
if (i === asstIdx) {
|
|
1148
|
+
out.push({ ...m, tool_calls: keptCalls });
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
if (i > asstIdx && i !== lastUser && m?.role === 'tool' && m.tool_call_id && !keptIds.has(m.tool_call_id)) {
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
if (i > asstIdx && i !== lastUser && Array.isArray(m?.content)) {
|
|
1155
|
+
const blocks = m.content.filter((b) => b?.type !== 'tool_result' || keptIds.has(b.tool_use_id));
|
|
1156
|
+
if (blocks.length !== m.content.length) {
|
|
1157
|
+
if (!blocks.length && m.role !== 'assistant') continue;
|
|
1158
|
+
out.push({ ...m, content: blocks });
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
out.push(m);
|
|
1163
|
+
}
|
|
1164
|
+
return out;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
914
1167
|
/**
|
|
915
1168
|
* Pick a severable cut: keep a recent tail, honour the byte budget, floor
|
|
916
1169
|
* at minTurns of user/assistant, and never drop the last user ask.
|
|
@@ -940,6 +1193,11 @@ export function cutTranscript(msgs, knobs = {}) {
|
|
|
940
1193
|
return { cut: -1, firstSpillable, lastUser: lastUserAskIndex(msgs, firstSpillable), knobs: k };
|
|
941
1194
|
}
|
|
942
1195
|
|
|
1196
|
+
// Only moves the cut at a severable index. A current-turn tool storm
|
|
1197
|
+
// (assistant(tool_calls) + N tool results + user ask) has no severable
|
|
1198
|
+
// index inside the chain, so this walk is a no-op — the byte budget is
|
|
1199
|
+
// applied by stubbing bodies in stubBoundFileResults, not by orphaning
|
|
1200
|
+
// a tool_result.
|
|
943
1201
|
let tailStart = cut;
|
|
944
1202
|
{
|
|
945
1203
|
let used = 0;
|
|
@@ -977,8 +1235,10 @@ function stubForCut(msgs, cut, opts) {
|
|
|
977
1235
|
boundFiles: opts.boundFiles,
|
|
978
1236
|
boundAbs: opts.boundAbs,
|
|
979
1237
|
cwd: opts.cwd,
|
|
980
|
-
fromIndex: cut,
|
|
1238
|
+
fromIndex: Math.max(0, cut),
|
|
981
1239
|
aggressive: Boolean(opts.aggressive),
|
|
1240
|
+
budget: opts.budget,
|
|
1241
|
+
keepTail: opts.keepTail,
|
|
982
1242
|
});
|
|
983
1243
|
}
|
|
984
1244
|
|
|
@@ -1015,9 +1275,15 @@ export function applySpillCut(msgs, {
|
|
|
1015
1275
|
action: 'hold',
|
|
1016
1276
|
};
|
|
1017
1277
|
if (plan.cut <= plan.firstSpillable) {
|
|
1018
|
-
|
|
1278
|
+
// No severable index — still stub/trim the un-severable tail so a
|
|
1279
|
+
// 300-result storm is not forwarded at full size.
|
|
1280
|
+
const stubFrom = plan.firstSpillable >= 0 ? plan.firstSpillable : 0;
|
|
1281
|
+
const stubbed = stubForCut(msgs, stubFrom, {
|
|
1282
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1283
|
+
});
|
|
1284
|
+
const sentChars = sliceChars(stubbed.messages, stubFrom);
|
|
1019
1285
|
const corpus = Math.max(Number(corpusChars) || 0, sentChars);
|
|
1020
|
-
return { ...empty, sentChars, ratio: spillRatio(corpus, sentChars) };
|
|
1286
|
+
return { ...empty, stubbed, sentChars, ratio: spillRatio(corpus, sentChars) };
|
|
1021
1287
|
}
|
|
1022
1288
|
|
|
1023
1289
|
const measure = (cut, stubbed, knobsNow) => {
|
|
@@ -1033,7 +1299,9 @@ export function applySpillCut(msgs, {
|
|
|
1033
1299
|
};
|
|
1034
1300
|
};
|
|
1035
1301
|
|
|
1036
|
-
let stubbed = stubForCut(msgs, plan.cut, {
|
|
1302
|
+
let stubbed = stubForCut(msgs, plan.cut, {
|
|
1303
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1304
|
+
});
|
|
1037
1305
|
let stats = measure(plan.cut, stubbed, k);
|
|
1038
1306
|
let action = 'hold';
|
|
1039
1307
|
|
|
@@ -1049,7 +1317,9 @@ export function applySpillCut(msgs, {
|
|
|
1049
1317
|
if (decision.recut) {
|
|
1050
1318
|
plan = cutTranscript(msgs, k);
|
|
1051
1319
|
if (plan.cut > plan.firstSpillable) {
|
|
1052
|
-
stubbed = stubForCut(msgs, plan.cut, {
|
|
1320
|
+
stubbed = stubForCut(msgs, plan.cut, {
|
|
1321
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1322
|
+
});
|
|
1053
1323
|
stats = measure(plan.cut, stubbed, k);
|
|
1054
1324
|
}
|
|
1055
1325
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.76",
|
|
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",
|