@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.
- package/bin/agentforge.js +25 -2
- package/package.json +5 -1
- package/scripts/postinstall.js +62 -0
- package/src/OllamaAgent.js +938 -252
- package/src/hampagent/browser.js +209 -73
- package/src/selfUpdate.js +7 -2
- package/src/worker.js +68 -36
- package/templates/agent/AGENTFORGE.md +120 -0
package/src/hampagent/browser.js
CHANGED
|
@@ -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
|
|
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;
|
|
19
|
+
_browser.on('disconnected', () => { _browser = null; _activePage = null; });
|
|
20
20
|
return _browser;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
125
|
+
let browser;
|
|
40
126
|
try {
|
|
41
|
-
|
|
127
|
+
browser = await getBrowser();
|
|
42
128
|
} catch (err) {
|
|
43
|
-
_browser = null;
|
|
44
|
-
return `Browser error: ${err.message}\nIs AgentForge Browser
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 '
|
|
61
|
-
const
|
|
62
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
186
|
+
const page = await getPage();
|
|
187
|
+
|
|
188
|
+
let clickResult = null;
|
|
189
|
+
|
|
90
190
|
if (input.ref !== undefined) {
|
|
91
|
-
|
|
92
|
-
const els = document.querySelectorAll('a,button,
|
|
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
|
|
95
|
-
return
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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) => {
|
|
118
|
-
|
|
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:
|
|
241
|
+
await page.keyboard.type(text, { delay: 30 });
|
|
121
242
|
}
|
|
122
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
+
await _wait(500);
|
|
279
|
+
return await _snapshot(page);
|
|
151
280
|
}
|
|
152
281
|
|
|
153
282
|
case 'wait': {
|
|
154
|
-
await
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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}`);
|