aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/core/core-bundle.js79
-rw-r--r--modules/core/module-aiassist.js44
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-orchestrator.js35
-rw-r--r--modules/test/test-delegation-reporting.js57
-rw-r--r--modules/test/test-tool-context-bound.js17
6 files changed, 201 insertions, 33 deletions
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