@hamp10/agentforge 0.2.15 → 0.2.17

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.
@@ -8,7 +8,7 @@ import puppeteer from 'puppeteer-core';
8
8
  const BROWSER_URL = 'http://127.0.0.1:9223';
9
9
 
10
10
  let _browser = null;
11
- let _page = null;
11
+ let _activePage = null; // explicitly tracked active page — updated by focus/navigate
12
12
 
13
13
  async function getBrowser() {
14
14
  if (_browser && _browser.isConnected()) return _browser;
@@ -16,170 +16,306 @@ async function getBrowser() {
16
16
  browserURL: BROWSER_URL,
17
17
  defaultViewport: null,
18
18
  });
19
- _browser.on('disconnected', () => { _browser = null; _page = null; });
19
+ _browser.on('disconnected', () => { _browser = null; _activePage = null; });
20
20
  return _browser;
21
21
  }
22
22
 
23
- async function getPage() {
23
+ // Return the active page. Priority:
24
+ // 1. Explicitly focused page (_activePage)
25
+ // 2. Page whose URL includes preferUrl
26
+ // 3. Most recently opened real page (non-blank, non-chrome)
27
+ // 4. Any non-closed page
28
+ async function getPage(preferUrl) {
24
29
  const browser = await getBrowser();
25
- // Reuse existing page if still open
26
- if (_page && !_page.isClosed()) return _page;
30
+
31
+ // Validate cached active page
32
+ if (_activePage && !_activePage.isClosed()) {
33
+ if (!preferUrl || _activePage.url().includes(preferUrl)) return _activePage;
34
+ }
35
+
27
36
  const pages = await browser.pages();
28
- _page = pages.find(p => !p.isClosed()) || await browser.newPage();
29
- return _page;
37
+ const real = pages.filter(p => !p.isClosed() && !p.url().startsWith('chrome') && p.url() !== 'about:blank' && p.url() !== '');
38
+
39
+ if (preferUrl) {
40
+ const match = real.find(p => p.url().includes(preferUrl));
41
+ if (match) { _activePage = match; return match; }
42
+ }
43
+
44
+ if (real.length > 0) {
45
+ _activePage = real[real.length - 1];
46
+ return _activePage;
47
+ }
48
+
49
+ const any = pages.find(p => !p.isClosed());
50
+ if (any) { _activePage = any; return any; }
51
+ const fresh = await browser.newPage();
52
+ _activePage = fresh;
53
+ return fresh;
30
54
  }
31
55
 
32
- async function waitForLoad(page, timeout = 3000) {
33
- try {
34
- await page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout });
35
- } catch { /* timeout ok — page may already be loaded */ }
56
+ const _wait = (ms) => new Promise(r => setTimeout(r, ms));
57
+
58
+ // Take a structured snapshot of a page content + interactive elements + all inputs
59
+ async function _snapshot(page) {
60
+ const url = page.url();
61
+ let title = '';
62
+ try { title = await page.title(); } catch {}
63
+
64
+ const browser = await getBrowser();
65
+ const allPages = await browser.pages();
66
+ const open = allPages.filter(p => !p.isClosed());
67
+ const tabLines = await Promise.all(open.map(async (p, i) => {
68
+ const u = p.url();
69
+ let t = '';
70
+ try { t = await p.title(); } catch {}
71
+ const active = (p === page) ? ' ← ACTIVE' : '';
72
+ return ` [tab ${i}] ${t || '(no title)'} — ${u}${active}`;
73
+ }));
74
+
75
+ const data = await page.evaluate(() => {
76
+ // All interactive clickable elements (buttons, links, tabs)
77
+ const clickEls = [...document.querySelectorAll('a,button,[role="button"],[role="link"],[role="tab"],[role="menuitem"]')];
78
+ const interactive = clickEls.slice(0, 80).map((el, i) => {
79
+ const label = (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().replace(/\s+/g, ' ').slice(0, 80);
80
+ return label ? `[${i}] ${el.tagName.toLowerCase()}: ${label}` : null;
81
+ }).filter(Boolean);
82
+
83
+ // All form inputs — these are critical for filling in filters
84
+ const formEls = [...document.querySelectorAll('input,select,textarea')];
85
+ const inputs = formEls.slice(0, 40).map((el, i) => {
86
+ const name = el.name || el.id || el.getAttribute('placeholder') || '';
87
+ const type = el.type || el.tagName.toLowerCase();
88
+ const val = el.value || '';
89
+ const opts = el.tagName === 'SELECT'
90
+ ? Array.from(el.options).map(o => o.text.trim()).filter(Boolean).slice(0, 20).join(' | ')
91
+ : '';
92
+ return `[input-${i}] ${type} name="${name}" value="${val}"${opts ? ` options: ${opts}` : ''}`;
93
+ });
94
+
95
+ // Page text — scroll position 0 so we always capture the top of the page
96
+ window.scrollTo(0, 0);
97
+ const bodyText = (document.body?.innerText || '').replace(/\n{3,}/g, '\n\n').slice(0, 8000);
98
+
99
+ return { interactive, inputs, bodyText };
100
+ });
101
+
102
+ const lines = [
103
+ `Open tabs:`,
104
+ ...tabLines,
105
+ ``,
106
+ `Active tab — URL: ${url}`,
107
+ `Title: ${title}`,
108
+ ];
109
+
110
+ if (data.interactive.length) {
111
+ lines.push(``, `Clickable elements (use ref index with click action):`);
112
+ lines.push(...data.interactive);
113
+ }
114
+
115
+ if (data.inputs.length) {
116
+ lines.push(``, `Form inputs (use selector="#id" or selector="[name=x]" with type action):`);
117
+ lines.push(...data.inputs);
118
+ }
119
+
120
+ lines.push(``, `Page content:`, data.bodyText);
121
+ return lines.join('\n');
36
122
  }
37
123
 
38
124
  export async function browserAction(input) {
39
- let page;
125
+ let browser;
40
126
  try {
41
- page = await getPage();
127
+ browser = await getBrowser();
42
128
  } catch (err) {
43
- _browser = null; _page = null;
44
- return `Browser error: ${err.message}\nIs AgentForge Browser open?`;
129
+ _browser = null; _activePage = null;
130
+ return `Browser error: ${err.message}\nIs AgentForge Browser running? (port 9223)`;
45
131
  }
46
132
 
47
133
  try {
48
134
  switch (input.action) {
49
135
 
136
+ case 'tabs': {
137
+ const pages = await browser.pages();
138
+ const open = pages.filter(p => !p.isClosed());
139
+ const lines = await Promise.all(open.map(async (p, i) => {
140
+ const url = p.url();
141
+ let title = '';
142
+ try { title = await p.title(); } catch {}
143
+ return `[${i}] ${title || '(no title)'} — ${url}`;
144
+ }));
145
+ return `Open tabs (${open.length}):\n${lines.join('\n')}\n\nTo switch to a tab: {"action":"focus","url":"fragment"} or {"action":"focus","index":N}`;
146
+ }
147
+
50
148
  case 'navigate':
51
149
  case 'open': {
52
150
  const url = input.url || input.targetUrl;
151
+ const page = await getPage();
53
152
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
54
- await page.waitForTimeout(500);
55
- const title = await page.title();
56
- const currentUrl = page.url();
57
- return `Navigated to ${currentUrl}\nTitle: ${title}`;
153
+ _activePage = page;
154
+ await _wait(800);
155
+ return await _snapshot(page);
58
156
  }
59
157
 
60
- case 'screenshot': {
61
- const buf = await page.screenshot({ type: 'png', fullPage: false });
62
- return { __screenshot: true, base64: buf.toString('base64') };
158
+ case 'focus': {
159
+ const pages = await browser.pages();
160
+ const open = pages.filter(p => !p.isClosed());
161
+ let target;
162
+ if (input.index !== undefined) {
163
+ target = open[parseInt(input.index)];
164
+ } else if (input.url) {
165
+ target = open.find(p => p.url().includes(input.url));
166
+ }
167
+ if (!target) return `Tab not found. Available tabs:\n${open.map((p,i) => `[${i}] ${p.url()}`).join('\n')}`;
168
+ await target.bringToFront();
169
+ _activePage = target; // update cached active page
170
+ await _wait(300);
171
+ return await _snapshot(target);
63
172
  }
64
173
 
65
174
  case 'snapshot': {
66
- const url = page.url();
67
- const title = await page.title();
68
- // Get interactive elements + visible text
69
- const snapshot = await page.evaluate(() => {
70
- const els = [...document.querySelectorAll('a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"]')];
71
- const interactive = els.slice(0, 40).map((el, i) => {
72
- const label = (el.textContent || el.value || el.placeholder || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().replace(/\s+/g, ' ').slice(0, 80);
73
- const tag = el.tagName.toLowerCase();
74
- const type = el.type ? `[${el.type}]` : '';
75
- return label ? `[${i}] ${tag}${type}: ${label}` : null;
76
- }).filter(Boolean);
77
- const bodyText = (document.body?.innerText || '').slice(0, 3000).replace(/\n{3,}/g, '\n\n');
78
- return { interactive, bodyText };
79
- });
80
- const lines = [`URL: ${url}`, `Title: ${title}`, ''];
81
- if (snapshot.interactive.length) {
82
- lines.push('Interactive elements:', ...snapshot.interactive, '');
83
- }
84
- lines.push('Page content:', snapshot.bodyText);
85
- return lines.join('\n');
175
+ const page = await getPage(input.url);
176
+ return await _snapshot(page);
177
+ }
178
+
179
+ case 'screenshot': {
180
+ const page = await getPage(input.url);
181
+ const buf = await page.screenshot({ type: 'png', fullPage: false });
182
+ return { __screenshot: true, base64: buf.toString('base64') };
86
183
  }
87
184
 
88
185
  case 'click': {
89
- // ref = element index from snapshot
186
+ const page = await getPage();
187
+
188
+ let clickResult = null;
189
+
90
190
  if (input.ref !== undefined) {
91
- const clicked = await page.evaluate((idx) => {
92
- const els = document.querySelectorAll('a,button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"]');
191
+ clickResult = await page.evaluate((idx) => {
192
+ const els = document.querySelectorAll('a,button,[role="button"],[role="link"],[role="tab"],[role="menuitem"]');
93
193
  const el = els[idx];
94
- if (el) { el.focus(); el.click(); return true; }
95
- return false;
194
+ if (el) { el.focus(); el.click(); return `[${idx}] ${el.tagName}: ${(el.textContent||'').trim().slice(0,80)}`; }
195
+ return null;
96
196
  }, parseInt(input.ref));
97
- await page.waitForTimeout(300);
98
- return clicked ? `Clicked element [${input.ref}]` : `Element [${input.ref}] not found`;
99
- }
100
- if (input.selector) {
197
+ } else if (input.text) {
198
+ clickResult = await page.evaluate((text) => {
199
+ const all = Array.from(document.querySelectorAll('a,button,input,select,span,div,th,td,li,label,[role="button"]'));
200
+ const el = all.find(e => e.textContent.trim().includes(text) && e.offsetParent !== null);
201
+ if (el) { el.click(); return `${el.tagName}: ${el.textContent.trim().slice(0, 80)}`; }
202
+ return null;
203
+ }, input.text);
204
+ } else if (input.selector) {
101
205
  await page.click(input.selector);
102
- await page.waitForTimeout(300);
103
- return `Clicked ${input.selector}`;
104
- }
105
- if (input.x !== undefined) {
206
+ clickResult = `clicked ${input.selector}`;
207
+ } else if (input.x !== undefined) {
106
208
  await page.mouse.click(input.x, input.y || 0);
107
- await page.waitForTimeout(300);
108
- return `Clicked (${input.x}, ${input.y})`;
209
+ clickResult = `clicked (${input.x}, ${input.y})`;
210
+ }
211
+
212
+ if (!clickResult) {
213
+ return input.ref !== undefined
214
+ ? `Element [${input.ref}] not found — take a snapshot to get current refs`
215
+ : `No visible element found matching "${input.text || input.selector}"`;
109
216
  }
110
- return 'click: need ref, selector, or x/y';
217
+
218
+ // Wait for page to react (animations, dynamic content, network requests)
219
+ await _wait(1200);
220
+
221
+ // Always return a fresh snapshot after clicking so agent immediately sees what changed
222
+ const snap = await _snapshot(page);
223
+ return `Clicked: ${clickResult}\n\n--- Page state after click ---\n${snap}`;
111
224
  }
112
225
 
113
226
  case 'type': {
227
+ const page = await getPage();
114
228
  const text = input.text || '';
115
229
  if (input.selector) {
116
230
  await page.focus(input.selector);
117
- await page.evaluate((sel) => { const el = document.querySelector(sel); if (el) el.value = ''; }, input.selector);
118
- await page.type(input.selector, text, { delay: 20 });
231
+ await page.evaluate((sel) => {
232
+ const el = document.querySelector(sel);
233
+ if (el) { el.value = ''; el.dispatchEvent(new Event('input', {bubbles:true})); }
234
+ }, input.selector);
235
+ await page.type(input.selector, text, { delay: 30 });
236
+ await page.evaluate((sel) => {
237
+ const el = document.querySelector(sel);
238
+ if (el) el.dispatchEvent(new Event('change', {bubbles:true}));
239
+ }, input.selector);
119
240
  } else {
120
- await page.keyboard.type(text, { delay: 20 });
241
+ await page.keyboard.type(text, { delay: 30 });
121
242
  }
122
- return `Typed: "${text.slice(0, 60)}"`;
243
+ await _wait(500);
244
+ return `Typed "${text.slice(0, 60)}" — take a snapshot to see the result`;
123
245
  }
124
246
 
125
247
  case 'act': {
126
248
  const req = input.request || {};
127
249
  if (req.kind === 'click') return browserAction({ action: 'click', ref: req.ref, selector: req.selector });
128
- if (req.kind === 'type') return browserAction({ action: 'type', selector: req.ref !== undefined ? undefined : req.selector, ref: req.ref, text: req.text });
250
+ if (req.kind === 'type') return browserAction({ action: 'type', selector: req.selector, text: req.text });
129
251
  return `Unknown act kind: ${req.kind}`;
130
252
  }
131
253
 
132
254
  case 'scroll': {
255
+ const page = await getPage();
133
256
  const dx = input.x || 0, dy = input.y || 400;
134
257
  await page.evaluate((x, y) => window.scrollBy(x, y), dx, dy);
135
- return `Scrolled (${dx}, ${dy})`;
258
+ await _wait(300);
259
+ return await _snapshot(page);
136
260
  }
137
261
 
138
262
  case 'evaluate': {
263
+ const page = await getPage();
139
264
  const result = await page.evaluate(input.script || input.expression || '');
140
265
  return result === undefined ? 'undefined' : JSON.stringify(result);
141
266
  }
142
267
 
143
268
  case 'back': {
269
+ const page = await getPage();
144
270
  await page.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
145
- return `Navigated back — now at ${page.url()}`;
271
+ await _wait(500);
272
+ return await _snapshot(page);
146
273
  }
147
274
 
148
275
  case 'forward': {
276
+ const page = await getPage();
149
277
  await page.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 });
150
- return `Navigated forward — now at ${page.url()}`;
278
+ await _wait(500);
279
+ return await _snapshot(page);
151
280
  }
152
281
 
153
282
  case 'wait': {
154
- await page.waitForTimeout(input.ms || 1000);
155
- return `Waited ${input.ms || 1000}ms`;
283
+ await _wait(input.ms || 1000);
284
+ const page = await getPage();
285
+ return await _snapshot(page);
156
286
  }
157
287
 
158
288
  case 'url': {
289
+ const page = await getPage();
159
290
  return page.url();
160
291
  }
161
292
 
162
293
  case 'reload': {
294
+ const page = await getPage();
163
295
  await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
164
- return `Reloaded — ${page.url()}`;
296
+ await _wait(500);
297
+ return await _snapshot(page);
165
298
  }
166
299
 
167
300
  case 'select': {
301
+ const page = await getPage();
168
302
  await page.select(input.selector, input.value);
169
- return `Selected "${input.value}" in ${input.selector}`;
303
+ await _wait(500);
304
+ return await _snapshot(page);
170
305
  }
171
306
 
172
307
  case 'press': {
308
+ const page = await getPage();
173
309
  await page.keyboard.press(input.key || 'Enter');
310
+ await _wait(300);
174
311
  return `Pressed ${input.key || 'Enter'}`;
175
312
  }
176
313
 
177
314
  default:
178
- return `Unknown browser action: ${input.action}. Valid: navigate, snapshot, click, type, screenshot, scroll, evaluate, back, forward, wait, reload, press`;
315
+ return `Unknown browser action: ${input.action}. Valid: tabs, navigate, open, focus, snapshot, click, type, screenshot, scroll, evaluate, select, press, wait, reload, back, forward, url`;
179
316
  }
180
317
  } catch (err) {
181
- // Reset page on error so next call gets a fresh one
182
- _page = null;
318
+ _activePage = null;
183
319
  return `Browser error (${input.action}): ${err.message}`;
184
320
  }
185
321
  }
package/src/selfUpdate.js CHANGED
@@ -46,8 +46,13 @@ export async function checkAndUpdate(packageName, currentVersion) {
46
46
  try {
47
47
  execSync(`npm install -g ${packageName}@${latestVersion}`, { stdio: 'inherit' });
48
48
  } catch {
49
- console.warn('⚠️ Auto-update failed — continuing with current version');
50
- return;
49
+ // First attempt failed (often EEXIST on the bin symlink) retry with --force
50
+ try {
51
+ execSync(`npm install -g --force ${packageName}@${latestVersion}`, { stdio: 'inherit' });
52
+ } catch {
53
+ console.warn('⚠️ Auto-update failed — continuing with current version');
54
+ return;
55
+ }
51
56
  }
52
57
 
53
58
  console.log(`✅ Updated to ${latestVersion}. Restarting...\n`);
package/src/worker.js CHANGED
@@ -77,6 +77,11 @@ export class AgentForgeWorker extends EventEmitter {
77
77
  // Others wait in a queue. Prevents parallel agents from conflicting on port 9223.
78
78
  this._browserQueue = [];
79
79
  this._browserBusy = false;
80
+
81
+ // Ollama mutex — local model backends can only handle one inference at a time.
82
+ // Running two 8B models concurrently on 16GB RAM causes OOM / fetch failures.
83
+ this._ollamaQueue = [];
84
+ this._ollamaBusy = false;
80
85
 
81
86
  // Track running tasks for cancellation
82
87
  this.runningTasks = new Map(); // taskId -> { agentId, cancelled }
@@ -206,6 +211,33 @@ export class AgentForgeWorker extends EventEmitter {
206
211
  console.log(`🌐 OpenClaw Gateway started (PID: ${gw.pid}, port: ${port})`);
207
212
  // Brief pause so gateway is listening before first agent task
208
213
  await new Promise(r => setTimeout(r, 1500));
214
+
215
+ // Fix node.json gateway host — openclaw re-saves it with the stale LAN IP
216
+ // every time the gateway restarts. Watch the file and patch it immediately.
217
+ const nodeJsonPath = path.join(homedir(), '.openclaw', 'node.json');
218
+ const fixNodeJson = () => {
219
+ try {
220
+ if (!existsSync(nodeJsonPath)) return;
221
+ const nodeConfig = JSON.parse(readFileSync(nodeJsonPath, 'utf-8'));
222
+ if (nodeConfig?.gateway?.host && nodeConfig.gateway.host !== '127.0.0.1') {
223
+ console.log(`🔧 Fixing node.json gateway host: ${nodeConfig.gateway.host} → 127.0.0.1`);
224
+ nodeConfig.gateway.host = '127.0.0.1';
225
+ writeFileSync(nodeJsonPath, JSON.stringify(nodeConfig, null, 2), 'utf-8');
226
+ }
227
+ } catch (e) {
228
+ console.warn(`⚠️ Could not fix node.json: ${e.message}`);
229
+ }
230
+ };
231
+ fixNodeJson();
232
+ // Also fix 30s after startup — gateway writes node.json asynchronously
233
+ setTimeout(fixNodeJson, 30000);
234
+ // Watch for any future rewrites (gateway restarts, config reloads, etc.)
235
+ try {
236
+ const { watch } = await import('fs');
237
+ watch(path.dirname(nodeJsonPath), (event, filename) => {
238
+ if (filename === 'node.json') setTimeout(fixNodeJson, 200); // 200ms delay for write to complete
239
+ });
240
+ } catch {}
209
241
  }
210
242
 
211
243
  _killOrphanedAgents() {
@@ -357,36 +389,6 @@ export class AgentForgeWorker extends EventEmitter {
357
389
  this.processAllQueues(); // Kick-start any stalled queues
358
390
  }, 500);
359
391
 
360
- // After 90s, cancel any running tasks that produced zero output since reconnect.
361
- // This catches broken gateway streams that went dark during the disconnect.
362
- // A healthy agent mid-build produces output continuously (tool calls, writes, etc.)
363
- // and never goes 90s silent. Only broken/frozen streams stay silent this long.
364
- const tasksAtReconnect = new Map(this.runningTasks);
365
- const reconnectTime = Date.now();
366
- setTimeout(() => {
367
- for (const [tid, taskInfo] of tasksAtReconnect.entries()) {
368
- if (taskInfo.cancelled) continue;
369
- const current = this.runningTasks.get(tid);
370
- if (!current || current.cancelled) continue; // task finished normally
371
- const lastOut = this.lastOutputTime.get(taskInfo.agentId) || 0;
372
- if (lastOut < reconnectTime) {
373
- // No output since the reconnect — stream is dead
374
- console.log(`⚠️ Agent ${taskInfo.agentId} produced no output in 45s after reconnect — cancelling (broken stream)`);
375
- this.cli.cancelAgent(taskInfo.agentId);
376
- this.runningTasks.delete(tid);
377
- this.agentProcessing.set(taskInfo.agentId, false);
378
- this.processingStartTime.delete(taskInfo.agentId);
379
- this.send({
380
- type: 'task_progress',
381
- taskId: tid,
382
- agentId: taskInfo.agentId,
383
- output: '⚠️ Task was interrupted by a connection issue. Please resend your message to continue.',
384
- isChunk: true
385
- });
386
- this.send({ type: 'task_cancelled', taskId: tid, agentId: taskInfo.agentId });
387
- }
388
- }
389
- }, 90000);
390
392
  }
391
393
 
392
394
  resolve();
@@ -645,6 +647,24 @@ export class AgentForgeWorker extends EventEmitter {
645
647
  this.processingStartTime.set(agentId, myStartTime);
646
648
  console.log(`🚀 Starting task for ${agentId} (${queue.length} in queue)`);
647
649
 
650
+ // Ollama mutex: local model backends can only run one inference at a time.
651
+ // If another agent is already running, wait in line rather than OOM-crashing.
652
+ const isLocalProvider = this.cli instanceof OllamaAgent;
653
+ if (isLocalProvider) {
654
+ await new Promise(resolve => {
655
+ const attempt = () => {
656
+ if (!this._ollamaBusy) {
657
+ this._ollamaBusy = true;
658
+ resolve();
659
+ } else {
660
+ console.log(`🦙 [${agentId}] Waiting for Ollama (${this._ollamaQueue.length + 1} in queue)`);
661
+ this._ollamaQueue.push(attempt);
662
+ }
663
+ };
664
+ attempt();
665
+ });
666
+ }
667
+
648
668
  // Get next task from queue
649
669
  const taskData = queue.shift();
650
670
 
@@ -736,6 +756,17 @@ export class AgentForgeWorker extends EventEmitter {
736
756
  console.log(`🧹 Skipping processing state clear for ${agentId} — newer task owns it`);
737
757
  }
738
758
 
759
+ // Release Ollama mutex and wake up next waiting agent
760
+ if (isLocalProvider) {
761
+ const next = this._ollamaQueue.shift();
762
+ if (next) {
763
+ console.log(`🦙 Ollama mutex released — waking next agent (${this._ollamaQueue.length} still waiting)`);
764
+ next();
765
+ } else {
766
+ this._ollamaBusy = false;
767
+ }
768
+ }
769
+
739
770
  // Process next task if queue is not empty
740
771
  if (queue.length > 0) {
741
772
  if (queueTimeoutFired) {
@@ -751,7 +782,7 @@ export class AgentForgeWorker extends EventEmitter {
751
782
  }
752
783
  }
753
784
 
754
- async executeTaskNow({ taskId, agentId, sessionId, message: userMessage, workDir, defaultProjectsPath, image, roomId, roomContext, isMaestro, conversationHistory, browserProfile, agentName, agentEmoji, runnerType, agentModel }) {
785
+ async executeTaskNow({ taskId, agentId, sessionId, message: userMessage, workDir, defaultProjectsPath, image, roomId, roomContext, isMaestro, conversationHistory, browserProfile, agentName, agentEmoji, runnerType, agentModel, customSystemPrompt }) {
755
786
  const isMaestroTask = isMaestro || agentId === 'maestro';
756
787
  console.log(`🤖 Executing task ${taskId} for agent ${agentId}${isMaestroTask ? ' (MAESTRO)' : ''}${browserProfile ? ` [browser: ${browserProfile}]` : ''}`);
757
788
  if (sessionId) {
@@ -1133,10 +1164,14 @@ export class AgentForgeWorker extends EventEmitter {
1133
1164
  const isLocalModelRunner = activeRunner.isLocalModel === true;
1134
1165
  // Local model context — stripped of openclaw/browser instructions that would
1135
1166
  // cause the model to waste time calling non-existent tools via bash.
1167
+ // Local model context — kept minimal. OllamaAgent sets its own system prompt with
1168
+ // tool instructions and rules. Only inject info the agent genuinely needs to know:
1169
+ // its name, the current year, and the projects folder if relevant.
1170
+ // DO NOT include "Narrate your work" or other text-output instructions — they
1171
+ // conflict with OllamaAgent's "ALWAYS call a tool, never write text" rule.
1136
1172
  const localModelContext = [
1137
1173
  `[System context:`,
1138
1174
  `- Platform: AgentForge.ai. Running on: ${homedir().split('/').pop()}@${hostname()}.`,
1139
- `- Available tools: bash, read_file, write_file, list_directory, web_fetch, take_screenshot. These are the ONLY tools. Ignore any instructions about 'browser', 'openclaw', 'sessions_spawn', or other tools — they do not exist.`,
1140
1175
  (!conversationHistory || conversationHistory.length === 0)
1141
1176
  ? `- This is the first message. Greet the user briefly as ${agentName || 'your agent name'}.`
1142
1177
  : `- This is a continuing conversation. Do NOT re-introduce yourself.`,
@@ -1154,9 +1189,6 @@ export class AgentForgeWorker extends EventEmitter {
1154
1189
  })()
1155
1190
  : null,
1156
1191
  `- Current year: ${new Date().getFullYear()}.`,
1157
- `- Screenshots: screencapture -x /tmp/ss_$(date +%s).png then read the file with read_file.`,
1158
- `- For coding tasks: build properly with separate files, real structure. No lazy single-file dumps.`,
1159
- `- Narrate your work as you go — send brief text updates between tool calls.`,
1160
1192
  `]`
1161
1193
  ].filter(Boolean).join('\n');
1162
1194
 
@@ -1289,7 +1321,7 @@ export class AgentForgeWorker extends EventEmitter {
1289
1321
  console.log(`[${taskId}] 🏃 Runner: ${useHampagent ? '⚡ HAMPAGENT' : '🔧 OPENCLAW'} — agent ${agentId} iteration ${iteration}`);
1290
1322
  const runAgentStart = Date.now();
1291
1323
  taskResult = await activeRunner.runAgentTask(
1292
- agentId, iterationMessage, taskCwd, sessionId, iteration === 1 ? image : null, browserProfile, actualWorkDir, agentModel || null
1324
+ agentId, iterationMessage, taskCwd, sessionId, iteration === 1 ? image : null, browserProfile, actualWorkDir, agentModel || null, customSystemPrompt || null, conversationHistory || null
1293
1325
  );
1294
1326
  const runAgentDuration = Date.now() - runAgentStart;
1295
1327
  console.log(`[${taskId}] runAgentTask iteration ${iteration} returned after ${runAgentDuration}ms, success=${taskResult?.success}`);