@hamp10/agentforge 0.2.0 → 0.2.2
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 +249 -39
- package/package.json +1 -1
- package/src/OllamaAgent.js +257 -34
- package/src/supervisor.js +7 -0
- package/src/worker.js +2 -2
package/bin/agentforge.js
CHANGED
|
@@ -54,7 +54,7 @@ function saveConfig(config) {
|
|
|
54
54
|
program
|
|
55
55
|
.name('agentforge')
|
|
56
56
|
.description('AgentForge worker - connect your machine to agentforge.ai')
|
|
57
|
-
.version('0.1
|
|
57
|
+
.version('0.2.1');
|
|
58
58
|
|
|
59
59
|
program
|
|
60
60
|
.command('login')
|
|
@@ -274,9 +274,9 @@ program
|
|
|
274
274
|
const worker = new AgentForgeWorker(config.token, wsUrl, config);
|
|
275
275
|
|
|
276
276
|
// Graceful shutdown
|
|
277
|
-
process.on('SIGINT',
|
|
278
|
-
process.on('SIGTERM', () => { console.log('\n[SIGTERM received]'); worker.shutdown(); });
|
|
279
|
-
process.on('SIGHUP',
|
|
277
|
+
process.on('SIGINT', () => { console.log('\n[SIGINT received — stopping]'); worker.shutdown(0); }); // Ctrl+C: clean stop
|
|
278
|
+
process.on('SIGTERM', () => { console.log('\n[SIGTERM received — restarting]'); worker.shutdown(1); }); // kill: supervisor restarts
|
|
279
|
+
process.on('SIGHUP', () => { console.log('\n[SIGHUP received — restarting]'); worker.shutdown(1); }); // terminal close: supervisor restarts
|
|
280
280
|
|
|
281
281
|
try {
|
|
282
282
|
await worker.initialize();
|
|
@@ -450,66 +450,276 @@ program
|
|
|
450
450
|
console.log('');
|
|
451
451
|
});
|
|
452
452
|
|
|
453
|
+
program
|
|
454
|
+
.command('doctor')
|
|
455
|
+
.description('Check your AgentForge worker health — diagnose any issues')
|
|
456
|
+
.action(async () => {
|
|
457
|
+
console.log('');
|
|
458
|
+
console.log('🩺 AgentForge Doctor');
|
|
459
|
+
console.log('================================');
|
|
460
|
+
console.log('');
|
|
461
|
+
|
|
462
|
+
let allGood = true;
|
|
463
|
+
|
|
464
|
+
// 1. Authentication
|
|
465
|
+
const config = loadConfig();
|
|
466
|
+
if (config.token) {
|
|
467
|
+
console.log('✅ Authenticated');
|
|
468
|
+
} else {
|
|
469
|
+
console.log('❌ Not authenticated');
|
|
470
|
+
console.log(' Fix: agentforge login');
|
|
471
|
+
allGood = false;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// 2. Server reachability
|
|
475
|
+
const serverUrl = config.url || 'https://agentforgeai-production.up.railway.app';
|
|
476
|
+
try {
|
|
477
|
+
const res = await fetch(`${serverUrl}/api/wsstatus`, { signal: AbortSignal.timeout(5000) });
|
|
478
|
+
if (res.ok || res.status === 401 || res.status === 403) {
|
|
479
|
+
console.log(`✅ Server reachable (${serverUrl})`);
|
|
480
|
+
} else {
|
|
481
|
+
console.log(`⚠️ Server returned ${res.status}`);
|
|
482
|
+
}
|
|
483
|
+
} catch (err) {
|
|
484
|
+
console.log(`❌ Cannot reach server: ${err.message}`);
|
|
485
|
+
allGood = false;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 3. AI Backend
|
|
489
|
+
if (config.provider === 'local') {
|
|
490
|
+
const localUrl = config.localUrl || 'http://localhost:11434';
|
|
491
|
+
try {
|
|
492
|
+
const res = await fetch(`${localUrl}/v1/models`, { signal: AbortSignal.timeout(5000) });
|
|
493
|
+
if (res.ok) {
|
|
494
|
+
const data = await res.json();
|
|
495
|
+
const models = (data.data ?? data.models ?? []).map(m => m.id || m.name);
|
|
496
|
+
const configured = config.localModel || '(not set)';
|
|
497
|
+
const found = models.includes(configured);
|
|
498
|
+
console.log(`✅ Local model server running (${localUrl})`);
|
|
499
|
+
console.log(` Configured model: ${configured} ${found ? '✅' : '⚠️ (not found in model list)'}`);
|
|
500
|
+
if (!found && models.length > 0) {
|
|
501
|
+
console.log(` Available models: ${models.slice(0, 5).join(', ')}`);
|
|
502
|
+
console.log(` Fix: agentforge local --model ${models[0]}`);
|
|
503
|
+
allGood = false;
|
|
504
|
+
}
|
|
505
|
+
} else {
|
|
506
|
+
console.log(`❌ Local server at ${localUrl} returned ${res.status}`);
|
|
507
|
+
allGood = false;
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
console.log(`❌ Local server not running at ${localUrl}`);
|
|
511
|
+
console.log(` Make sure Ollama/LM Studio/Jan is running, then: agentforge start`);
|
|
512
|
+
allGood = false;
|
|
513
|
+
}
|
|
514
|
+
} else if (OpenClawCLI.isAvailable()) {
|
|
515
|
+
console.log('✅ openclaw backend available');
|
|
516
|
+
} else {
|
|
517
|
+
// Auto-detect Ollama
|
|
518
|
+
try {
|
|
519
|
+
const res = await fetch('http://localhost:11434/v1/models', { signal: AbortSignal.timeout(2000) });
|
|
520
|
+
if (res.ok) {
|
|
521
|
+
const data = await res.json();
|
|
522
|
+
const models = (data.data ?? data.models ?? []).map(m => m.id || m.name);
|
|
523
|
+
console.log(`⚠️ Ollama is running but not configured as your backend`);
|
|
524
|
+
if (models.length > 0) console.log(` Models available: ${models.slice(0, 5).join(', ')}`);
|
|
525
|
+
console.log(` Fix: agentforge local --model ${models[0] || 'llama3.1:8b'}`);
|
|
526
|
+
allGood = false;
|
|
527
|
+
}
|
|
528
|
+
} catch {
|
|
529
|
+
console.log('❌ No AI backend configured');
|
|
530
|
+
console.log(' Fix: install Ollama (https://ollama.ai) then: agentforge local --model llama3.1:8b');
|
|
531
|
+
allGood = false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 4. Worker running
|
|
536
|
+
const supervisorPidFile = path.join(CONFIG_DIR, 'supervisor.pid');
|
|
537
|
+
const workerPidFile = path.join(CONFIG_DIR, 'worker.pid');
|
|
538
|
+
if (fs.existsSync(supervisorPidFile)) {
|
|
539
|
+
const pid = parseInt(fs.readFileSync(supervisorPidFile, 'utf8').trim());
|
|
540
|
+
try {
|
|
541
|
+
process.kill(pid, 0);
|
|
542
|
+
const wpid = fs.existsSync(workerPidFile) ? fs.readFileSync(workerPidFile, 'utf8').trim() : null;
|
|
543
|
+
console.log(`✅ Worker running (supervisor PID ${pid}${wpid ? ', worker PID ' + wpid : ''})`);
|
|
544
|
+
} catch {
|
|
545
|
+
console.log('⚠️ Supervisor PID file exists but process is not running');
|
|
546
|
+
console.log(' Fix: agentforge start');
|
|
547
|
+
allGood = false;
|
|
548
|
+
}
|
|
549
|
+
} else {
|
|
550
|
+
console.log('⚠️ Worker not running');
|
|
551
|
+
console.log(' Fix: agentforge start');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
console.log('');
|
|
555
|
+
if (allGood) {
|
|
556
|
+
console.log('✅ Everything looks healthy!');
|
|
557
|
+
} else {
|
|
558
|
+
console.log('Fix the issues above, then run: agentforge start');
|
|
559
|
+
}
|
|
560
|
+
console.log('');
|
|
561
|
+
});
|
|
562
|
+
|
|
453
563
|
program
|
|
454
564
|
.command('setup')
|
|
455
|
-
.description('
|
|
565
|
+
.description('Interactive setup wizard — gets AgentForge running in minutes')
|
|
456
566
|
.option('--tailscale-key <key>', 'Tailscale auth key (from tailscale.com/admin/settings/keys)')
|
|
457
567
|
.action(async (options) => {
|
|
458
568
|
const { execSync, spawnSync } = await import('child_process');
|
|
569
|
+
const readline = await import('readline');
|
|
570
|
+
|
|
571
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
572
|
+
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
|
|
459
573
|
|
|
460
574
|
console.log('');
|
|
461
|
-
console.log('🚀 AgentForge
|
|
575
|
+
console.log('🚀 AgentForge Setup');
|
|
462
576
|
console.log('================================');
|
|
463
|
-
console.log('');
|
|
577
|
+
console.log('Getting your machine ready to run AI agents.\n');
|
|
464
578
|
|
|
465
|
-
// Step 1:
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
579
|
+
// ── Step 1: Authentication ──────────────────────────────────────────────
|
|
580
|
+
const config = loadConfig();
|
|
581
|
+
if (config.token) {
|
|
582
|
+
console.log('✅ Step 1/2: Already logged in\n');
|
|
583
|
+
} else {
|
|
584
|
+
console.log('Step 1/2: Log in to AgentForge\n');
|
|
585
|
+
console.log('A browser window will open — log in there and come back.\n');
|
|
586
|
+
rl.close();
|
|
587
|
+
|
|
588
|
+
// Spawn login as a child process with inherited stdio so the interactive
|
|
589
|
+
// OAuth flow works (readline on stdin, browser opens, polling, etc.)
|
|
590
|
+
const { spawnSync: sp } = await import('child_process');
|
|
591
|
+
const loginResult = sp(process.execPath, [process.argv[1], 'login'], { stdio: 'inherit' });
|
|
592
|
+
if (loginResult.status !== 0) {
|
|
593
|
+
console.error('\n❌ Login failed — run: agentforge login');
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
470
596
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
597
|
+
// Re-open readline for the rest of setup
|
|
598
|
+
const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
599
|
+
Object.assign(rl, rl2); // replace for remaining ask() calls (won't be used further)
|
|
600
|
+
console.log('');
|
|
601
|
+
}
|
|
476
602
|
|
|
477
|
-
// Step
|
|
478
|
-
console.log('Step
|
|
479
|
-
console.log('');
|
|
603
|
+
// ── Step 2: AI Backend ──────────────────────────────────────────────────
|
|
604
|
+
console.log('Step 2/2: AI Backend\n');
|
|
480
605
|
|
|
481
|
-
const
|
|
606
|
+
const freshConfig = loadConfig();
|
|
482
607
|
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
} else {
|
|
486
|
-
console.log('Installing Tailscale...');
|
|
608
|
+
if (freshConfig.provider === 'local') {
|
|
609
|
+
const localUrl = freshConfig.localUrl || 'http://localhost:11434';
|
|
487
610
|
try {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
611
|
+
const res = await fetch(`${localUrl}/v1/models`, { signal: AbortSignal.timeout(3000) });
|
|
612
|
+
if (res.ok) {
|
|
613
|
+
const data = await res.json();
|
|
614
|
+
const models = (data.data ?? data.models ?? []).map(m => m.id || m.name);
|
|
615
|
+
console.log(`✅ Already configured: ${localUrl} with model "${freshConfig.localModel}"`);
|
|
616
|
+
if (models.length > 0 && !models.includes(freshConfig.localModel)) {
|
|
617
|
+
console.log(` ⚠️ Model "${freshConfig.localModel}" not found. Available: ${models.slice(0, 5).join(', ')}`);
|
|
618
|
+
}
|
|
619
|
+
console.log('');
|
|
620
|
+
}
|
|
621
|
+
} catch {
|
|
622
|
+
console.log(`⚠️ Configured local server at ${localUrl} is not reachable. Make sure it's running.\n`);
|
|
623
|
+
}
|
|
624
|
+
} else if (OpenClawCLI.isAvailable()) {
|
|
625
|
+
console.log('✅ openclaw detected and ready\n');
|
|
626
|
+
} else {
|
|
627
|
+
// Probe all common local model servers
|
|
628
|
+
const probes = [
|
|
629
|
+
{ name: 'Ollama', url: 'http://localhost:11434' },
|
|
630
|
+
{ name: 'LM Studio', url: 'http://localhost:1234' },
|
|
631
|
+
{ name: 'Jan', url: 'http://localhost:1337' },
|
|
632
|
+
{ name: 'llama.cpp', url: 'http://localhost:8080' },
|
|
633
|
+
{ name: 'vLLM', url: 'http://localhost:8000' },
|
|
634
|
+
];
|
|
635
|
+
|
|
636
|
+
const found = [];
|
|
637
|
+
process.stdout.write('Detecting local model servers...');
|
|
638
|
+
for (const probe of probes) {
|
|
639
|
+
try {
|
|
640
|
+
const res = await fetch(`${probe.url}/v1/models`, { signal: AbortSignal.timeout(1500) });
|
|
641
|
+
if (res.ok) {
|
|
642
|
+
const data = await res.json();
|
|
643
|
+
const models = (data.data ?? data.models ?? []).map(m => m.id || m.name).filter(Boolean);
|
|
644
|
+
found.push({ ...probe, models });
|
|
645
|
+
}
|
|
646
|
+
} catch {}
|
|
647
|
+
}
|
|
648
|
+
console.log('');
|
|
649
|
+
|
|
650
|
+
if (found.length === 0) {
|
|
651
|
+
console.log('');
|
|
652
|
+
console.log('No local model server detected.\n');
|
|
653
|
+
console.log('AgentForge needs an AI model to power your agents. The easiest option is Ollama:\n');
|
|
654
|
+
console.log(' 1. Go to https://ollama.ai and install it');
|
|
655
|
+
console.log(' 2. Run: ollama pull llama3.1:8b');
|
|
656
|
+
console.log(' 3. Run: agentforge setup (come back here when done)');
|
|
657
|
+
console.log('');
|
|
658
|
+
console.log('You can also use LM Studio, Jan, llama.cpp, or openclaw.');
|
|
659
|
+
rl.close();
|
|
660
|
+
process.exit(0);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Let user pick if multiple found, or auto-select if only one
|
|
664
|
+
let chosen = found[0];
|
|
665
|
+
if (found.length > 1) {
|
|
666
|
+
console.log('\nFound these model servers:\n');
|
|
667
|
+
found.forEach((b, i) => {
|
|
668
|
+
console.log(` ${i + 1}. ${b.name} (${b.url}) — ${b.models.length} model(s): ${b.models.slice(0, 3).join(', ')}`);
|
|
669
|
+
});
|
|
670
|
+
const ans = await ask(`\nWhich one to use? [1]: `);
|
|
671
|
+
const idx = parseInt(ans.trim()) - 1;
|
|
672
|
+
chosen = found[isNaN(idx) || idx < 0 || idx >= found.length ? 0 : idx];
|
|
673
|
+
} else {
|
|
674
|
+
console.log(`\nFound: ${chosen.name} (${chosen.url}) with ${chosen.models.length} model(s)`);
|
|
492
675
|
}
|
|
676
|
+
|
|
677
|
+
// Pick model
|
|
678
|
+
let model = chosen.models[0] || 'llama3.1:8b';
|
|
679
|
+
if (chosen.models.length > 1) {
|
|
680
|
+
console.log(`\nAvailable models: ${chosen.models.join(', ')}`);
|
|
681
|
+
const ans = await ask(`Model to use [${model}]: `);
|
|
682
|
+
if (ans.trim()) model = ans.trim();
|
|
683
|
+
} else if (chosen.models.length === 1) {
|
|
684
|
+
console.log(`Using model: ${model}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const newConfig = loadConfig();
|
|
688
|
+
newConfig.provider = 'local';
|
|
689
|
+
newConfig.localUrl = chosen.url;
|
|
690
|
+
newConfig.localModel = model;
|
|
691
|
+
saveConfig(newConfig);
|
|
692
|
+
console.log(`\n✅ Configured ${chosen.name} with model "${model}"\n`);
|
|
493
693
|
}
|
|
494
694
|
|
|
695
|
+
rl.close();
|
|
696
|
+
|
|
697
|
+
// ── Optional: Tailscale (only shown if installed or key provided) ────────
|
|
698
|
+
const tailscaleInstalled = spawnSync('which', ['tailscale'], { encoding: 'utf-8' }).status === 0;
|
|
495
699
|
if (options.tailscaleKey) {
|
|
496
700
|
try {
|
|
497
701
|
execSync(`sudo tailscale up --authkey=${options.tailscaleKey}`, { stdio: 'inherit' });
|
|
498
|
-
console.log('✅ Tailscale connected');
|
|
499
702
|
const result = spawnSync('tailscale', ['ip', '--4'], { encoding: 'utf-8' });
|
|
500
|
-
|
|
501
|
-
} catch
|
|
502
|
-
console.
|
|
703
|
+
console.log(`✅ Tailscale connected${result.stdout ? ' — IP: ' + result.stdout.trim() : ''}`);
|
|
704
|
+
} catch {
|
|
705
|
+
console.log('⚠️ Tailscale key failed — run: sudo tailscale up');
|
|
503
706
|
}
|
|
504
|
-
} else {
|
|
505
|
-
console.log('
|
|
506
|
-
console.log(' sudo tailscale up');
|
|
507
|
-
console.log(' (or: agentforge setup --tailscale-key <key>)');
|
|
508
|
-
console.log(' Get a key at: tailscale.com/admin/settings/keys');
|
|
707
|
+
} else if (tailscaleInstalled) {
|
|
708
|
+
console.log('✅ Tailscale installed (run "sudo tailscale up" to connect remotely)');
|
|
509
709
|
}
|
|
510
710
|
|
|
511
711
|
console.log('');
|
|
512
|
-
console.log('
|
|
712
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
713
|
+
console.log('✅ Setup complete!');
|
|
714
|
+
console.log('');
|
|
715
|
+
console.log(' Run this to start your worker:');
|
|
716
|
+
console.log('');
|
|
717
|
+
console.log(' agentforge start');
|
|
718
|
+
console.log('');
|
|
719
|
+
console.log(' Run this to check your health:');
|
|
720
|
+
console.log('');
|
|
721
|
+
console.log(' agentforge doctor');
|
|
722
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
513
723
|
console.log('');
|
|
514
724
|
});
|
|
515
725
|
|
package/package.json
CHANGED
package/src/OllamaAgent.js
CHANGED
|
@@ -79,6 +79,28 @@ const TOOLS = [
|
|
|
79
79
|
required: ['url']
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
type: 'function',
|
|
85
|
+
function: {
|
|
86
|
+
name: 'take_screenshot',
|
|
87
|
+
description: 'Take a screenshot of the current screen or the agent browser (port 9223). Returns base64 image data you can analyze visually. Use this to check what a webpage looks like, verify a build result, or monitor a running process.',
|
|
88
|
+
parameters: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
target: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
enum: ['screen', 'browser'],
|
|
94
|
+
description: 'screen = full screen capture. browser = screenshot of the agent browser (port 9223).'
|
|
95
|
+
},
|
|
96
|
+
url: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: 'Optional: navigate the browser to this URL before taking the screenshot.'
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
required: ['target']
|
|
102
|
+
}
|
|
103
|
+
}
|
|
82
104
|
}
|
|
83
105
|
];
|
|
84
106
|
|
|
@@ -146,25 +168,40 @@ export class OllamaAgent extends EventEmitter {
|
|
|
146
168
|
console.log(` Task: ${task}`);
|
|
147
169
|
console.log(` Working dir: ${workDir}`);
|
|
148
170
|
|
|
171
|
+
// Detect model capabilities
|
|
172
|
+
const isQwen3 = this.model.startsWith('qwen3');
|
|
173
|
+
const isVision = /vl|vision|llava|minicpm-v|moondream/i.test(this.model);
|
|
174
|
+
|
|
149
175
|
try {
|
|
150
176
|
// Load conversation history from disk (session persistence)
|
|
151
177
|
const history = this._loadHistory(agentId, workDir, sessionId);
|
|
152
178
|
|
|
179
|
+
const systemPrompt = [
|
|
180
|
+
`You are an AI agent running on AgentForge.ai.`,
|
|
181
|
+
`Your working directory is: ${workDir}`,
|
|
182
|
+
``,
|
|
183
|
+
`CRITICAL RULES — follow these exactly:`,
|
|
184
|
+
`1. Use the provided tools to complete the task. Do NOT write Python code, pseudo-code, or code blocks to simulate tool calls.`,
|
|
185
|
+
`2. To run a command, call the "bash" tool. To read a file, call "read_file". To write, call "write_file". To take a screenshot, call "take_screenshot".`,
|
|
186
|
+
`3. Every action must be a real tool call — not described in text, not shown as code.`,
|
|
187
|
+
`4. When you take a screenshot, you will receive the actual image back and can see it.`,
|
|
188
|
+
`5. When you are done, write a clear summary of what you accomplished.`,
|
|
189
|
+
`6. Do not ask for clarification — make your best judgment and act.`,
|
|
190
|
+
].join('\n');
|
|
191
|
+
|
|
153
192
|
const messages = [
|
|
154
|
-
{
|
|
155
|
-
role: 'system',
|
|
156
|
-
content: [
|
|
157
|
-
`You are an AI agent running on AgentForge.ai.`,
|
|
158
|
-
`Your working directory is: ${workDir}`,
|
|
159
|
-
`Use the available tools to complete the task autonomously.`,
|
|
160
|
-
`When you are done, write a clear summary of what you accomplished.`,
|
|
161
|
-
`Do not ask for clarification — make your best judgment and act.`
|
|
162
|
-
].join('\n')
|
|
163
|
-
},
|
|
193
|
+
{ role: 'system', content: systemPrompt },
|
|
164
194
|
...history,
|
|
165
|
-
{ role: 'user', content: task }
|
|
166
195
|
];
|
|
167
196
|
|
|
197
|
+
// Attach initial image to user message if provided
|
|
198
|
+
const userMessage = { role: 'user', content: task };
|
|
199
|
+
if (image && isVision) {
|
|
200
|
+
const base64 = image.replace(/^data:image\/\w+;base64,/, '');
|
|
201
|
+
userMessage.images = [base64];
|
|
202
|
+
}
|
|
203
|
+
messages.push(userMessage);
|
|
204
|
+
|
|
168
205
|
let finalContent = '';
|
|
169
206
|
const MAX_TURNS = 25;
|
|
170
207
|
|
|
@@ -175,18 +212,25 @@ export class OllamaAgent extends EventEmitter {
|
|
|
175
212
|
|
|
176
213
|
let response;
|
|
177
214
|
try {
|
|
178
|
-
|
|
215
|
+
const requestBody = {
|
|
216
|
+
model: this.model,
|
|
217
|
+
messages,
|
|
218
|
+
tools: TOOLS,
|
|
219
|
+
tool_choice: 'auto',
|
|
220
|
+
stream: true,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Disable thinking mode for qwen3 — prevents 3-minute silent think phases
|
|
224
|
+
// and makes tool-call JSON output reliable.
|
|
225
|
+
if (isQwen3) {
|
|
226
|
+
requestBody.options = { think: false };
|
|
227
|
+
}
|
|
228
|
+
|
|
179
229
|
response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
|
180
230
|
method: 'POST',
|
|
181
231
|
headers: { 'Content-Type': 'application/json' },
|
|
182
232
|
signal: controller.signal,
|
|
183
|
-
body: JSON.stringify(
|
|
184
|
-
model: this.model,
|
|
185
|
-
messages,
|
|
186
|
-
tools: TOOLS,
|
|
187
|
-
tool_choice: 'auto',
|
|
188
|
-
stream: false
|
|
189
|
-
})
|
|
233
|
+
body: JSON.stringify(requestBody)
|
|
190
234
|
});
|
|
191
235
|
} catch (fetchErr) {
|
|
192
236
|
if (fetchErr.name === 'AbortError') break;
|
|
@@ -198,9 +242,86 @@ export class OllamaAgent extends EventEmitter {
|
|
|
198
242
|
throw new Error(`Local model error ${response.status}: ${body}`);
|
|
199
243
|
}
|
|
200
244
|
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
|
|
245
|
+
// ── Stream the SSE response ──
|
|
246
|
+
// Accumulate content and tool calls from streaming deltas.
|
|
247
|
+
// Filter out <think>...</think> blocks (qwen3 chain-of-thought) — never show to user.
|
|
248
|
+
let streamContent = '';
|
|
249
|
+
let streamToolCalls = {};
|
|
250
|
+
let inThinkBlock = false;
|
|
251
|
+
let thinkBuffer = '';
|
|
252
|
+
|
|
253
|
+
const reader = response.body.getReader();
|
|
254
|
+
const decoder = new TextDecoder();
|
|
255
|
+
let buf = '';
|
|
256
|
+
|
|
257
|
+
while (true) {
|
|
258
|
+
if (controller.signal.aborted) break;
|
|
259
|
+
const { done, value } = await reader.read();
|
|
260
|
+
if (done) break;
|
|
261
|
+
|
|
262
|
+
buf += decoder.decode(value, { stream: true });
|
|
263
|
+
const lines = buf.split('\n');
|
|
264
|
+
buf = lines.pop(); // keep incomplete line
|
|
265
|
+
|
|
266
|
+
for (const line of lines) {
|
|
267
|
+
if (!line.startsWith('data: ')) continue;
|
|
268
|
+
const payload = line.slice(6).trim();
|
|
269
|
+
if (payload === '[DONE]') continue;
|
|
270
|
+
let evt;
|
|
271
|
+
try { evt = JSON.parse(payload); } catch { continue; }
|
|
272
|
+
|
|
273
|
+
const delta = evt.choices?.[0]?.delta;
|
|
274
|
+
if (!delta) continue;
|
|
275
|
+
|
|
276
|
+
// Accumulate tool call deltas
|
|
277
|
+
if (delta.tool_calls) {
|
|
278
|
+
for (const tc of delta.tool_calls) {
|
|
279
|
+
const idx = tc.index ?? 0;
|
|
280
|
+
if (!streamToolCalls[idx]) streamToolCalls[idx] = { id: tc.id || '', type: 'function', function: { name: '', arguments: '' } };
|
|
281
|
+
if (tc.id) streamToolCalls[idx].id = tc.id;
|
|
282
|
+
if (tc.function?.name) streamToolCalls[idx].function.name += tc.function.name;
|
|
283
|
+
if (tc.function?.arguments) streamToolCalls[idx].function.arguments += tc.function.arguments;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Stream content tokens, filtering <think>...</think> blocks
|
|
288
|
+
if (delta.content) {
|
|
289
|
+
thinkBuffer += delta.content;
|
|
290
|
+
|
|
291
|
+
// Process thinkBuffer to extract non-thinking text
|
|
292
|
+
let out = '';
|
|
293
|
+
let i = 0;
|
|
294
|
+
while (i < thinkBuffer.length) {
|
|
295
|
+
if (!inThinkBlock) {
|
|
296
|
+
const thinkStart = thinkBuffer.indexOf('<think>', i);
|
|
297
|
+
if (thinkStart === -1) {
|
|
298
|
+
out += thinkBuffer.slice(i);
|
|
299
|
+
i = thinkBuffer.length;
|
|
300
|
+
} else {
|
|
301
|
+
out += thinkBuffer.slice(i, thinkStart);
|
|
302
|
+
inThinkBlock = true;
|
|
303
|
+
i = thinkStart + 7;
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
const thinkEnd = thinkBuffer.indexOf('</think>', i);
|
|
307
|
+
if (thinkEnd === -1) {
|
|
308
|
+
// still inside think block, keep buffering
|
|
309
|
+
i = thinkBuffer.length;
|
|
310
|
+
} else {
|
|
311
|
+
inThinkBlock = false;
|
|
312
|
+
i = thinkEnd + 8;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
thinkBuffer = inThinkBlock ? thinkBuffer.slice(thinkBuffer.lastIndexOf('<think>')) : '';
|
|
317
|
+
|
|
318
|
+
streamContent += out;
|
|
319
|
+
if (out) {
|
|
320
|
+
this.emit('agent_output', { agentId, output: out });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
204
325
|
|
|
205
326
|
this.emit('tool_activity', {
|
|
206
327
|
agentId,
|
|
@@ -208,6 +329,14 @@ export class OllamaAgent extends EventEmitter {
|
|
|
208
329
|
description: `✅ Ollama responded`
|
|
209
330
|
});
|
|
210
331
|
|
|
332
|
+
// Reconstruct message from streamed parts
|
|
333
|
+
const toolCallsArray = Object.values(streamToolCalls);
|
|
334
|
+
const message = {
|
|
335
|
+
role: 'assistant',
|
|
336
|
+
content: streamContent || null,
|
|
337
|
+
tool_calls: toolCallsArray.length > 0 ? toolCallsArray : undefined
|
|
338
|
+
};
|
|
339
|
+
|
|
211
340
|
messages.push(message);
|
|
212
341
|
|
|
213
342
|
// ── Handle tool calls ──
|
|
@@ -236,23 +365,36 @@ export class OllamaAgent extends EventEmitter {
|
|
|
236
365
|
description: `✓ ${name}`
|
|
237
366
|
});
|
|
238
367
|
|
|
239
|
-
|
|
368
|
+
// If the tool returned an image (base64), push it as a vision message
|
|
369
|
+
// so the model can actually see what was captured.
|
|
370
|
+
const isImageResult = typeof result === 'string' && result.startsWith('data:image/');
|
|
371
|
+
if (isImageResult && isVision) {
|
|
372
|
+
messages.push({
|
|
373
|
+
role: 'tool',
|
|
374
|
+
tool_call_id: toolCall.id || undefined,
|
|
375
|
+
content: '[Screenshot captured — see image attached]'
|
|
376
|
+
});
|
|
377
|
+
const base64 = result.replace(/^data:image\/\w+;base64,/, '');
|
|
378
|
+
messages.push({
|
|
379
|
+
role: 'user',
|
|
380
|
+
content: 'Here is the screenshot:',
|
|
381
|
+
images: [base64]
|
|
382
|
+
});
|
|
383
|
+
} else {
|
|
384
|
+
messages.push({
|
|
385
|
+
role: 'tool',
|
|
386
|
+
tool_call_id: toolCall.id || undefined,
|
|
387
|
+
content: isImageResult ? '[Screenshot captured — install a vision model to analyze images]' : String(result)
|
|
388
|
+
});
|
|
389
|
+
}
|
|
240
390
|
}
|
|
241
391
|
// Loop back — model will respond to the tool results
|
|
242
392
|
continue;
|
|
243
393
|
}
|
|
244
394
|
|
|
245
|
-
// ── No tool calls:
|
|
246
|
-
if (
|
|
247
|
-
finalContent =
|
|
248
|
-
// Stream the response in chunks so the UI feels live
|
|
249
|
-
const words = message.content.split(' ');
|
|
250
|
-
const CHUNK_SIZE = 8;
|
|
251
|
-
for (let i = 0; i < words.length; i += CHUNK_SIZE) {
|
|
252
|
-
if (controller.signal.aborted) break;
|
|
253
|
-
const chunk = words.slice(i, i + CHUNK_SIZE).join(' ') + (i + CHUNK_SIZE < words.length ? ' ' : '');
|
|
254
|
-
this.emit('agent_output', { agentId, output: chunk });
|
|
255
|
-
}
|
|
395
|
+
// ── No tool calls: final answer already streamed above ──
|
|
396
|
+
if (streamContent) {
|
|
397
|
+
finalContent = streamContent;
|
|
256
398
|
}
|
|
257
399
|
break;
|
|
258
400
|
}
|
|
@@ -357,6 +499,22 @@ export class OllamaAgent extends EventEmitter {
|
|
|
357
499
|
return text.slice(0, 4000) + (text.length > 4000 ? '\n...(truncated)' : '');
|
|
358
500
|
}
|
|
359
501
|
|
|
502
|
+
case 'take_screenshot': {
|
|
503
|
+
const target = args.target || 'screen';
|
|
504
|
+
const tmpFile = `/tmp/af_screenshot_${Date.now()}.png`;
|
|
505
|
+
|
|
506
|
+
if (target === 'browser') {
|
|
507
|
+
// Navigate + screenshot via CDP on agent browser (port 9223)
|
|
508
|
+
return await this._cdpScreenshot(args.url, tmpFile);
|
|
509
|
+
} else {
|
|
510
|
+
// Full screen capture
|
|
511
|
+
await execAsync(`screencapture -x "${tmpFile}"`);
|
|
512
|
+
const data = readFileSync(tmpFile).toString('base64');
|
|
513
|
+
try { await execAsync(`rm -f "${tmpFile}"`); } catch {}
|
|
514
|
+
return `data:image/png;base64,${data}`;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
360
518
|
default:
|
|
361
519
|
return `Unknown tool: ${name}`;
|
|
362
520
|
}
|
|
@@ -365,6 +523,69 @@ export class OllamaAgent extends EventEmitter {
|
|
|
365
523
|
}
|
|
366
524
|
}
|
|
367
525
|
|
|
526
|
+
// ─── CDP browser screenshot ───────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
async _cdpScreenshot(navigateUrl, tmpFile) {
|
|
529
|
+
const CDP_PORT = 9223;
|
|
530
|
+
let tabId;
|
|
531
|
+
|
|
532
|
+
// Get or create a tab
|
|
533
|
+
const tabsRes = await fetch(`http://127.0.0.1:${CDP_PORT}/json`);
|
|
534
|
+
const tabs = await tabsRes.json();
|
|
535
|
+
const usable = tabs.find(t => t.type === 'page' && t.webSocketDebuggerUrl);
|
|
536
|
+
|
|
537
|
+
if (!usable) {
|
|
538
|
+
// Create new tab
|
|
539
|
+
const newTab = await fetch(`http://127.0.0.1:${CDP_PORT}/json/new`, { method: 'PUT' });
|
|
540
|
+
const newTabData = await newTab.json();
|
|
541
|
+
tabId = newTabData.id;
|
|
542
|
+
} else {
|
|
543
|
+
tabId = usable.id;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return new Promise((resolve, reject) => {
|
|
547
|
+
// Inline WebSocket CDP — no ws package dependency needed (Node 22 has WebSocket built in)
|
|
548
|
+
const ws = new WebSocket(`ws://127.0.0.1:${CDP_PORT}/devtools/page/${tabId}`);
|
|
549
|
+
let msgId = 1;
|
|
550
|
+
const pending = new Map();
|
|
551
|
+
|
|
552
|
+
const send = (method, params = {}) => new Promise((res, rej) => {
|
|
553
|
+
const id = msgId++;
|
|
554
|
+
pending.set(id, { resolve: res, reject: rej });
|
|
555
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
ws.addEventListener('message', (evt) => {
|
|
559
|
+
const msg = JSON.parse(evt.data);
|
|
560
|
+
if (msg.id && pending.has(msg.id)) {
|
|
561
|
+
const { resolve: res, reject: rej } = pending.get(msg.id);
|
|
562
|
+
pending.delete(msg.id);
|
|
563
|
+
if (msg.error) rej(new Error(msg.error.message));
|
|
564
|
+
else res(msg.result);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
ws.addEventListener('open', async () => {
|
|
569
|
+
try {
|
|
570
|
+
if (navigateUrl) {
|
|
571
|
+
await send('Page.navigate', { url: navigateUrl });
|
|
572
|
+
// Wait for load
|
|
573
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
574
|
+
}
|
|
575
|
+
const { data } = await send('Page.captureScreenshot', { format: 'png' });
|
|
576
|
+
ws.close();
|
|
577
|
+
resolve(`data:image/png;base64,${data}`);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
ws.close();
|
|
580
|
+
reject(err);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
ws.addEventListener('error', (err) => reject(new Error(`CDP WebSocket error: ${err.message}`)));
|
|
585
|
+
setTimeout(() => { ws.close(); reject(new Error('CDP screenshot timeout')); }, 20000);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
368
589
|
_resolvePath(p, workDir) {
|
|
369
590
|
return path.isAbsolute(p) ? p : path.join(workDir, p);
|
|
370
591
|
}
|
|
@@ -382,6 +603,8 @@ export class OllamaAgent extends EventEmitter {
|
|
|
382
603
|
case 'web_fetch': {
|
|
383
604
|
try { return `Fetching ${new URL(args.url).hostname}`; } catch { return 'Fetching URL'; }
|
|
384
605
|
}
|
|
606
|
+
case 'take_screenshot':
|
|
607
|
+
return `Screenshot: ${args.url || args.target}`;
|
|
385
608
|
default:
|
|
386
609
|
return name;
|
|
387
610
|
}
|
package/src/supervisor.js
CHANGED
|
@@ -39,11 +39,18 @@ function removePid(file) {
|
|
|
39
39
|
export async function runSupervisor(innerArgv) {
|
|
40
40
|
writePid(PID_FILE, process.pid);
|
|
41
41
|
|
|
42
|
+
// SIGTERM on supervisor = intentional stop (from agentforge stop command)
|
|
42
43
|
process.on('SIGTERM', () => {
|
|
43
44
|
console.log('[supervisor] Received SIGTERM — shutting down');
|
|
44
45
|
removePid(PID_FILE);
|
|
45
46
|
process.exit(0);
|
|
46
47
|
});
|
|
48
|
+
// SIGINT = Ctrl+C in foreground terminal = intentional stop
|
|
49
|
+
process.on('SIGINT', () => {
|
|
50
|
+
console.log('[supervisor] Received SIGINT — shutting down');
|
|
51
|
+
removePid(PID_FILE);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
});
|
|
47
54
|
|
|
48
55
|
let consecutiveCrashes = 0;
|
|
49
56
|
|
package/src/worker.js
CHANGED
|
@@ -1756,12 +1756,12 @@ Review and add specific steps, pitfalls, and patterns that helped succeed.
|
|
|
1756
1756
|
});
|
|
1757
1757
|
}
|
|
1758
1758
|
|
|
1759
|
-
async shutdown() {
|
|
1759
|
+
async shutdown(code = 1) {
|
|
1760
1760
|
console.log('🛑 Shutting down worker...');
|
|
1761
1761
|
if (this.ws) {
|
|
1762
1762
|
this.ws.close();
|
|
1763
1763
|
}
|
|
1764
|
-
process.exit(
|
|
1764
|
+
process.exit(code);
|
|
1765
1765
|
}
|
|
1766
1766
|
|
|
1767
1767
|
// Find the AgentForge git repo root, regardless of whether worker is globally installed or run from source
|