clearotron 0.4.1-beta.2 → 0.4.1-beta.3
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/.env.example +9 -0
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +2 -1
- package/driver/CHANGELOG.md +6 -0
- package/driver/engine/mcp/band-server.mjs +29 -9
- package/driver/engine/mcp/codex-config.mjs +31 -3
- package/driver/engine/mcp/gather-config.mjs +17 -2
- package/driver/engine/mcp/perplexity-server.mjs +5 -1
- package/driver/engine/mcp/stdio-server.mjs +24 -4
- package/driver/package.json +1 -1
- package/driver/suite-census.json +24 -6
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/bridge.mjs +25 -3
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/e2e-first-time.mjs +1 -1
- package/shared/answer-parts.mjs +127 -0
- package/shared/names-in-force.mjs +1 -0
package/.env.example
CHANGED
|
@@ -96,6 +96,15 @@ CLEAROTRON_CLAUDE_PATH=claude
|
|
|
96
96
|
# the copied `.env`, not a choice).
|
|
97
97
|
# effect: setup
|
|
98
98
|
CLEAROTRON_CODEX_PATH=codex
|
|
99
|
+
#
|
|
100
|
+
# How long one tool answer may be before it is handed over in parts, in bytes, counted as a quoted string
|
|
101
|
+
# (shared/answer-parts.mjs). Each engine gives every tool server its own size: 45,000 on Claude, because
|
|
102
|
+
# Claude Code saves an answer longer than 50,000 characters to a file the model must read for itself, and
|
|
103
|
+
# 36,000 on openai-agent, because Codex removes the middle of a tool read longer than about 39,000 bytes.
|
|
104
|
+
# A smaller value here still applies on both; empty leaves the engine's size. The answer itself is
|
|
105
|
+
# unchanged either way; the tool runs once, and a later part is read from the answer the server holds.
|
|
106
|
+
# effect: tuning
|
|
107
|
+
CLEAROTRON_TOOL_ANSWER_BYTES=
|
|
99
108
|
|
|
100
109
|
CLEAROTRON_WORK_DIR=
|
|
101
110
|
# Customer/project config store (loadProfiles). Unset ⇒ driver's built-in profiles dir.
|
package/build-info.json
CHANGED
|
@@ -292,7 +292,8 @@ of truncations.
|
|
|
292
292
|
|
|
293
293
|
| Var | Default | Meaning |
|
|
294
294
|
|---|---|---|
|
|
295
|
-
| `CLEAROTRON_BAND_RESPONSE_CHARS` | the
|
|
295
|
+
| `CLEAROTRON_BAND_RESPONSE_CHARS` | five sixths of `CLEAROTRON_TOOL_ANSWER_BYTES`: 37,500 on the Claude engine, 30,000 on the OpenAI engine; 70,000 with no answer size | Characters per band-server response, the band summary's parts included; a summary part is never longer than 70,000 whatever this says. Floor 4096. The sixth left over is room for the quotes and line breaks a printout adds and for a banner, so a band answer is never split again into answer parts. On the OpenAI engine it is at most 30,000; a smaller value set here still applies. |
|
|
296
|
+
| `CLEAROTRON_TOOL_ANSWER_BYTES` | 45,000 on the Claude engine, 36,000 on the OpenAI engine | Bytes per tool answer from every local tool server and the case-law bridge, counted as a quoted string (a quote or line break counts two). A longer answer is served in parts, each naming the next, and the tool runs once: a later part is read from the answer the server holds. Each engine writes its size into every server's entry: Claude Code saves an answer longer than 50,000 characters to a file the model must read for itself, and Codex removes the middle of a tool read longer than about 39,000 bytes. A smaller value set here still applies on both. A server started outside a stage, with no size, serves every answer whole. Floor 8192. |
|
|
296
297
|
|
|
297
298
|
### Surfaces beside the driver — portal, demo, e2e harness
|
|
298
299
|
|
package/driver/CHANGELOG.md
CHANGED
|
@@ -20,6 +20,7 @@ import { readFileSync, readdirSync, appendFileSync, existsSync, mkdirSync } from
|
|
|
20
20
|
import { join, dirname } from "node:path";
|
|
21
21
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
22
22
|
import { serve } from "./stdio-server.mjs";
|
|
23
|
+
import { answerBudget } from "../../../shared/answer-parts.mjs";
|
|
23
24
|
import { classifyRecord, prepareTargets, SHAPE_TIERS, isLiveRecord } from "../../band-shape.mjs";
|
|
24
25
|
import { recordQids } from "../../named-band.mjs";
|
|
25
26
|
import { normalizeRecordUri } from "../../registry-fidelity.mjs";
|
|
@@ -98,7 +99,8 @@ const loadJson = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
|
98
99
|
// contract is honest and explicit: an over-size shape announces "part N/M" and names the next call;
|
|
99
100
|
// the common single-part case is served verbatim with no banner. The part size was a test seam with a
|
|
100
101
|
// floor under it so a misconfiguration could never shred the shape into confetti; step 3 deleted
|
|
101
|
-
// the knob, and a constant cannot be misconfigured.
|
|
102
|
+
// the knob, and a constant cannot be misconfigured. A smaller answer size still lowers it
|
|
103
|
+
// (SHAPE_PART_SERVED, below), never under the answer size's own floor.
|
|
102
104
|
const SHAPE_PART_CHARS = 70000; // step 3 — was a knob; no environment ever set it
|
|
103
105
|
|
|
104
106
|
// PR-11 — the SAME transport cap bounds the other two tools (they were shipped unbounded): a
|
|
@@ -106,7 +108,19 @@ const SHAPE_PART_CHARS = 70000; // step 3 — was a knob; no environment ever
|
|
|
106
108
|
// per-tool-output token cap and error the call — killing the reading path exactly where the record
|
|
107
109
|
// volume is highest. band_lookup TRIMS records (the full match count always survives, with an
|
|
108
110
|
// honest note); band_record serves PARTS like band_shape (a record is a document — never trimmed).
|
|
109
|
-
|
|
111
|
+
// Given an answer size and no band size of its own, the band answers in five sixths of it: the rest is room
|
|
112
|
+
// for the quotes and line breaks a printout adds (up to 13% on band answers) and for a banner, so a band
|
|
113
|
+
// answer is never split a second time (shared/answer-parts.mjs). 37,500 on Claude, 30,000 on codex.
|
|
114
|
+
const RESPONSE_CHARS = Math.max(4096, Number(process.env.CLEAROTRON_BAND_RESPONSE_CHARS)
|
|
115
|
+
|| (answerBudget() ? Math.floor(answerBudget() / 1.2) : SHAPE_PART_CHARS));
|
|
116
|
+
|
|
117
|
+
// The shape is split at the smaller of its own part size and the answer size, because codex cuts the
|
|
118
|
+
// middle out of a read over about 39,000 bytes and is given a smaller answer size for that
|
|
119
|
+
// (engine/mcp/codex-config.mjs, CODEX_BAND_ANSWER_CHARS). Measured in testing, 2026-10-11: a GPT model that
|
|
120
|
+
// printed a 70,269-character shape part to read it named 11 of its 24 marker rows, and in one attempt of a
|
|
121
|
+
// real run four of five shape reads were that long. Never larger than the constant,
|
|
122
|
+
// so a larger answer size cannot push a part past Claude's limit; with no size set, nothing changes.
|
|
123
|
+
const SHAPE_PART_SERVED = Math.min(SHAPE_PART_CHARS, RESPONSE_CHARS);
|
|
110
124
|
|
|
111
125
|
/** Split text into parts of ≤ budget chars, cutting only at line boundaries (an over-budget single
|
|
112
126
|
* line still ships whole — never cut mid-line). Concatenating the parts with "\n" restores the
|
|
@@ -216,7 +230,7 @@ function bandShape(params) {
|
|
|
216
230
|
if (!RUN_DIR) { logRead("band_shape", { format }, { ok: false }); return { isError: true, text: "ERROR: CLEAROTRON_BAND_RUN_DIR not set — band tools are wired per run by the driver." }; }
|
|
217
231
|
if (!existsSync(p)) { logRead("band_shape", { format }, { ok: false }); return { isError: true, text: `ERROR: ${p} not found — the driver derives it after the register fan-in; if the band exists but the shape does not, report this as a driver defect (do NOT slice the raw band instead).` }; }
|
|
218
232
|
const text = readFileSync(p, "utf8");
|
|
219
|
-
const parts = splitAtLines(text,
|
|
233
|
+
const parts = splitAtLines(text, SHAPE_PART_SERVED);
|
|
220
234
|
const total = parts.length;
|
|
221
235
|
if (total === 1) { // the common case — served verbatim, no banner
|
|
222
236
|
logRead("band_shape", { format }, { ok: true, bytes: text.length });
|
|
@@ -240,6 +254,11 @@ function bandLookup(params = {}) {
|
|
|
240
254
|
try { band = loadJson(bandPath()); } catch (e) { logRead("band_lookup", params, { ok: false }); return { isError: true, text: `ERROR: band unreadable (${e.message}) — a driver-side defect; report it, never hand-repair the band.` }; }
|
|
241
255
|
const { record_id, owner, nice_class, tier, qid, query, text, status, live_only = false } = params;
|
|
242
256
|
const limit = Math.max(1, Math.min(Number(params.limit) || 20, 100));
|
|
257
|
+
// PAGING. A large band is read page by page from `offset`, in the band's own order, and a capped answer
|
|
258
|
+
// names the next offset. Without it the only way to see every record was to slice the band by owner or
|
|
259
|
+
// text fragments, and a seat told to judge the complete band read it many times over (1,423 records,
|
|
260
|
+
// 2,287 lookups, 65.8 MB, until the engine's output cap stopped the turn).
|
|
261
|
+
const offset = Math.max(0, Math.floor(Number(params.offset) || 0));
|
|
243
262
|
if (tier && !SHAPE_TIERS.includes(tier)) return { isError: true, text: `ERROR: tier must be one of ${SHAPE_TIERS.join(" | ")}.` };
|
|
244
263
|
|
|
245
264
|
// tier filtering classifies against the SHAPE's own frozen targets — same classifier, same result.
|
|
@@ -270,7 +289,7 @@ function bandLookup(params = {}) {
|
|
|
270
289
|
if (text && !norm(r?.mark_text).includes(norm(text))) continue;
|
|
271
290
|
if (prepared && classifyRecord(r?.mark_text, prepared).tier !== tier) continue;
|
|
272
291
|
matched++;
|
|
273
|
-
if (hits.length < limit) hits.push(project(r));
|
|
292
|
+
if (matched > offset && hits.length < limit) hits.push(project(r));
|
|
274
293
|
}
|
|
275
294
|
// matching crowd descriptors ride along (a lookup that lands in an un-enumerated zone must SEE that).
|
|
276
295
|
const crowds = (Array.isArray(band?.crowds) ? band.crowds : []).filter((c) => {
|
|
@@ -301,9 +320,10 @@ function bandLookup(params = {}) {
|
|
|
301
320
|
// The full `matched` count always ships, and the note says exactly how many records were withheld.
|
|
302
321
|
let byteCapped = 0;
|
|
303
322
|
const body = () => JSON.stringify({
|
|
304
|
-
matched, returned: hits.length,
|
|
305
|
-
...(matched > hits.length ? {
|
|
306
|
-
...(
|
|
323
|
+
matched, returned: hits.length, ...(offset ? { offset } : {}),
|
|
324
|
+
...(matched > offset + hits.length ? { next_offset: offset + hits.length } : {}),
|
|
325
|
+
...(matched > offset + hits.length ? { note: `capped at ${hits.length} — call again with offset: ${offset + hits.length} for the next page; the count above is the full match` } : {}),
|
|
326
|
+
...(byteCapped ? { byte_cap_note: `${byteCapped} matched record(s) withheld to fit one tool response — call again with offset: ${offset + hits.length} for the rest` } : {}),
|
|
307
327
|
records: hits,
|
|
308
328
|
...(crowds.length ? { matching_crowds: crowds, crowd_note: "these matching slices were NOT enumerated — records under them may exist that no lookup can return; weigh the descriptor, never treat this result as their clean"
|
|
309
329
|
+ (refusedCrowds ? ` — and ${refusedCrowds} of them was/were NEVER ANSWERED at all (see "refusal"): no count exists for those, so they are unsearched rather than empty, and nothing about them can be read off a zero` : "")
|
|
@@ -405,11 +425,11 @@ serve({
|
|
|
405
425
|
},
|
|
406
426
|
{
|
|
407
427
|
name: "band_lookup",
|
|
408
|
-
description: "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, limit (default 20, max 100). Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.",
|
|
428
|
+
description: "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, limit (default 20, max 100), offset (default 0: where in the matching records to start — a capped answer names the next offset, so a large band is read page by page). Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.",
|
|
409
429
|
inputSchema: { type: "object", properties: {
|
|
410
430
|
record_id: { type: "string" }, owner: { type: "string" }, nice_class: { type: "string" },
|
|
411
431
|
tier: { type: "string", enum: SHAPE_TIERS }, qid: { type: "string" }, query: { type: "string" }, text: { type: "string" },
|
|
412
|
-
status: { type: "string" }, live_only: { type: "boolean" }, limit: { type: "number" },
|
|
432
|
+
status: { type: "string" }, live_only: { type: "boolean" }, limit: { type: "number" }, offset: { type: "number" },
|
|
413
433
|
} },
|
|
414
434
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
415
435
|
handler: bandLookup,
|
|
@@ -50,6 +50,30 @@ export const TOOL_SERVER_SETTINGS = Object.freeze([
|
|
|
50
50
|
"OAUTH_BRIDGE_CREDS_DIR", "OAUTH_BRIDGE_CLIENT_NAME",
|
|
51
51
|
]);
|
|
52
52
|
|
|
53
|
+
// CODEX CUTS THE MIDDLE OUT OF A LONG READ. It shows a model at most 10,000 tokens of the text a tool call
|
|
54
|
+
// puts in front of it, from its start and its end, counting 4 bytes a token, with "…N tokens truncated…"
|
|
55
|
+
// between them. For the GPT models, which call tools only from code (5.6 and 6.x), that text is what the
|
|
56
|
+
// code prints, and a quoted print adds a byte for each quote and line break: up to 13% on real band answers.
|
|
57
|
+
// Measured in testing, 2026-10-11, on codex 0.162.0 with gpt-6.1-sol: a model that printed a 68,752-character
|
|
58
|
+
// band page to read it named 14 of its 30 records, and at 32,000 characters it named every one. The band
|
|
59
|
+
// server sizes its answers for Claude's limit (70,000 characters, under 25,000 tokens). The vendor's settings
|
|
60
|
+
// for a larger budget (`tool_output_token_limit`, a tool's `output_token_limit`) changed nothing on that
|
|
61
|
+
// version, written in this file or given on the command line. So here the band server answers in 30,000
|
|
62
|
+
// characters: printed with its escapes and banner it stays under the general answer size below, and so under
|
|
63
|
+
// the cut. An install's own smaller size still wins.
|
|
64
|
+
export const CODEX_BAND_ANSWER_CHARS = 30000;
|
|
65
|
+
export const codexBandAnswerChars = (env = process.env) =>
|
|
66
|
+
Math.min(CODEX_BAND_ANSWER_CHARS, Number(env.CLEAROTRON_BAND_RESPONSE_CHARS) || CODEX_BAND_ANSWER_CHARS);
|
|
67
|
+
|
|
68
|
+
// AND EVERY OTHER ANSWER: each server here, the case-law bridges and the page fetcher included, is given an
|
|
69
|
+
// answer size, and an answer longer than that, counted as a quoted string, is served in parts, the tool
|
|
70
|
+
// running once (shared/answer-parts.mjs). 36,000 bytes leaves room under the cut for the engine's own
|
|
71
|
+
// wrapping (about 840 bytes); the band server's own answers stay under it and are never split again. An
|
|
72
|
+
// install's own smaller size still wins.
|
|
73
|
+
export const CODEX_TOOL_ANSWER_BYTES = 36000;
|
|
74
|
+
export const codexToolAnswerBytes = (env = process.env) =>
|
|
75
|
+
Math.min(CODEX_TOOL_ANSWER_BYTES, Number(env.CLEAROTRON_TOOL_ANSWER_BYTES) || CODEX_TOOL_ANSWER_BYTES);
|
|
76
|
+
|
|
53
77
|
// ── TOML value escaping (basic strings) ──────────────────────────────────────────────────────────────
|
|
54
78
|
// One encoder for every TOML block the product writes; re-exported so this module's callers keep their import.
|
|
55
79
|
export { tomlString };
|
|
@@ -121,7 +145,7 @@ export function webFetchRequested(allowedTools) {
|
|
|
121
145
|
// stays the backstop. Absent/0/negative ⇒ emit nothing and leave codex's default alone, so this is inert
|
|
122
146
|
// for any caller that does not thread a budget.
|
|
123
147
|
export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstructions, nodeBin = process.execPath, credEnvForward = [...CRED_ENV_FORWARD, ...TOOL_SERVER_SETTINGS], toolTimeoutSec, fence = null, withheldFromCommands = [],
|
|
124
|
-
platform = process.platform } = {}) {
|
|
148
|
+
platform = process.platform, bandAnswerChars = codexBandAnswerChars(), toolAnswerBytes = codexToolAnswerBytes() } = {}) {
|
|
125
149
|
const servers = parseClaudeMcpServers(mcpConfig);
|
|
126
150
|
const enabled = enabledToolsByServer(allowedTools);
|
|
127
151
|
const toolTimeout = Number(toolTimeoutSec) > 0 ? Math.floor(Number(toolTimeoutSec)) : undefined;
|
|
@@ -167,11 +191,15 @@ export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstru
|
|
|
167
191
|
lines.push("");
|
|
168
192
|
};
|
|
169
193
|
|
|
170
|
-
|
|
194
|
+
// Every server answers under codex's cut, the band server in its own way too (CODEX_TOOL_ANSWER_BYTES,
|
|
195
|
+
// CODEX_BAND_ANSWER_CHARS, above); `env` wins over a forwarded name.
|
|
196
|
+
const answers = { CLEAROTRON_TOOL_ANSWER_BYTES: String(toolAnswerBytes) };
|
|
197
|
+
for (const [name, s] of Object.entries(servers)) emitServer(name, { ...s, enabledTools: enabled[name],
|
|
198
|
+
env: { ...s.env, ...answers, ...(name === "band" ? { CLEAROTRON_BAND_RESPONSE_CHARS: String(bandAnswerChars) } : {}) } }, { forwardCreds: true });
|
|
171
199
|
|
|
172
200
|
// WebFetch → the engine-local fetch server (codex has no built-in). Needs no creds (no env_vars).
|
|
173
201
|
if (webFetchRequested(allowedTools))
|
|
174
|
-
emitServer("fetch", { command: nodeBin, args: [FETCH_SERVER], env: {}, enabledTools: ["fetch_url"] }, { forwardCreds: false });
|
|
202
|
+
emitServer("fetch", { command: nodeBin, args: [FETCH_SERVER], env: { ...answers }, enabledTools: ["fetch_url"] }, { forwardCreds: false });
|
|
175
203
|
|
|
176
204
|
if (fence) lines.push(...fenceToml(fence));
|
|
177
205
|
// ON WINDOWS, CODEX'S SANDBOX IS ON ONLY WHEN THIS NAMES IT. With no `[windows] sandbox` and no feature
|
|
@@ -834,9 +834,21 @@ function resolveGroup(group) {
|
|
|
834
834
|
}
|
|
835
835
|
}
|
|
836
836
|
|
|
837
|
+
// CLAUDE'S ANSWER SIZE. Claude Code replaces a tool answer longer than 50,000 characters, or 25,000 tokens,
|
|
838
|
+
// with a message naming a file it saved the answer to, and the model must read that file itself, the
|
|
839
|
+
// first read refused when the file is past 25,000 tokens (the vendor's MCP page; measured in testing,
|
|
840
|
+
// 2026-10-11, on 2.1.286 and 2.1.295: 49,159 characters arrived whole, 51,189 were replaced). So every
|
|
841
|
+
// server is given this size, and a longer answer comes in the parts shared/answer-parts.mjs serves, the
|
|
842
|
+
// banners the same on both engines. 45,000 bytes counted as a quoted string is under 50,000 characters, and
|
|
843
|
+
// under 25,000 tokens for any text a register or a search returns. The codex config writes its own, smaller
|
|
844
|
+
// size over this one (codex-config.mjs). An install's own smaller size still wins.
|
|
845
|
+
export const CLAUDE_TOOL_ANSWER_BYTES = 45000;
|
|
846
|
+
export const claudeToolAnswerBytes = (env = process.env) =>
|
|
847
|
+
Math.min(CLAUDE_TOOL_ANSWER_BYTES, Number(env.CLEAROTRON_TOOL_ANSWER_BYTES) || CLAUDE_TOOL_ANSWER_BYTES);
|
|
848
|
+
|
|
837
849
|
// The mcp-config server env carries ONLY the per-run, non-secret values (session key + ledger paths +
|
|
838
|
-
// agent + the run dir the band tools serve). Creds (CORSEARCH_SESSION_KEY /
|
|
839
|
-
// EUIPO_*) are inherited from the engine env.
|
|
850
|
+
// agent + the run dir the band tools serve, and the answer size above). Creds (CORSEARCH_SESSION_KEY /
|
|
851
|
+
// PERPLEXITY_API_KEY / EUIPO_*) are inherited from the engine env.
|
|
840
852
|
function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
|
|
841
853
|
const e = {};
|
|
842
854
|
// — see buildGatherMcpConfig: a register server without a run is refused there, so by the time
|
|
@@ -881,6 +893,7 @@ function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
|
|
|
881
893
|
if (runDir) e.CLEAROTRON_REGISTER_RECORD_LOG = runRecordLogPath(runDir);
|
|
882
894
|
for (const v of ["CLEAROTRON_GATHER_SESSION_ID", "EUIPO_ENVIRONMENT"])
|
|
883
895
|
if (process.env[v]) e[v] = process.env[v];
|
|
896
|
+
e.CLEAROTRON_TOOL_ANSWER_BYTES = String(claudeToolAnswerBytes()); // CLAUDE_TOOL_ANSWER_BYTES, above
|
|
884
897
|
return e;
|
|
885
898
|
}
|
|
886
899
|
|
|
@@ -935,6 +948,8 @@ export function buildGatherMcpConfig(groups = [], { sessionKey, agent, runDir, r
|
|
|
935
948
|
if (runDir) auditEnv.CLEAROTRON_BAND_RUN_DIR = runDir;
|
|
936
949
|
if (sessionKey) auditEnv.CLEAROTRON_GATHER_SESSION_KEY = sessionKey;
|
|
937
950
|
if (agent) auditEnv.CLEAROTRON_GATHER_AGENT = agent;
|
|
951
|
+
// In a run, a fourth: the answer size, because a case-law answer is served in parts like any other.
|
|
952
|
+
if (Object.keys(auditEnv).length) auditEnv.CLEAROTRON_TOOL_ANSWER_BYTES = String(claudeToolAnswerBytes());
|
|
938
953
|
for (const k of bridgeKeys) mcpServers[k] = { command: NODE, args: [BRIDGE, "--server", k], connectionTimeoutMs: 60000, ...(Object.keys(auditEnv).length ? { env: auditEnv } : {}) };
|
|
939
954
|
return Object.keys(mcpServers).length ? { mcpServers } : null;
|
|
940
955
|
}
|
|
@@ -10,6 +10,7 @@ import { dirname, basename } from "node:path";
|
|
|
10
10
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
11
11
|
import { underStudioSegment } from "../../../shared/path-seps.mjs"; // Windows builds the path with "\"
|
|
12
12
|
import { serve } from "./stdio-server.mjs";
|
|
13
|
+
import { answerBudget } from "../../../shared/answer-parts.mjs";
|
|
13
14
|
import {
|
|
14
15
|
isKnownPreset, questionPresetFor, buildRequestBody, callAgentAPI, formatResponse, formatSandboxResponse,
|
|
15
16
|
validateGridSpec, buildGridProgramTask, captureGridFromResponse, requiredLedgerRefusal,
|
|
@@ -194,7 +195,10 @@ function tellObligations(spec, ledgerJson) {
|
|
|
194
195
|
// and a grid's candidates grow with its results per cell. So they are served in parts, split between
|
|
195
196
|
// cells, as band_shape serves an oversize shape, with the same part size. A later part is read from the
|
|
196
197
|
// saved ledger: nothing is searched again.
|
|
197
|
-
|
|
198
|
+
// Smaller when this server was given an answer size (codex cuts the middle out of a longer read;
|
|
199
|
+
// shared/answer-parts.mjs): three quarters of it, which leaves room for the rest of the reply and for the
|
|
200
|
+
// quotes and line breaks a printout adds, so a part of the grid is never split again on its way to the model.
|
|
201
|
+
const GRID_PART_CHARS = answerBudget() ? Math.min(70000, Math.floor(answerBudget() * 0.75)) : 70000;
|
|
198
202
|
|
|
199
203
|
// `reserve` is what part 1's reply also carries (the meaning obligations): part 1 is that much smaller,
|
|
200
204
|
// and every call passes the same reserve for the same ledger, so every call splits it the same way.
|
|
@@ -12,6 +12,7 @@ import { createInterface } from "node:readline";
|
|
|
12
12
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
13
13
|
import { dirname } from "node:path";
|
|
14
14
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
15
|
+
import { answerBudget, answerPieces, servedPart, partNotHeld, takePart, heldAnswers, withAnswerPart } from "../../../shared/answer-parts.mjs";
|
|
15
16
|
|
|
16
17
|
// ── half 2 — THE TOOL CALL LOG, so a killed call is a FACT and not an inference ────────────────
|
|
17
18
|
//
|
|
@@ -88,6 +89,10 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
88
89
|
// reader must key on (server, seq) and never on seq alone.
|
|
89
90
|
let callSeq = 0;
|
|
90
91
|
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
92
|
+
// A LONG ANSWER IS SERVED IN PARTS when this server was given an answer size (shared/answer-parts.mjs):
|
|
93
|
+
// the handler runs once, the answer is held here, and a later part is read from it. No size, no change.
|
|
94
|
+
const budget = answerBudget();
|
|
95
|
+
const held = heldAnswers();
|
|
91
96
|
// ── — THE ANNOTATIONS TRAVEL, BECAUSE A TOOL MUST BE ABLE TO SAY WHAT IT DOES ──
|
|
92
97
|
//
|
|
93
98
|
// This line used to pick three fields and drop the rest, so a tool that declared `annotations` had
|
|
@@ -117,9 +122,10 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
117
122
|
// convenience — the exact thing annotations exist to prevent.
|
|
118
123
|
// Listed when asked, not at start: a tool may give the schema for the run it serves (`schemaNow`), read
|
|
119
124
|
// at call time like every per-run read here. A tool without one lists its declared schema, as before.
|
|
125
|
+
const schemaOf = (t) => (typeof t.schemaNow === "function" ? t.schemaNow(t.inputSchema) : t.inputSchema);
|
|
120
126
|
const list = () => tools.map((t) => ({
|
|
121
127
|
name: t.name, description: t.description,
|
|
122
|
-
inputSchema:
|
|
128
|
+
inputSchema: budget ? withAnswerPart(schemaOf(t)) : schemaOf(t),
|
|
123
129
|
...(t.annotations ? { annotations: t.annotations } : {}),
|
|
124
130
|
}));
|
|
125
131
|
const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
|
|
@@ -144,11 +150,20 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
144
150
|
} else if (method === "tools/call") {
|
|
145
151
|
const tool = byName.get(params?.name);
|
|
146
152
|
if (!tool) return ok(id, { isError: true, content: [{ type: "text", text: `unknown tool: ${params?.name}` }] });
|
|
153
|
+
// A later part of a held answer is read from it, and the tool does not run: no started/settled pair,
|
|
154
|
+
// one row of its own, so a count of the tool's runs stays a count of its runs.
|
|
155
|
+
const { args, part } = budget ? takePart(params?.arguments) : { args: params?.arguments, part: null };
|
|
156
|
+
const kept = part ? held.get(tool.name, args) : null;
|
|
157
|
+
if (part && (kept || part > 1)) {
|
|
158
|
+
logToolEvent({ event: "answer-part", server: name, tool: tool.name, part, ...(kept ? { of: kept.length } : { held: false }) });
|
|
159
|
+
return ok(id, kept ? { content: [{ type: "text", text: servedPart(tool.name, kept, part) }] }
|
|
160
|
+
: { isError: true, content: [{ type: "text", text: partNotHeld(tool.name, part) }] });
|
|
161
|
+
}
|
|
147
162
|
// — STAMPED BEFORE THE WORK, settled after it, and the pair is the whole mechanism. A
|
|
148
163
|
// process killed mid-call cannot write anything, so "started with no settled" is the only shape
|
|
149
164
|
// that can record a call that never returned.
|
|
150
165
|
const seq = ++callSeq;
|
|
151
|
-
const axis = typeof
|
|
166
|
+
const axis = typeof args?.axis === "string" ? args.axis : null;
|
|
152
167
|
logToolEvent({ event: "started", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}) });
|
|
153
168
|
// ── — THE ONE SEAM EVERY TYPED CALL PASSES THROUGH ───────────────────────
|
|
154
169
|
//
|
|
@@ -157,7 +172,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
157
172
|
// re-check by hand, which made "this field is required" a property of one author's diligence
|
|
158
173
|
// rather than of the contract. The refusal names the fields in the acceptors' own token shape so
|
|
159
174
|
// a seat can act on it in the turn it happens.
|
|
160
|
-
const unmet = requiredFieldViolations(tool.inputSchema,
|
|
175
|
+
const unmet = requiredFieldViolations(tool.inputSchema, args);
|
|
161
176
|
if (unmet.length) {
|
|
162
177
|
// THE REASON THE ROW HAD IN SCOPE AND THREW AWAY. A settled row with
|
|
163
178
|
// `ok: false` and nothing else asserts a failure and withholds everything that would let a
|
|
@@ -177,7 +192,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
177
192
|
+ `${unmet.length === 1 ? "it" : "them"}. Send the field itself, not null and not a placeholder.` }] });
|
|
178
193
|
}
|
|
179
194
|
let out;
|
|
180
|
-
try { out = await tool.handler(
|
|
195
|
+
try { out = await tool.handler(args || {}); }
|
|
181
196
|
catch (e) {
|
|
182
197
|
// A THROWN tool error SETTLES. It returned an answer — a wrong one, but the model saw it and
|
|
183
198
|
// could act on it. Recording it as unsettled would attribute a live failure to a timeout. The
|
|
@@ -193,6 +208,11 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
193
208
|
const code = isError ? complaintCode(text) : null;
|
|
194
209
|
logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: !isError,
|
|
195
210
|
...(isError ? { failure: "server", ...(code ? { reason: code } : {}) } : {}) });
|
|
211
|
+
const pieces = budget && !isError ? answerPieces(text, budget, tool.name) : null;
|
|
212
|
+
if (pieces && pieces.length > 1) {
|
|
213
|
+
held.hold(tool.name, args, pieces);
|
|
214
|
+
return ok(id, { content: [{ type: "text", text: servedPart(tool.name, pieces, 1) }] });
|
|
215
|
+
}
|
|
196
216
|
ok(id, { isError: isError || undefined, content: [{ type: "text", text }] });
|
|
197
217
|
} else if (id != null) {
|
|
198
218
|
err(id, -32601, `method not found: ${method}`);
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.1-beta.
|
|
5
|
+
"version": "0.4.1-beta.3",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|
package/driver/suite-census.json
CHANGED
|
@@ -766,8 +766,8 @@
|
|
|
766
766
|
"todos": 0
|
|
767
767
|
},
|
|
768
768
|
"a-large-grid-is-served-in-parts-under-the-tool-cap.test.mjs": {
|
|
769
|
-
"tests":
|
|
770
|
-
"asserts":
|
|
769
|
+
"tests": 5,
|
|
770
|
+
"asserts": 23,
|
|
771
771
|
"skips": 0,
|
|
772
772
|
"todos": 0
|
|
773
773
|
},
|
|
@@ -795,6 +795,18 @@
|
|
|
795
795
|
"skips": 0,
|
|
796
796
|
"todos": 0
|
|
797
797
|
},
|
|
798
|
+
"a-long-answer-reaches-a-gpt-model-in-parts.test.mjs": {
|
|
799
|
+
"tests": 5,
|
|
800
|
+
"asserts": 39,
|
|
801
|
+
"skips": 0,
|
|
802
|
+
"todos": 0
|
|
803
|
+
},
|
|
804
|
+
"a-long-case-law-answer-reaches-a-gpt-model-in-parts.test.mjs": {
|
|
805
|
+
"tests": 2,
|
|
806
|
+
"asserts": 7,
|
|
807
|
+
"skips": 0,
|
|
808
|
+
"todos": 0
|
|
809
|
+
},
|
|
798
810
|
"a-mark-name-cannot-lie-about-how-it-reads.test.mjs": {
|
|
799
811
|
"tests": 9,
|
|
800
812
|
"asserts": 38,
|
|
@@ -1365,6 +1377,12 @@
|
|
|
1365
1377
|
"skips": 0,
|
|
1366
1378
|
"todos": 0
|
|
1367
1379
|
},
|
|
1380
|
+
"a-seat-reads-the-band-page-by-page.test.mjs": {
|
|
1381
|
+
"tests": 4,
|
|
1382
|
+
"asserts": 25,
|
|
1383
|
+
"skips": 0,
|
|
1384
|
+
"todos": 0
|
|
1385
|
+
},
|
|
1368
1386
|
"a-secret-file-is-compared-not-printed.test.mjs": {
|
|
1369
1387
|
"tests": 5,
|
|
1370
1388
|
"asserts": 16,
|
|
@@ -2020,8 +2038,8 @@
|
|
|
2020
2038
|
"todos": 0
|
|
2021
2039
|
},
|
|
2022
2040
|
"band-server.test.mjs": {
|
|
2023
|
-
"tests":
|
|
2024
|
-
"asserts":
|
|
2041
|
+
"tests": 26,
|
|
2042
|
+
"asserts": 136,
|
|
2025
2043
|
"skips": 0,
|
|
2026
2044
|
"todos": 0
|
|
2027
2045
|
},
|
|
@@ -3088,8 +3106,8 @@
|
|
|
3088
3106
|
"todos": 0
|
|
3089
3107
|
},
|
|
3090
3108
|
"engine.codex-config.test.mjs": {
|
|
3091
|
-
"tests":
|
|
3092
|
-
"asserts":
|
|
3109
|
+
"tests": 23,
|
|
3110
|
+
"asserts": 89,
|
|
3093
3111
|
"skips": 0,
|
|
3094
3112
|
"todos": 0
|
|
3095
3113
|
},
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.4.1-beta.
|
|
3
|
+
"version": "0.4.1-beta.3",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
|
package/package.json
CHANGED
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.1-beta.
|
|
5
|
+
"version": "0.4.1-beta.3",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
|
|
8
8
|
"engines": {
|
|
@@ -30,6 +30,7 @@ import { appendFileSync, mkdirSync } from "node:fs";
|
|
|
30
30
|
import { homedir } from "node:os";
|
|
31
31
|
import path from "node:path";
|
|
32
32
|
import { driverDir } from "../../shared/driver-dir.mjs"; //
|
|
33
|
+
import { answerBudget, answerPieces, servedPart, partNotHeld, takePart, heldAnswers, withAnswerPart } from "../../shared/answer-parts.mjs";
|
|
33
34
|
import { RefreshLock } from "./refresh-lock.mjs";
|
|
34
35
|
import { exitOnStdinClose } from "./stdin-guard.mjs";
|
|
35
36
|
|
|
@@ -327,6 +328,19 @@ async function main() {
|
|
|
327
328
|
{ capabilities: { tools: {} } },
|
|
328
329
|
);
|
|
329
330
|
|
|
331
|
+
// A LONG ANSWER IS SERVED IN PARTS when the bridge was given an answer size (shared/answer-parts.mjs). An
|
|
332
|
+
// answer made of text alone is split; one carrying anything else is passed on as it came.
|
|
333
|
+
const budget = answerBudget();
|
|
334
|
+
const held = heldAnswers();
|
|
335
|
+
const partedResult = (tool, args, res) => {
|
|
336
|
+
const blocks = Array.isArray(res?.content) ? res.content : [];
|
|
337
|
+
if (!blocks.length || !blocks.every((c) => c?.type === "text")) return res;
|
|
338
|
+
const pieces = answerPieces(blocks.map((c) => String(c.text ?? "")).join("\n"), budget, tool);
|
|
339
|
+
if (pieces.length === 1) return res;
|
|
340
|
+
held.hold(tool, args, pieces);
|
|
341
|
+
return { content: [{ type: "text", text: servedPart(tool, pieces, 1) }] };
|
|
342
|
+
};
|
|
343
|
+
|
|
330
344
|
downstream.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
331
345
|
const upstreamResp = await upstream.listTools();
|
|
332
346
|
const upstreamTools = upstreamResp?.tools ?? [];
|
|
@@ -339,7 +353,7 @@ async function main() {
|
|
|
339
353
|
`[oauth-bridge:${serverName}] filtered ${dropped.length} unlisted upstream tool(s) from ListTools: ${dropped.join(", ")}\n`,
|
|
340
354
|
);
|
|
341
355
|
}
|
|
342
|
-
return { ...upstreamResp, tools };
|
|
356
|
+
return { ...upstreamResp, tools: budget ? tools.map((t) => ({ ...t, inputSchema: withAnswerPart(t.inputSchema) })) : tools };
|
|
343
357
|
});
|
|
344
358
|
|
|
345
359
|
downstream.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
@@ -353,12 +367,20 @@ async function main() {
|
|
|
353
367
|
`Tool "${name}" is not in the bridge allowlist for ${serverName}. To enable it, add the unprefixed upstream name to ALLOWED_TOOLS in bridge.mjs and restart.`,
|
|
354
368
|
);
|
|
355
369
|
}
|
|
370
|
+
// A later part of a held answer is read from it; the upstream server is not asked again.
|
|
371
|
+
const { args, part } = budget ? takePart(req.params.arguments) : { args: req.params.arguments, part: null };
|
|
372
|
+
const kept = part ? held.get(name, args) : null;
|
|
373
|
+
if (part && (kept || part > 1)) {
|
|
374
|
+
logCall(serverName, name, args, kept ? { ok: true, answer_part: part, of: kept.length } : { ok: false, answer_part: part, held: false });
|
|
375
|
+
return kept ? { content: [{ type: "text", text: servedPart(name, kept, part) }] }
|
|
376
|
+
: { isError: true, content: [{ type: "text", text: partNotHeld(name, part) }] };
|
|
377
|
+
}
|
|
356
378
|
try {
|
|
357
|
-
const res = await upstream.callTool({ name, arguments:
|
|
379
|
+
const res = await upstream.callTool({ name, arguments: args });
|
|
358
380
|
const refused = toolError(res);
|
|
359
381
|
if (refused === null) logCall(serverName, name, req.params.arguments, { ok: true, bytes: resultBytes(res) });
|
|
360
382
|
else logCall(serverName, name, req.params.arguments, { ok: false, error: refused });
|
|
361
|
-
return res;
|
|
383
|
+
return budget && refused === null ? partedResult(name, args, res) : res;
|
|
362
384
|
} catch (err) {
|
|
363
385
|
// A FAILED CALL IS LOGGED TOO, and it is the half that matters: without it, "the search ran and
|
|
364
386
|
// found nothing" and "the search errored" leave the same trace — the ambiguity is about.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.4.1-beta.
|
|
3
|
+
"version": "0.4.1-beta.3",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
|
|
@@ -222,7 +222,7 @@ export const NOT_COUNTED_EVENTS = {
|
|
|
222
222
|
"stale-repair-entry", "taint-disclose", "unsettled-inputs", "verdict-2", "verdict-3",
|
|
223
223
|
],
|
|
224
224
|
"read from its own record: the stage records, the dispatch records and the tool-call log": [
|
|
225
|
-
"attempt", "stage", "settled", "started",
|
|
225
|
+
"answer-part", "attempt", "stage", "settled", "started",
|
|
226
226
|
],
|
|
227
227
|
"the portal's, the profile store's or the config store's audit log, never a run log": [
|
|
228
228
|
"compose-read", "demo-order", "family-group", "family-ungroup", "other", "person-add", "person-change",
|
|
@@ -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",
|