clearotron 0.4.1-beta.2 → 0.4.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // answer-parts.mjs — a tool answer longer than the engine carries whole, served in parts.
5
+ //
6
+ // Codex shows a model at most 10,000 tokens of the text a tool call puts in front of it, counting 4 bytes a
7
+ // token, and puts "…N tokens truncated…" where the middle was. That text is the tool's answer itself, or,
8
+ // for the GPT models that call tools only from code (all of 5.6 and 6.x), what that code prints, and code
9
+ // that prints an answer as a quoted string adds a byte for each quote and line break. Measured in testing,
10
+ // 2026-10-11, on codex 0.162.0 with gpt-6.1-sol and on codex 0.156.1 with gpt-5.6-sol: a model that
11
+ // printed a 42,560-byte answer to read it saw its start and end only. Register searches, research answers,
12
+ // fetched pages and case-law results all run past that size on real matters. So when a server is given an
13
+ // answer size (CLEAROTRON_TOOL_ANSWER_BYTES, set for codex in engine/mcp/codex-config.mjs), an answer
14
+ // longer than that, counted as a quoted string, is split into parts, each with a banner naming the next
15
+ // call. The tool runs ONCE: the whole answer is held in the server's process and a later part is read from
16
+ // it, so nothing is searched, fetched or written again. With no size given, nothing here runs and every
17
+ // answer is served as it always was.
18
+ //
19
+ // Two places use it, because every tool answer the engine reads passes through one of them: serve() in
20
+ // driver/engine/mcp/stdio-server.mjs, for every local server, and the OAuth bridge, for the case-law servers.
21
+
22
+ /** The argument a later part is asked for by. No tool uses this name for anything else. */
23
+ export const ANSWER_PART_ARG = "answer_part";
24
+
25
+ /** Room left in each part for its banner; a tool's name is counted on top. */
26
+ const BANNER_RESERVE = 400;
27
+
28
+ /** The answer size this server was given, in bytes, or 0 when none was. Never under 8 KB. */
29
+ export function answerBudget(env = process.env) {
30
+ const n = Number(env.CLEAROTRON_TOOL_ANSWER_BYTES);
31
+ return n > 0 ? Math.max(8192, Math.floor(n)) : 0;
32
+ }
33
+
34
+ /** What a character costs once the text is a quoted string: its UTF-8 bytes, or its escape. PURE. */
35
+ function quotedCost(code) {
36
+ if (code === 0x22 || code === 0x5c || code === 0x0a || code === 0x0d || code === 0x09 || code === 0x08 || code === 0x0c) return 2;
37
+ if (code < 0x20) return 6;
38
+ return code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
39
+ }
40
+
41
+ /** The size of text as a quoted string, in bytes, quotes not counted: never less than its own bytes. PURE. */
42
+ export function quotedBytes(text) {
43
+ let n = 0;
44
+ for (const ch of String(text)) n += quotedCost(ch.codePointAt(0));
45
+ return n;
46
+ }
47
+
48
+ /**
49
+ * Split text into consecutive pieces of at most `budget` bytes, counted as a quoted string. PURE. A piece
50
+ * ends after its last line break when it holds one, so a line is cut only when it is longer than a piece;
51
+ * every cut falls between characters. Joining the pieces with nothing between them gives the text back.
52
+ */
53
+ export function splitAnswer(text, budget) {
54
+ const chars = Array.from(String(text));
55
+ const pieces = [];
56
+ let start = 0;
57
+ while (start < chars.length) {
58
+ let cost = 0, end = start, lastBreak = -1;
59
+ while (end < chars.length) {
60
+ const c = quotedCost(chars[end].codePointAt(0));
61
+ if (cost + c > budget && end > start) break;
62
+ cost += c;
63
+ if (chars[end] === "\n") lastBreak = end;
64
+ end++;
65
+ }
66
+ if (end < chars.length && lastBreak >= start) end = lastBreak + 1;
67
+ pieces.push(chars.slice(start, end).join(""));
68
+ start = end;
69
+ }
70
+ return pieces.length ? pieces : [""];
71
+ }
72
+
73
+ /** The pieces of an answer too long for one response; one piece when it fits. PURE. */
74
+ export function answerPieces(text, budget, tool) {
75
+ if (!budget || quotedBytes(text) <= budget) return [String(text)];
76
+ return splitAnswer(text, Math.max(1024, budget - BANNER_RESERVE - 2 * quotedBytes(String(tool))));
77
+ }
78
+
79
+ /** Part `part` of `pieces`, between the banners that say what it is and how to read the next one. PURE. */
80
+ export function servedPart(tool, pieces, part) {
81
+ const total = pieces.length;
82
+ const p = Math.min(Math.max(1, Math.floor(Number(part)) || 1), total);
83
+ const head = `[${tool} answer part ${p}/${total} — this answer exceeds one tool response, so it is served in ${total} parts.`
84
+ + ` It is COMPLETE only once ALL ${total} parts are read — never reason from a partial answer.]`;
85
+ const tail = p < total
86
+ ? `[end of part ${p}/${total} — call ${tool} again with the same arguments plus {"${ANSWER_PART_ARG}": ${p + 1}} for the next part]`
87
+ : `[end of part ${total}/${total} — the answer is complete once parts 1..${total} are all read]`;
88
+ return `${head}\n${pieces[p - 1]}\n${tail}`;
89
+ }
90
+
91
+ /** What a part request is told when this process no longer holds that answer. PURE. */
92
+ export function partNotHeld(tool, part) {
93
+ return `${tool}: no answer for these arguments is held, so part ${part} cannot be served — `
94
+ + `call ${tool} again without "${ANSWER_PART_ARG}" for the whole answer.`;
95
+ }
96
+
97
+ /** The arguments of a call without the part it asks for, and that part (null when it asks for none). PURE. */
98
+ export function takePart(args) {
99
+ if (!args || typeof args !== "object" || !(ANSWER_PART_ARG in args)) return { args: args || {}, part: null };
100
+ const { [ANSWER_PART_ARG]: asked, ...rest } = args;
101
+ const part = Math.floor(Number(asked));
102
+ return { args: rest, part: part >= 1 ? part : null };
103
+ }
104
+
105
+ const canonical = (v) => (Array.isArray(v) ? v.map(canonical)
106
+ : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])])) : v);
107
+
108
+ /** The answers this process holds, by tool and arguments; the oldest goes once `limit` are held. */
109
+ export function heldAnswers(limit = 16) {
110
+ const held = new Map();
111
+ const key = (tool, args) => `${tool}\u0000${JSON.stringify(canonical(args ?? {}))}`;
112
+ return {
113
+ hold(tool, args, pieces) {
114
+ const k = key(tool, args);
115
+ held.delete(k);
116
+ held.set(k, pieces);
117
+ while (held.size > limit) held.delete(held.keys().next().value);
118
+ },
119
+ get: (tool, args) => held.get(key(tool, args)) ?? null,
120
+ };
121
+ }
122
+
123
+ /** A tool's input schema with the part argument added, for a server that was given an answer size. PURE. */
124
+ export function withAnswerPart(schema) {
125
+ const s = schema && typeof schema === "object" ? schema : { type: "object" };
126
+ return { ...s, properties: { ...(s.properties || {}), [ANSWER_PART_ARG]: { type: "number" } } };
127
+ }
@@ -177,6 +177,7 @@ export const NAMES_IN_FORCE = Object.freeze([
177
177
  "CLEAROTRON_SYNTHESIS_MODEL",
178
178
  "CLEAROTRON_TAINT_CHAIN",
179
179
  "CLEAROTRON_TEST_FIXTURE_PROFILES",
180
+ "CLEAROTRON_TOOL_ANSWER_BYTES",
180
181
  "CLEAROTRON_TURN_CAP",
181
182
  "CLEAROTRON_TURN_LOCK_POLL_MS",
182
183
  "CLEAROTRON_UNREACHABLE_SENIOR",