agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
228 lines • 9.26 kB
JavaScript
/**
* servedJson — a served tool result's JSON, read by the JSON grammar when the
* whole text is NOT one JSON value.
*
* Pattern: two pure readers over the JSON grammar (no imports, no state, no
* clock): `leadingJsonValues` (the complete values a text OPENS
* with, and the text after them) and `jsonPrefixOf` (the leaves of
* JSON text cut short, token by token, and the whole words of a
* string the cut falls inside).
* Role: core/ layer, `evidence/`. Asked by `evidenceIndex.ts` ·
* `readResult` — the ONE reading of a result that the evidence index
* and the inputs layer's declared-sources check
* (`resultCarries.ts` · `resultReader`) share.
* Emits: N/A.
*
* ## Why a served result is not always one JSON value
*
* The dispatch loop serves a tool's value as JSON and joins framework notes
* AFTER it — a stepped skill's step banner, an effect note, the repeated-call
* note (`stages/toolCalls.ts`); an MCP text result joins its content blocks
* with a newline (`mcp/toolResult.ts` · `readTextResult`); and a capped
* result's `head` is the first characters of the tool's JSON, cut mid-value
* (`toolResultCap.ts`). `JSON.parse` refuses all of these, and the text
* fallback tokenises `{"id":4417,"up":true}` into `id`, `:4417`, `up`,
* `:true` — so every number and boolean the tool returned read as ABSENT: the
* evidence gate's false flag on an answer that quoted one, and the
* declared-sources check's false `not-in-result` on a value the model took
* from one (then a needless ask). So the tool's JSON is read by the JSON
* grammar as far as it goes, and only what follows it is text.
*
* The extent of a value is found the way `findings/unsettled.ts` ·
* `leadingObjectOf` finds the served envelope — strings and their escapes
* skipped, brackets counted — and each span is parsed once, guarded.
*/
/** Characters that separate JSON tokens outside a string. */
const DELIMITERS = new Set(['{', '}', '[', ']', ',', ':', '"']);
/** JSON's number grammar — a literal that matches is a number. */
const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
const isSpace = (ch) => ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t';
function skipSpace(text, from) {
let at = from;
while (at < text.length && isSpace(text[at]))
at += 1;
return at;
}
/**
* The end (exclusive) of the bracketed JSON value that starts at `start` —
* strings and their escapes skipped, brackets counted; `-1` when the text ends
* before it closes.
*/
function valueEnd(text, start) {
let depth = 0;
let inString = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (inString) {
if (ch === '\\')
i += 1;
else if (ch === '"')
inString = false;
}
else if (ch === '"') {
inString = true;
}
else if (ch === '{' || ch === '[') {
depth += 1;
}
else if (ch === '}' || ch === ']') {
depth -= 1;
if (depth === 0)
return i + 1;
}
}
return -1;
}
/** `JSON.parse`, guarded — `{ ok: false }` for text that is not one JSON value. */
function parseOne(text) {
try {
return { ok: true, value: JSON.parse(text) };
}
catch {
return { ok: false };
}
}
/**
* The complete JSON values `text` OPENS with — an object or an array, then
* any further ones separated by whitespace (the blocks of an MCP text result)
* — and the text after the last of them (`rest`, which starts at its first
* non-space character; `''` when nothing follows). `values` is empty when the
* text does not open with a complete, parseable value.
*
* @example
* ```ts
* leadingJsonValues('{"id":4417}\n{"id":2210}\n\n[identical call: …]');
* // { values: [{ id: 4417 }, { id: 2210 }], rest: '[identical call: …]' }
* ```
*/
export function leadingJsonValues(text) {
const values = [];
let at = skipSpace(text, 0);
while (at < text.length && (text[at] === '{' || text[at] === '[')) {
const end = valueEnd(text, at);
if (end < 0)
break;
const read = parseOne(text.slice(at, end));
if (!read.ok)
break;
values.push(read.value);
at = skipSpace(text, end);
}
return { values, rest: text.slice(at) };
}
/**
* JSON text CUT SHORT — an object or an array whose text ends before it
* closes (a capped result's `head`, a result a tool truncated itself) — read
* by the JSON grammar as far as it goes: its complete leaves, in order, and,
* when the text ends inside a string, that string's whole words (`words`).
* The token the text ends inside is never a leaf — it may be cut (`4417` cut
* at `44` is not the value `44`) — and the word a cut string ends in is never
* one of its words (`mo` may be `more`).
*
* `undefined` when the text is not JSON cut short: it does not open with `{`
* or `[`, a token is not a JSON token (`[INFO] …`, `{level=info`) — the one
* the text ends inside not even the beginning of one — a closing bracket does
* not match, or the value CLOSES: a complete value that did not parse is not
* JSON at all. Such text is read as text.
*
* @example
* ```ts
* jsonPrefixOf('{"hosts":[{"id":4417,"up":true},{"id":22');
* // { leaves: ['hosts', 'id', '4417', 'up', 'true', 'id'] }
* jsonPrefixOf('{"id":4417,"msg":"disk full on host-7 and mo');
* // { leaves: ['id', '4417', 'msg'], words: 'disk full on host-7 and' }
* ```
*/
export function jsonPrefixOf(text) {
let at = skipSpace(text, 0);
if (text[at] !== '{' && text[at] !== '[')
return undefined;
const leaves = [];
const open = [];
while (at < text.length) {
const ch = text[at];
if (isSpace(ch) || ch === ',' || ch === ':') {
at += 1;
}
else if (ch === '{' || ch === '[') {
open.push(ch === '{' ? '}' : ']');
at += 1;
}
else if (ch === '}' || ch === ']') {
if (open.pop() !== ch || open.length === 0)
return undefined;
at += 1;
}
else if (ch === '"') {
const end = stringEnd(text, at);
if (end < 0) {
// The text ends inside this string: not a complete leaf — its whole words are text.
const words = wholeWordsOf(text.slice(at + 1));
return words === undefined ? { leaves } : { leaves, words };
}
const read = parseOne(text.slice(at, end));
if (!read.ok)
return undefined;
leaves.push(read.value);
at = end;
}
else {
let end = at;
while (end < text.length && !DELIMITERS.has(text[end]) && !isSpace(text[end])) {
end += 1;
}
const literal = text.slice(at, end);
// The text ends inside this literal: it may be cut — but only a JSON
// literal's beginning can be (`tr`, `-4`, `1.5e`); anything else is not JSON.
if (end === text.length)
return isLiteralStart(literal) ? { leaves } : undefined;
if (literal === 'true' || literal === 'false')
leaves.push(literal);
else if (JSON_NUMBER.test(literal))
leaves.push(String(Number(literal)));
else if (literal !== 'null')
return undefined;
at = end;
}
}
return { leaves };
}
/**
* The whole words of a string literal's body cut short (the characters after
* its opening quote): the body up to its LAST space, read as a JSON string. A
* space cannot sit inside an escape, so that part is complete; the word the
* cut may have split — and anything glued to it without a space — is left
* out. `undefined` when nothing but that word is left, or the part does not
* read as a JSON string (an escape the grammar refuses).
*/
function wholeWordsOf(body) {
const lastSpace = body.lastIndexOf(' ');
if (lastSpace < 0)
return undefined;
const read = parseOne(`"${body.slice(0, lastSpace)}"`);
if (!read.ok)
return undefined;
const words = read.value;
return words.trim() === '' ? undefined : words;
}
/** The beginning of a JSON number: `-`, `4`, `1.`, `1.5e`, `1.5e-`, `12e+3`. */
const NUMBER_START = /^-?(?:\d+(?:\.\d*)?(?:[eE][+-]?\d*)?)?$/;
/** Whether `literal` is the beginning of a JSON literal — a number, `true`, `false` or `null`, cut. */
function isLiteralStart(literal) {
return ('true'.startsWith(literal) ||
'false'.startsWith(literal) ||
'null'.startsWith(literal) ||
NUMBER_START.test(literal));
}
/** The end (exclusive) of the string literal opening at `start`; `-1` when the text ends inside it. */
function stringEnd(text, start) {
for (let i = start + 1; i < text.length; i += 1) {
const ch = text[i];
if (ch === '\\')
i += 1;
else if (ch === '"')
return i + 1;
}
return -1;
}
//# sourceMappingURL=servedJson.js.map