aboutsummaryrefslogtreecommitdiff
path: root/modules/core/module-aiassist.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-02 21:21:24 +0100
committerAgando <roduccomm@gmail.com>2026-11-02 21:21:24 +0100
commit4587c757b17d8a92df7c98ec37ace4c5500a4f5a (patch)
tree4b89cdf25fb7f2354cc8299260b1ddafeb568f25 /modules/core/module-aiassist.js
parent08a77451f503f531eee44c69c14bed3a2961cc5c (diff)
fix(newos4): read_file pages were silently truncated; delegation no longer invents "completed"
Two defects found by re-deriving a report that claimed both were still open. read_file paging (module-aiassist.js) - A page larger than the result clip was clipped AFTER read_file had computed next_offset. On the last page next_offset is null, so the model was told truncated:false while its tail had been removed: measured 11 890 of 19 999 chars delivered, returned:19999, no pointer to the rest - 8 109 chars lost with no way to ask for them. * the clip now sets truncated=true and next_offset=offset+keep when it removes the tail of a page that had no continuation (and never splits a surrogate pair) * read_file caps a page at the result budget (min(pageCap, toolResultChars-200)), so a page is always delivered whole and next_offset is exactly contiguous Live v4.6.143: 19 999-char file -> page 11 800 (next_offset 11 800), page 2 8 199 (next_offset null): 11 800 + 8 199 = 19 999, nothing withheld. A model asking for limit:32000 is clamped to 11 800 and told truncated:true. The ~500-char symptom was the 500-char floor: a caller passing limit:200 gets 500, which is why targeted sed -n reads looked better than the tool. Delegation reporting (module-orchestrator.js, module-aiassist.js) - chat() returning {content} was already fixed in 8a9333b, but three other places still substituted the word "completed" for an absent answer, so the symptom survived: delegateTask's outbox wrote status:'completed' even when the worker returned nothing; the delegate_task tool returned {success:true, result:"completed"}; orchestrate-parallel summarised an unmatched agent as {status:'ok', result:'completed (agent recycled)'} and a missing outbox as "completed (no outbox)". - Now: an empty/undefined answer is status 'failed' with error 'agent produced no answer' / 'no result in outbox' / 'agent not found — result unverifiable', and the tool returns success:false with result:null. Tests: test-delegation-reporting.js 6 (RED 3/6 against HEAD: D3/D4 success:true, D5 the literal "completed"), test-tool-context-bound 26 with E6-E8 (RED 24/2: truncated:false, next_offset null). maintain 27/27.
Diffstat (limited to 'modules/core/module-aiassist.js')
-rw-r--r--modules/core/module-aiassist.js44
1 files changed, 39 insertions, 5 deletions
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index 09551c2..0a0ea71 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -162,7 +162,7 @@ buildSystemPrompt() {
parameters: { type: 'object', properties: {
path: { type: 'string', description: 'File path' },
offset: { type: 'integer', description: 'Start character offset (default 0)' },
- limit: { type: 'integer', description: 'Max characters per page (default 8000). The effective page size follows the current model\'s budget and is capped again before the result enters context — the response carries returned_chars/total_chars/truncated/next_offset, page on those' }
+ limit: { type: 'integer', description: 'Max characters per page (defaults to the active model page budget, ~12k for a large model). A page is never larger than the result clip, so next_offset is always the next unseen character and is capped again before the result enters context — the response carries returned_chars/total_chars/truncated/next_offset, page on those' }
}, required: ['path'] }
}
}, {
@@ -1449,12 +1449,28 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
try {
const obj = JSON.parse(text);
if (obj && typeof obj.content === 'string' && obj.content.length > 0) {
- const keep = Math.max(500, max - 200); // headroom for the other keys
+ // headroom for the other keys, and a whole character (never half a
+ // surrogate pair, which would make JSON.stringify emit a bad body)
+ let keep = Math.max(500, max - 200);
+ if (keep < obj.content.length) {
+ const c = obj.content.charCodeAt(keep - 1);
+ if (c >= 0xD800 && c <= 0xDBFF) keep -= 1;
+ }
+ const hadMore = (obj.next_offset !== null && obj.next_offset !== undefined);
+ const full = obj.content.length;
obj.returned_chars = keep;
+ obj.returned = keep;
obj.content = obj.content.slice(0, keep) +
- '\n…[clipped: ' + (obj.content.length - keep) + ' of ' + obj.content.length +
+ '\n…[clipped: ' + (full - keep) + ' of ' + full +
' chars withheld — re-read with offset/limit if you need the rest]';
obj.clipped = true;
+ // A page that was already the last one has no next_offset to keep. If
+ // the tail is removed anyway, the caller must still be told there is
+ // more, or it concludes it has the whole file (measured 2026-11-02).
+ if (!hadMore) {
+ obj.next_offset = (Number(obj.offset) || 0) + keep;
+ obj.truncated = true;
+ }
return JSON.stringify(obj);
}
} catch (e) { /* not JSON → fall through to the string clip */ }
@@ -2581,7 +2597,13 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const total = content.length;
let offset = Math.max(0, Math.min(parseInt(a.offset, 10) || 0, total));
const pageCap = Math.max(500, Math.min(this._toolLimit('readPageChars'), 200000));
- const limit = Math.max(500, Math.min(parseInt(a.limit, 10) || 8000, pageCap));
+ // A page bigger than the result budget gets clipped on the way out,
+ // and a clipped page's tail is invisible to the model. Cap the page
+ // at what the clip will keep (200 = headroom for the envelope keys)
+ // so a page is always shown whole and next_offset stays contiguous.
+ const resultRoom = Math.max(500, this._toolLimit('toolResultChars') - 200);
+ const defaultPage = Math.min(pageCap, resultRoom);
+ const limit = Math.max(500, Math.min(parseInt(a.limit, 10) || defaultPage, pageCap, resultRoom));
// Never split a surrogate pair (emoji/CJK-ext) across pages:
// a lone surrogate makes JSON.stringify emit an invalid body.
if (offset > 0) {
@@ -2928,7 +2950,19 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
if (!orch) return JSON.stringify({ error: 'Orchestrator not available' });
try {
const result = await orch.delegateTask(task);
- return JSON.stringify({ success: true, task: task.substring(0, 80), result: typeof result === 'string' ? result.substring(0, 500) : 'completed' });
+ // undefined/null/{} must all read as "no answer" — JSON.stringify(undefined)
+ // is undefined, and JSON.stringify({}) is a truthy '{}'.
+ const answer = typeof result === 'string'
+ ? result
+ : ((result && typeof result.content === 'string') ? result.content : '');
+ // Never invent "completed": an empty answer is reported as a failure
+ // so the caller (model or human) knows nothing came back.
+ return JSON.stringify({
+ success: !!answer,
+ task: task.substring(0, 80),
+ result: answer ? answer.substring(0, 500) : null,
+ error: answer ? undefined : 'worker returned no answer'
+ });
} catch (e) {
return JSON.stringify({ error: 'Delegation failed: ' + e.message });
}