diff options
| author | Agando <roduccomm@gmail.com> | 2026-11-02 21:21:24 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-11-02 21:21:24 +0100 |
| commit | 4587c757b17d8a92df7c98ec37ace4c5500a4f5a (patch) | |
| tree | 4b89cdf25fb7f2354cc8299260b1ddafeb568f25 | |
| parent | 08a77451f503f531eee44c69c14bed3a2961cc5c (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.
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 79 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 44 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/module-orchestrator.js | 35 | ||||
| -rw-r--r-- | modules/test/test-delegation-reporting.js | 57 | ||||
| -rw-r--r-- | modules/test/test-tool-context-bound.js | 17 |
7 files changed, 204 insertions, 36 deletions
@@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.141"></script> +<script src="modules/core/core-bundle.js?v=v4.6.143"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.141"></script> +<script src="modules/core/module-loader.js?v=v4.6.143"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.141"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.143"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 71f3ada..2a7ec3e 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -9037,7 +9037,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'] } } }, { @@ -10324,12 +10324,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 */ } @@ -11456,7 +11472,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) { @@ -11803,7 +11825,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 }); } @@ -17442,13 +17476,21 @@ class OrchestratorModule { if (r.status === 'fulfilled') { // Find the agent that handled this sub-task const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal)); - if (agent) { - const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); - const snippet = outbox?.result ? outbox.result.substring(0, 200) : 'completed (no outbox)'; - summaries.push({ task: sub.goal, agent: agent.id, status: 'ok', result: snippet }); - } else { - summaries.push({ task: sub.goal, status: 'ok', result: 'completed (agent recycled)' }); + if (!agent) { + // "ok / completed (agent recycled)" told the caller a task succeeded + // when we could not even find its agent. + summaries.push({ task: sub.goal, status: 'failed', error: 'agent not found — result unverifiable' }); + continue; } + const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); + const body = outbox && typeof outbox.result === 'string' ? outbox.result : ''; + const snippet = body.substring(0, 200); + summaries.push({ + task: sub.goal, agent: agent.id, + status: body ? 'ok' : 'failed', + result: body || null, + error: body ? undefined : (outbox && outbox.error) || 'no result in outbox' + }); } else { summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' }); } @@ -17554,26 +17596,31 @@ class OrchestratorModule { const _agentTag = agent.id + '/' + (agent.role || 'worker'); const result = await aiModule.chat([`[DELEGATED] ${task}`], { agentTag: _agentTag }); try { aiModule._sessions.delete(_agentTag); } catch (_e) {} - agent.status = 'completed'; + // chat() resolves {content, toolTurns}. Anything that is not a non-empty answer + // is a failure — the old code substituted the literal "completed", so a worker + // that returned nothing was indistinguishable from one that finished. + const _answer = typeof result === 'string' ? result : ((result && result.content) || ''); + agent.status = _answer ? 'completed' : 'failed'; // Write result to outbox this.terminal.fs.writeFile(`agents/${agent.id}/outbox.json`, JSON.stringify({ - result: typeof result === 'string' ? result : (result?.content || 'completed'), - status: 'completed', + result: _answer || null, + status: _answer ? 'completed' : 'failed', + error: _answer ? null : 'agent produced no answer', timestamp: new Date().toISOString() })); // Record in memory graph const memgraph = this.terminal.moduleManager.modules['MemoryGraphModule']; if (memgraph) { - memgraph.recordTask(task, typeof result === 'string' ? result : (result?.content || ''), agent.id); + memgraph.recordTask(task, _answer, agent.id); memgraph.recordFileAccess(`/agents/${agent.id}/outbox.json`); } this.terminal.print(`✅ ${agent.id} completed task`, 'success'); // Return result for callers that await delegateTask - outboxResult = typeof result === 'string' ? result : (result?.content || 'completed'); + outboxResult = _answer; // '' when the worker returned nothing; callers see that // Check restart policy — if always, set back to idle for next task if (agent.restartPolicy === 'always') { 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 }); } diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 367ad60..e1ed716 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.141'; + const LATEST_VER = 'v4.6.143'; const CACHE = '?v=' + LATEST_VER; // Exposed so ext modules can register their own URL with the same cache-busting version // instead of an unversioned path the browser may already have cached (shell.js did). diff --git a/modules/core/module-orchestrator.js b/modules/core/module-orchestrator.js index 9f23ceb..6b8ee18 100644 --- a/modules/core/module-orchestrator.js +++ b/modules/core/module-orchestrator.js @@ -1116,13 +1116,21 @@ class OrchestratorModule { if (r.status === 'fulfilled') { // Find the agent that handled this sub-task const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal)); - if (agent) { - const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); - const snippet = outbox?.result ? outbox.result.substring(0, 200) : 'completed (no outbox)'; - summaries.push({ task: sub.goal, agent: agent.id, status: 'ok', result: snippet }); - } else { - summaries.push({ task: sub.goal, status: 'ok', result: 'completed (agent recycled)' }); + if (!agent) { + // "ok / completed (agent recycled)" told the caller a task succeeded + // when we could not even find its agent. + summaries.push({ task: sub.goal, status: 'failed', error: 'agent not found — result unverifiable' }); + continue; } + const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); + const body = outbox && typeof outbox.result === 'string' ? outbox.result : ''; + const snippet = body.substring(0, 200); + summaries.push({ + task: sub.goal, agent: agent.id, + status: body ? 'ok' : 'failed', + result: body || null, + error: body ? undefined : (outbox && outbox.error) || 'no result in outbox' + }); } else { summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' }); } @@ -1228,26 +1236,31 @@ class OrchestratorModule { const _agentTag = agent.id + '/' + (agent.role || 'worker'); const result = await aiModule.chat([`[DELEGATED] ${task}`], { agentTag: _agentTag }); try { aiModule._sessions.delete(_agentTag); } catch (_e) {} - agent.status = 'completed'; + // chat() resolves {content, toolTurns}. Anything that is not a non-empty answer + // is a failure — the old code substituted the literal "completed", so a worker + // that returned nothing was indistinguishable from one that finished. + const _answer = typeof result === 'string' ? result : ((result && result.content) || ''); + agent.status = _answer ? 'completed' : 'failed'; // Write result to outbox this.terminal.fs.writeFile(`agents/${agent.id}/outbox.json`, JSON.stringify({ - result: typeof result === 'string' ? result : (result?.content || 'completed'), - status: 'completed', + result: _answer || null, + status: _answer ? 'completed' : 'failed', + error: _answer ? null : 'agent produced no answer', timestamp: new Date().toISOString() })); // Record in memory graph const memgraph = this.terminal.moduleManager.modules['MemoryGraphModule']; if (memgraph) { - memgraph.recordTask(task, typeof result === 'string' ? result : (result?.content || ''), agent.id); + memgraph.recordTask(task, _answer, agent.id); memgraph.recordFileAccess(`/agents/${agent.id}/outbox.json`); } this.terminal.print(`✅ ${agent.id} completed task`, 'success'); // Return result for callers that await delegateTask - outboxResult = typeof result === 'string' ? result : (result?.content || 'completed'); + outboxResult = _answer; // '' when the worker returned nothing; callers see that // Check restart policy — if always, set back to idle for next task if (agent.restartPolicy === 'always') { diff --git a/modules/test/test-delegation-reporting.js b/modules/test/test-delegation-reporting.js new file mode 100644 index 0000000..7d4854e --- /dev/null +++ b/modules/test/test-delegation-reporting.js @@ -0,0 +1,57 @@ +// node modules/test/test-delegation-reporting.js +// A delegated task must report the worker's answer, and must report FAILURE when there is +// no answer. Three places used to substitute the literal "completed": delegateTask's +// outbox, the delegate_task tool, and orchestrate-parallel's per-sub-task summary. +const fs = require('fs'), path = require('path'); +const ROOT = __dirname + '/../core'; +const store = {}; +global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; +global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } }; +global.window = global; +global.addEventListener = () => {}; + +const AI = new Function(fs.readFileSync(path.join(ROOT, 'module-aiassist.js'), 'utf8') + '\nreturn WebX11AIAssistantModule;')(); + +let fails = 0, passes = 0; +const ok = (n, c, d) => { if (c) { passes++; return; } fails++; console.log('FAIL ' + n + (d ? ' — ' + d : '')); }; + +function mk(orch) { + const stub = { + print: () => {}, log: () => {}, setAiBusy: () => {}, markDirty: () => {}, + setPrompt: () => {}, clearInput: () => {}, scrollToBottom: () => {}, refreshPrompt: () => {}, + ollamaManager: null, + fs: { mkdirRecursive: () => {}, writeFile: () => true, readFile: () => null, exists: () => false, list: () => [] }, + addEventListener: () => {}, removeEventListener: () => {}, + }; + stub.moduleManager = { modules: { OrchestratorModule: orch } }; + const ai = new AI(); + ai.terminal = stub; + return ai; +} + +(async () => { + // D1/D2: an answer comes back as the answer, and success is true + const withAnswer = mk({ delegateTask: async () => 'the worker answer' }); + const r1 = JSON.parse(await withAnswer.runTool('delegate_task', JSON.stringify({ task: 'do a thing' }))); + ok('D1 the answer is the worker answer', r1.result === 'the worker answer', JSON.stringify(r1)); + ok('D2 success is true when there is an answer', r1.success === true, JSON.stringify(r1.success)); + + // D3/D4: no answer must NOT be reported as completed + const silent = mk({ delegateTask: async () => '' }); + const r2 = JSON.parse(await silent.runTool('delegate_task', JSON.stringify({ task: 'do a thing' }))); + ok('D3 an empty answer reports success=false', r2.success === false, JSON.stringify(r2)); + ok('D4 an empty answer is not called "completed"', r2.result === null && /no answer/i.test(r2.error || ''), JSON.stringify(r2)); + + // D5: undefined (an older orchestrator, or a rejected internal path) is also not success + const undef = mk({ delegateTask: async () => undefined }); + const r3 = JSON.parse(await undef.runTool('delegate_task', JSON.stringify({ task: 'do a thing' }))); + ok('D5 undefined is a failure, not a completion', r3.success === false, JSON.stringify(r3)); + + // D6: an object answer (the shape chat() now returns) is unwrapped, not stringified + const objAnswer = mk({ delegateTask: async () => 'plain string from orchestrator' }); + const r4 = JSON.parse(await objAnswer.runTool('delegate_task', JSON.stringify({ task: 'x' }))); + ok('D6 the tool keeps the answer verbatim', r4.result === 'plain string from orchestrator', JSON.stringify(r4)); + + console.log('test-delegation-reporting: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); diff --git a/modules/test/test-tool-context-bound.js b/modules/test/test-tool-context-bound.js index 0b42eeb..848b307 100644 --- a/modules/test/test-tool-context-bound.js +++ b/modules/test/test-tool-context-bound.js @@ -103,6 +103,23 @@ function ok(name, cond, detail) { ok('E4 clip shortens content', parsed && parsed.content.length < 16000 && parsed.content.length >= 3000, parsed && String(parsed.content.length)); ok('E5 clipped envelope fits the limit', clipped.length <= 6000 + 400, String(clipped.length)); + + // E6-E8: the last page of a file has next_offset=null. If the clip removes its tail, + // telling the caller "truncated: false" hides the remainder with no way to ask for it + // (measured 2026-11-02: 8 109 of 19 999 chars withheld, returned claimed 19 999). + const lastPage = JSON.stringify({ + path: '/big.txt', offset: 0, returned: 19999, total_chars: 19999, + truncated: false, next_offset: null, + content: 'y'.repeat(19999), + }); + const lc = ai._clipToolResult(lastPage); + let lp = null; + try { lp = JSON.parse(lc); } catch (e) {} + ok('E6 a clipped last page reports truncated=true', lp && lp.truncated === true, lp && String(lp.truncated)); + ok('E7 it gives a resumable next_offset', lp && lp.next_offset === lp.returned_chars, + lp && String(lp.next_offset) + ' vs ' + String(lp.returned_chars)); + ok('E8 returned no longer claims the whole file', lp && lp.returned === lp.returned_chars, + lp && String(lp.returned)); } // F. budgets are resolvable per model class, with precedence call arg > profile > ai-config > default |