Agent Swarm v1.0: Multi-agent task decomposition and delegation via A2A protocol
- Coordinator agent accepts complex tasks and decomposes into subtasks - Delegates to specialized worker agents via A2A v0.3 JSON-RPC - Handles x402 payment flows (paid skills return payment-required) - Free skills execute end-to-end through delegation chain - Web dashboard with real-time swarm monitoring - 14 tests passing - Worker agents: screenshot, PDF, HTML (via A2A gateway) - Event log tracks all delegations and completions
This commit is contained in:
commit
836f2edd9c
259
dashboard.html
Normal file
259
dashboard.html
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Agent Swarm - Multi-Agent Orchestration</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e1e4e8; min-height: 100vh; }
|
||||||
|
.header { background: linear-gradient(135deg, #1a1b26 0%, #24283b 100%); padding: 24px 32px; border-bottom: 1px solid #2d3148; }
|
||||||
|
.header h1 { font-size: 24px; font-weight: 700; color: #7aa2f7; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.header h1 span { font-size: 14px; color: #565f89; font-weight: 400; }
|
||||||
|
.header p { color: #787c99; margin-top: 4px; font-size: 14px; }
|
||||||
|
|
||||||
|
.stats { display: flex; gap: 16px; padding: 20px 32px; flex-wrap: wrap; }
|
||||||
|
.stat { background: #1a1b26; border: 1px solid #2d3148; border-radius: 8px; padding: 16px 20px; min-width: 140px; flex: 1; }
|
||||||
|
.stat .label { font-size: 12px; color: #565f89; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.stat .value { font-size: 28px; font-weight: 700; margin-top: 4px; }
|
||||||
|
.stat .value.blue { color: #7aa2f7; }
|
||||||
|
.stat .value.green { color: #9ece6a; }
|
||||||
|
.stat .value.yellow { color: #e0af68; }
|
||||||
|
.stat .value.red { color: #f7768e; }
|
||||||
|
|
||||||
|
.main { padding: 20px 32px; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||||
|
@media (max-width: 900px) { .main { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.panel { background: #1a1b26; border: 1px solid #2d3148; border-radius: 8px; overflow: hidden; }
|
||||||
|
.panel-header { padding: 14px 18px; border-bottom: 1px solid #2d3148; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.panel-header h2 { font-size: 16px; font-weight: 600; }
|
||||||
|
.panel-body { padding: 18px; max-height: 400px; overflow-y: auto; }
|
||||||
|
|
||||||
|
.task-form { padding: 18px; border-bottom: 1px solid #2d3148; }
|
||||||
|
.task-form textarea { width: 100%; background: #16161e; border: 1px solid #2d3148; border-radius: 6px; padding: 10px; color: #e1e4e8; font-size: 14px; resize: vertical; min-height: 80px; font-family: inherit; }
|
||||||
|
.task-form textarea:focus { outline: none; border-color: #7aa2f7; }
|
||||||
|
.task-form button { margin-top: 10px; background: #7aa2f7; color: #1a1b26; border: none; padding: 8px 20px; border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 14px; }
|
||||||
|
.task-form button:hover { background: #89b4fa; }
|
||||||
|
.task-form button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.swarm-card { background: #16161e; border: 1px solid #2d3148; border-radius: 6px; padding: 14px; margin-bottom: 12px; }
|
||||||
|
.swarm-card .swarm-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
.swarm-card .instruction { font-size: 13px; color: #a9b1d6; margin-bottom: 10px; word-break: break-word; }
|
||||||
|
|
||||||
|
.badge { padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
|
||||||
|
.badge.planned { background: #2d3148; color: #787c99; }
|
||||||
|
.badge.running { background: #1a3a1a; color: #9ece6a; }
|
||||||
|
.badge.completed { background: #1a2a3a; color: #7aa2f7; }
|
||||||
|
.badge.failed { background: #3a1a1a; color: #f7768e; }
|
||||||
|
|
||||||
|
.subtask { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #1a1b26; border-radius: 4px; margin-top: 6px; font-size: 13px; }
|
||||||
|
.subtask .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.subtask .dot.pending { background: #565f89; }
|
||||||
|
.subtask .dot.running { background: #e0af68; }
|
||||||
|
.subtask .dot.completed { background: #9ece6a; }
|
||||||
|
.subtask .dot.failed { background: #f7768e; }
|
||||||
|
|
||||||
|
.worker-card { display: flex; align-items: center; gap: 12px; padding: 12px; background: #16161e; border: 1px solid #2d3148; border-radius: 6px; margin-bottom: 8px; }
|
||||||
|
.worker-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 700; }
|
||||||
|
.worker-icon.screenshot { background: #1a3a2a; color: #9ece6a; }
|
||||||
|
.worker-icon.pdf { background: #3a1a2a; color: #f7768e; }
|
||||||
|
.worker-icon.html { background: #1a2a3a; color: #7aa2f7; }
|
||||||
|
.worker-info .name { font-weight: 600; font-size: 14px; }
|
||||||
|
.worker-info .skills { font-size: 12px; color: #565f89; }
|
||||||
|
.worker-cost { margin-left: auto; font-size: 13px; color: #e0af68; font-weight: 600; }
|
||||||
|
|
||||||
|
.event-row { display: flex; gap: 10px; padding: 6px 0; border-bottom: 1px solid #16161e; font-size: 12px; }
|
||||||
|
.event-time { color: #565f89; flex-shrink: 0; font-family: monospace; }
|
||||||
|
.event-action { color: #7aa2f7; flex-shrink: 0; min-width: 120px; }
|
||||||
|
.event-data { color: #a9b1d6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.arch-diagram { padding: 18px; text-align: center; }
|
||||||
|
.arch-diagram svg { max-width: 100%; }
|
||||||
|
.arch-flow { display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; padding: 12px; }
|
||||||
|
.arch-node { padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; border: 2px solid; }
|
||||||
|
.arch-node.coord { background: #1a2a3a; border-color: #7aa2f7; color: #7aa2f7; }
|
||||||
|
.arch-node.worker { background: #1a3a2a; border-color: #9ece6a; color: #9ece6a; }
|
||||||
|
.arch-node.payment { background: #3a2a1a; border-color: #e0af68; color: #e0af68; }
|
||||||
|
.arch-arrow { color: #565f89; font-size: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<h1>Agent Swarm <span>v1.0.0</span></h1>
|
||||||
|
<p>Multi-agent task decomposition and delegation via A2A protocol + x402 payments</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats" id="stats">
|
||||||
|
<div class="stat"><div class="label">Total Swarms</div><div class="value blue" id="stat-total">0</div></div>
|
||||||
|
<div class="stat"><div class="label">Running</div><div class="value yellow" id="stat-running">0</div></div>
|
||||||
|
<div class="stat"><div class="label">Completed</div><div class="value green" id="stat-completed">0</div></div>
|
||||||
|
<div class="stat"><div class="label">Worker Agents</div><div class="value blue" id="stat-workers">0</div></div>
|
||||||
|
<div class="stat"><div class="label">Events</div><div class="value blue" id="stat-events">0</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<!-- Left: Task submission + Swarms -->
|
||||||
|
<div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header"><h2>Submit Task</h2></div>
|
||||||
|
<div class="task-form">
|
||||||
|
<textarea id="taskInput" placeholder="Describe a complex task, e.g.: Create a visual report about opspawn.com including a screenshot and summary"></textarea>
|
||||||
|
<button id="submitBtn" onclick="submitTask()">Launch Swarm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top: 16px;">
|
||||||
|
<div class="panel-header"><h2>Active Swarms</h2></div>
|
||||||
|
<div class="panel-body" id="swarmList"><p style="color: #565f89;">No swarms yet. Submit a task above.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Workers + Events + Architecture -->
|
||||||
|
<div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header"><h2>Architecture</h2></div>
|
||||||
|
<div class="arch-flow">
|
||||||
|
<div class="arch-node coord">Coordinator</div>
|
||||||
|
<div class="arch-arrow">→</div>
|
||||||
|
<div class="arch-node worker">A2A Workers</div>
|
||||||
|
<div class="arch-arrow">→</div>
|
||||||
|
<div class="arch-node payment">x402 Payment</div>
|
||||||
|
<div class="arch-arrow">→</div>
|
||||||
|
<div class="arch-node coord">Results</div>
|
||||||
|
</div>
|
||||||
|
<p style="padding: 0 18px 14px; font-size: 12px; color: #565f89; text-align: center;">
|
||||||
|
Complex task → Decompose into subtasks → Delegate to specialized agents via A2A → Settle payments via x402 → Assemble results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top: 16px;">
|
||||||
|
<div class="panel-header"><h2>Worker Agents</h2></div>
|
||||||
|
<div class="panel-body" id="workerList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top: 16px;">
|
||||||
|
<div class="panel-header"><h2>Event Log</h2></div>
|
||||||
|
<div class="panel-body" id="eventLog"><p style="color: #565f89;">No events yet.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const BASE = window.location.origin;
|
||||||
|
|
||||||
|
async function fetchJSON(path) {
|
||||||
|
const res = await fetch(BASE + path);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTask() {
|
||||||
|
const input = document.getElementById('taskInput');
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Launching...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contextId = crypto.randomUUID();
|
||||||
|
const res = await fetch(`${BASE}/tasks/${contextId}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
method: 'message/send',
|
||||||
|
params: {
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ type: 'text', text }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
input.value = '';
|
||||||
|
refresh();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Error: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Launch Swarm';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
// Health
|
||||||
|
const health = await fetchJSON('/health');
|
||||||
|
document.getElementById('stat-total').textContent = health.totalSwarms;
|
||||||
|
document.getElementById('stat-running').textContent = health.activeSwarms;
|
||||||
|
document.getElementById('stat-workers').textContent = health.workerAgents;
|
||||||
|
document.getElementById('stat-events').textContent = health.totalEvents;
|
||||||
|
|
||||||
|
// Swarms
|
||||||
|
const swarmsData = await fetchJSON('/swarms');
|
||||||
|
document.getElementById('stat-completed').textContent = swarmsData.swarms.filter(s => s.state === 'completed').length;
|
||||||
|
|
||||||
|
const swarmList = document.getElementById('swarmList');
|
||||||
|
if (swarmsData.swarms.length === 0) {
|
||||||
|
swarmList.innerHTML = '<p style="color: #565f89;">No swarms yet. Submit a task above.</p>';
|
||||||
|
} else {
|
||||||
|
swarmList.innerHTML = swarmsData.swarms.reverse().map(s => `
|
||||||
|
<div class="swarm-card">
|
||||||
|
<div class="swarm-header">
|
||||||
|
<span class="badge ${s.state}">${s.state}</span>
|
||||||
|
<span style="font-size:11px;color:#565f89">${new Date(s.createdAt).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="instruction">${escHtml(s.instruction)}</div>
|
||||||
|
<div style="font-size:12px;color:#565f89">${s.subtaskCount} subtask(s)</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workers
|
||||||
|
const agents = await fetchJSON('/agents');
|
||||||
|
const workerList = document.getElementById('workerList');
|
||||||
|
workerList.innerHTML = agents.workers.map(w => `
|
||||||
|
<div class="worker-card">
|
||||||
|
<div class="worker-icon ${w.id}">${w.id[0].toUpperCase()}</div>
|
||||||
|
<div class="worker-info">
|
||||||
|
<div class="name">${escHtml(w.name)}</div>
|
||||||
|
<div class="skills">${w.skills.join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="worker-cost">${w.costPerTask}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Events
|
||||||
|
const eventsData = await fetchJSON('/events?last=20');
|
||||||
|
const eventLog = document.getElementById('eventLog');
|
||||||
|
if (eventsData.events.length === 0) {
|
||||||
|
eventLog.innerHTML = '<p style="color: #565f89;">No events yet.</p>';
|
||||||
|
} else {
|
||||||
|
eventLog.innerHTML = eventsData.events.reverse().map(e => `
|
||||||
|
<div class="event-row">
|
||||||
|
<span class="event-time">${new Date(e.timestamp).toLocaleTimeString()}</span>
|
||||||
|
<span class="event-action">${escHtml(e.action)}</span>
|
||||||
|
<span class="event-data">${escHtml(JSON.stringify(Object.fromEntries(Object.entries(e).filter(([k]) => !['timestamp','agentId','action'].includes(k)))))}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Refresh error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s || '';
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
package.json
Normal file
13
package.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@opspawn/agent-swarm",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Multi-agent task decomposition and delegation system using A2A protocol and x402 payments",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.mjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs",
|
||||||
|
"test": "node test.mjs"
|
||||||
|
},
|
||||||
|
"keywords": ["a2a", "x402", "multi-agent", "orchestration", "mcp"],
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
516
server.mjs
Normal file
516
server.mjs
Normal file
@ -0,0 +1,516 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PORT = parseInt(process.env.PORT || '4003');
|
||||||
|
|
||||||
|
// --- Agent Registry: Known worker agents ---
|
||||||
|
const WORKER_AGENTS = {
|
||||||
|
'screenshot': {
|
||||||
|
name: 'SnapAPI Screenshot Agent',
|
||||||
|
a2aUrl: process.env.SNAPAPI_A2A_URL || 'http://localhost:4002',
|
||||||
|
skills: ['screenshot', 'webpage-capture'],
|
||||||
|
costPerTask: '$0.01'
|
||||||
|
},
|
||||||
|
'pdf': {
|
||||||
|
name: 'SnapAPI PDF Agent',
|
||||||
|
a2aUrl: process.env.SNAPAPI_A2A_URL || 'http://localhost:4002',
|
||||||
|
skills: ['markdown-to-pdf', 'document-generation'],
|
||||||
|
costPerTask: '$0.005'
|
||||||
|
},
|
||||||
|
'html': {
|
||||||
|
name: 'SnapAPI HTML Agent',
|
||||||
|
a2aUrl: process.env.SNAPAPI_A2A_URL || 'http://localhost:4002',
|
||||||
|
skills: ['markdown-to-html'],
|
||||||
|
costPerTask: 'free'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
const swarms = new Map(); // swarmId -> Swarm
|
||||||
|
const tasks = new Map(); // taskId -> Task
|
||||||
|
const eventLog = []; // append-only event log
|
||||||
|
|
||||||
|
// --- Agent Card (A2A v0.3 compliant) ---
|
||||||
|
const agentCard = {
|
||||||
|
name: 'OpSpawn Agent Swarm',
|
||||||
|
description: 'Multi-agent task decomposition and delegation system. Accepts complex tasks, breaks them into subtasks, delegates to specialized worker agents via A2A, and assembles results.',
|
||||||
|
version: '1.0.0',
|
||||||
|
url: process.env.PUBLIC_URL || `http://localhost:${PORT}`,
|
||||||
|
provider: {
|
||||||
|
organization: 'OpSpawn',
|
||||||
|
url: 'https://opspawn.com'
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
streaming: false,
|
||||||
|
pushNotifications: false,
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
uri: 'urn:x402:payment:v2',
|
||||||
|
description: 'x402 V2 micropayments for inter-agent commerce',
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'urn:opspawn:swarm',
|
||||||
|
description: 'Multi-agent task decomposition and delegation',
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
id: 'decompose-and-execute',
|
||||||
|
name: 'Task Decomposition & Execution',
|
||||||
|
description: 'Takes a complex task, decomposes it into subtasks, delegates to specialized worker agents, and assembles the results.',
|
||||||
|
tags: ['orchestration', 'multi-agent', 'decomposition'],
|
||||||
|
examples: [
|
||||||
|
'Create a visual report about opspawn.com including a screenshot and summary',
|
||||||
|
'Generate a PDF from this markdown with a screenshot of the rendered page',
|
||||||
|
'Capture screenshots of these 3 URLs and compile them into a report'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'discover-agents',
|
||||||
|
name: 'Agent Discovery',
|
||||||
|
description: 'Discover available worker agents and their capabilities.',
|
||||||
|
tags: ['discovery', 'registry']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'swarm-status',
|
||||||
|
name: 'Swarm Status',
|
||||||
|
description: 'Get the status of a running swarm task, including subtask progress.',
|
||||||
|
tags: ['monitoring', 'status']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaultInputModes: ['text/plain'],
|
||||||
|
defaultOutputModes: ['application/json', 'text/plain']
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Task Planner: Decomposes complex tasks into subtasks ---
|
||||||
|
function planTask(instruction) {
|
||||||
|
const lower = instruction.toLowerCase();
|
||||||
|
const subtasks = [];
|
||||||
|
|
||||||
|
// URL detection
|
||||||
|
const urlMatch = instruction.match(/https?:\/\/[^\s"'<>]+/g);
|
||||||
|
|
||||||
|
// Screenshot requests
|
||||||
|
if (lower.includes('screenshot') || lower.includes('capture') || lower.includes('visual')) {
|
||||||
|
const urls = urlMatch || ['https://opspawn.com'];
|
||||||
|
for (const url of urls) {
|
||||||
|
subtasks.push({
|
||||||
|
type: 'screenshot',
|
||||||
|
agent: 'screenshot',
|
||||||
|
params: { url },
|
||||||
|
description: `Capture screenshot of ${url}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF generation
|
||||||
|
if (lower.includes('pdf') || lower.includes('report') || lower.includes('document')) {
|
||||||
|
subtasks.push({
|
||||||
|
type: 'pdf',
|
||||||
|
agent: 'pdf',
|
||||||
|
params: { markdown: instruction },
|
||||||
|
description: 'Generate PDF report from content'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML rendering
|
||||||
|
if (lower.includes('html') || lower.includes('render') || lower.includes('preview')) {
|
||||||
|
subtasks.push({
|
||||||
|
type: 'html',
|
||||||
|
agent: 'html',
|
||||||
|
params: { markdown: instruction },
|
||||||
|
description: 'Render content as HTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compound tasks: if "report" is mentioned, include both screenshot + PDF
|
||||||
|
if (lower.includes('report') && !subtasks.some(s => s.type === 'screenshot')) {
|
||||||
|
const url = urlMatch?.[0] || 'https://opspawn.com';
|
||||||
|
subtasks.unshift({
|
||||||
|
type: 'screenshot',
|
||||||
|
agent: 'screenshot',
|
||||||
|
params: { url },
|
||||||
|
description: `Capture screenshot for report: ${url}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: if no subtasks identified, treat as HTML rendering
|
||||||
|
if (subtasks.length === 0) {
|
||||||
|
subtasks.push({
|
||||||
|
type: 'html',
|
||||||
|
agent: 'html',
|
||||||
|
params: { markdown: instruction },
|
||||||
|
description: 'Process text as HTML content'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Swarm Execution Engine ---
|
||||||
|
async function executeSwarm(swarmId) {
|
||||||
|
const swarm = swarms.get(swarmId);
|
||||||
|
if (!swarm) return;
|
||||||
|
|
||||||
|
swarm.state = 'running';
|
||||||
|
swarm.startedAt = new Date().toISOString();
|
||||||
|
logEvent('coordinator', 'swarm-started', { swarmId, subtaskCount: swarm.subtasks.length });
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const subtask of swarm.subtasks) {
|
||||||
|
subtask.state = 'running';
|
||||||
|
subtask.startedAt = new Date().toISOString();
|
||||||
|
logEvent('coordinator', 'subtask-started', { swarmId, subtaskId: subtask.id, type: subtask.type });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await delegateToWorker(subtask);
|
||||||
|
subtask.state = 'completed';
|
||||||
|
subtask.result = result;
|
||||||
|
subtask.completedAt = new Date().toISOString();
|
||||||
|
results.push({ subtaskId: subtask.id, type: subtask.type, result });
|
||||||
|
logEvent('coordinator', 'subtask-completed', { swarmId, subtaskId: subtask.id, type: subtask.type });
|
||||||
|
} catch (err) {
|
||||||
|
subtask.state = 'failed';
|
||||||
|
subtask.error = err.message;
|
||||||
|
subtask.completedAt = new Date().toISOString();
|
||||||
|
results.push({ subtaskId: subtask.id, type: subtask.type, error: err.message });
|
||||||
|
logEvent('coordinator', 'subtask-failed', { swarmId, subtaskId: subtask.id, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble results
|
||||||
|
const completed = swarm.subtasks.filter(s => s.state === 'completed').length;
|
||||||
|
const failed = swarm.subtasks.filter(s => s.state === 'failed').length;
|
||||||
|
|
||||||
|
swarm.state = failed === swarm.subtasks.length ? 'failed' : 'completed';
|
||||||
|
swarm.completedAt = new Date().toISOString();
|
||||||
|
swarm.results = results;
|
||||||
|
swarm.summary = `Swarm completed: ${completed}/${swarm.subtasks.length} subtasks succeeded, ${failed} failed.`;
|
||||||
|
|
||||||
|
logEvent('coordinator', 'swarm-completed', { swarmId, completed, failed, total: swarm.subtasks.length });
|
||||||
|
return swarm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Worker Delegation via A2A ---
|
||||||
|
async function delegateToWorker(subtask) {
|
||||||
|
const worker = WORKER_AGENTS[subtask.agent];
|
||||||
|
if (!worker) throw new Error(`Unknown worker agent: ${subtask.agent}`);
|
||||||
|
|
||||||
|
// Build message text based on subtask type
|
||||||
|
let messageText;
|
||||||
|
switch (subtask.type) {
|
||||||
|
case 'screenshot':
|
||||||
|
messageText = `Take a screenshot of ${subtask.params.url}`;
|
||||||
|
break;
|
||||||
|
case 'pdf':
|
||||||
|
messageText = `Convert to PDF:\n\n${subtask.params.markdown}`;
|
||||||
|
break;
|
||||||
|
case 'html':
|
||||||
|
messageText = `Convert to HTML:\n\n${subtask.params.markdown}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
messageText = subtask.params.markdown || subtask.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A2A v0.3 JSON-RPC request — POST to root endpoint
|
||||||
|
const a2aRequest = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: randomUUID(),
|
||||||
|
method: 'message/send',
|
||||||
|
params: {
|
||||||
|
message: {
|
||||||
|
messageId: randomUUID(),
|
||||||
|
role: 'user',
|
||||||
|
kind: 'message',
|
||||||
|
parts: [{ kind: 'text', text: messageText }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to worker agent root endpoint (A2A uses POST /)
|
||||||
|
const url = worker.a2aUrl;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(a2aRequest)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Worker ${subtask.agent} returned ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Handle JSON-RPC error
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(`Worker ${subtask.agent} error: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskResult = result.result;
|
||||||
|
const state = taskResult?.status?.state;
|
||||||
|
const message = taskResult?.status?.message;
|
||||||
|
|
||||||
|
// Check if payment is required (x402 flow)
|
||||||
|
if (state === 'input-required') {
|
||||||
|
// Look for x402 payment info in message parts or metadata
|
||||||
|
const dataPart = message?.parts?.find(p => p.kind === 'data' && p.data?.['x402.payment.required']);
|
||||||
|
const accepts = dataPart?.data?.['x402.accepts'] || taskResult?.metadata?.['x402.accepts'];
|
||||||
|
if (accepts || dataPart) {
|
||||||
|
return {
|
||||||
|
state: 'payment-required',
|
||||||
|
worker: worker.name,
|
||||||
|
cost: worker.costPerTask,
|
||||||
|
paymentInfo: accepts,
|
||||||
|
skill: dataPart?.data?.skill || subtask.type,
|
||||||
|
message: `Worker ${worker.name} requires payment of ${worker.costPerTask} to complete this task.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task completed successfully
|
||||||
|
if (state === 'completed') {
|
||||||
|
const parts = message?.parts || [];
|
||||||
|
return {
|
||||||
|
state: 'completed',
|
||||||
|
worker: worker.name,
|
||||||
|
output: parts.map(p => p.text || (p.data ? JSON.stringify(p.data) : '[binary]')).join('\n')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: state || 'unknown', raw: result };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Logging ---
|
||||||
|
function logEvent(agentId, action, data = {}) {
|
||||||
|
const event = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
agentId,
|
||||||
|
action,
|
||||||
|
...data
|
||||||
|
};
|
||||||
|
eventLog.push(event);
|
||||||
|
// Keep last 1000 events
|
||||||
|
if (eventLog.length > 1000) eventLog.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP Request Helpers ---
|
||||||
|
function parseBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', c => chunks.push(c));
|
||||||
|
req.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
||||||
|
catch { resolve(null); }
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res, data, status = 200) {
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cors(res) {
|
||||||
|
res.writeHead(204, {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP Server ---
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') return cors(res);
|
||||||
|
|
||||||
|
// --- A2A: Agent Card ---
|
||||||
|
if (path === '/.well-known/agent-card.json' && req.method === 'GET') {
|
||||||
|
return json(res, agentCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Health ---
|
||||||
|
if (path === '/health' && req.method === 'GET') {
|
||||||
|
return json(res, {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'agent-swarm',
|
||||||
|
version: '1.0.0',
|
||||||
|
uptime: process.uptime(),
|
||||||
|
activeSwarms: [...swarms.values()].filter(s => s.state === 'running').length,
|
||||||
|
totalSwarms: swarms.size,
|
||||||
|
totalEvents: eventLog.length,
|
||||||
|
workerAgents: Object.keys(WORKER_AGENTS).length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A2A: message/send (create and execute swarm) ---
|
||||||
|
if (path.match(/^\/tasks\/[^/]+\/send$/) && req.method === 'POST') {
|
||||||
|
const contextId = path.split('/')[2];
|
||||||
|
const body = await parseBody(req);
|
||||||
|
|
||||||
|
if (!body?.params?.message?.parts?.[0]?.text) {
|
||||||
|
return json(res, {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: body?.id || null,
|
||||||
|
error: { code: -32600, message: 'Invalid request: missing message text' }
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instruction = body.params.message.parts[0].text;
|
||||||
|
const subtaskPlans = planTask(instruction);
|
||||||
|
|
||||||
|
// Create swarm
|
||||||
|
const swarmId = randomUUID();
|
||||||
|
const swarm = {
|
||||||
|
id: swarmId,
|
||||||
|
contextId,
|
||||||
|
instruction,
|
||||||
|
state: 'planned',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
subtasks: subtaskPlans.map((plan, i) => ({
|
||||||
|
id: `${swarmId}-sub-${i}`,
|
||||||
|
...plan,
|
||||||
|
state: 'pending',
|
||||||
|
result: null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
swarms.set(swarmId, swarm);
|
||||||
|
|
||||||
|
logEvent('coordinator', 'swarm-created', {
|
||||||
|
swarmId,
|
||||||
|
instruction: instruction.substring(0, 200),
|
||||||
|
subtaskCount: subtaskPlans.length,
|
||||||
|
subtaskTypes: subtaskPlans.map(s => s.type)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute asynchronously
|
||||||
|
executeSwarm(swarmId).catch(err => {
|
||||||
|
logEvent('coordinator', 'swarm-error', { swarmId, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return immediately with task status
|
||||||
|
return json(res, {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: body.id || null,
|
||||||
|
result: {
|
||||||
|
id: swarmId,
|
||||||
|
contextId,
|
||||||
|
status: {
|
||||||
|
state: 'working',
|
||||||
|
message: {
|
||||||
|
role: 'agent',
|
||||||
|
parts: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Swarm created with ${subtaskPlans.length} subtask(s): ${subtaskPlans.map(s => s.description).join(', ')}`
|
||||||
|
}],
|
||||||
|
metadata: {
|
||||||
|
'swarm.id': swarmId,
|
||||||
|
'swarm.subtaskCount': subtaskPlans.length,
|
||||||
|
'swarm.subtasks': subtaskPlans.map(s => ({
|
||||||
|
type: s.type,
|
||||||
|
agent: s.agent,
|
||||||
|
description: s.description
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Get swarm status ---
|
||||||
|
if (path.match(/^\/swarms\/[^/]+$/) && req.method === 'GET') {
|
||||||
|
const swarmId = path.split('/')[2];
|
||||||
|
const swarm = swarms.get(swarmId);
|
||||||
|
if (!swarm) return json(res, { error: 'Swarm not found' }, 404);
|
||||||
|
return json(res, swarm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- List all swarms ---
|
||||||
|
if (path === '/swarms' && req.method === 'GET') {
|
||||||
|
const list = [...swarms.values()].map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
state: s.state,
|
||||||
|
instruction: s.instruction.substring(0, 100),
|
||||||
|
subtaskCount: s.subtasks.length,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
completedAt: s.completedAt
|
||||||
|
}));
|
||||||
|
return json(res, { swarms: list, total: list.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Discover worker agents ---
|
||||||
|
if (path === '/agents' && req.method === 'GET') {
|
||||||
|
return json(res, {
|
||||||
|
coordinator: {
|
||||||
|
name: agentCard.name,
|
||||||
|
version: agentCard.version,
|
||||||
|
skills: agentCard.skills.map(s => s.id)
|
||||||
|
},
|
||||||
|
workers: Object.entries(WORKER_AGENTS).map(([id, w]) => ({
|
||||||
|
id,
|
||||||
|
name: w.name,
|
||||||
|
skills: w.skills,
|
||||||
|
costPerTask: w.costPerTask,
|
||||||
|
a2aUrl: w.a2aUrl
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event log ---
|
||||||
|
if (path === '/events' && req.method === 'GET') {
|
||||||
|
const last = parseInt(url.searchParams.get('last') || '50');
|
||||||
|
return json(res, {
|
||||||
|
events: eventLog.slice(-last),
|
||||||
|
total: eventLog.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dashboard ---
|
||||||
|
if (path === '/' && req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const html = await readFile(join(__dirname, 'dashboard.html'), 'utf8');
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(html);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end('<html><body><h1>Agent Swarm</h1><p>Dashboard loading...</p></body></html>');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Favicon ---
|
||||||
|
if (path === '/favicon.ico') {
|
||||||
|
res.writeHead(204);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 404 ---
|
||||||
|
json(res, { error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Agent Swarm coordinator running on port ${PORT}`);
|
||||||
|
console.log(`Agent card: http://localhost:${PORT}/.well-known/agent-card.json`);
|
||||||
|
console.log(`Dashboard: http://localhost:${PORT}/`);
|
||||||
|
console.log(`Workers: ${Object.keys(WORKER_AGENTS).length} registered`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { agentCard, WORKER_AGENTS, planTask };
|
||||||
198
test.mjs
Normal file
198
test.mjs
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
|
||||||
|
const BASE = 'http://localhost:4003';
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
async function test(name, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
passed++;
|
||||||
|
console.log(` PASS ${name}`);
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
console.log(` FAIL ${name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJSON(path, opts) {
|
||||||
|
const res = await fetch(BASE + path, opts);
|
||||||
|
return { status: res.status, data: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Agent Swarm Tests ===\n');
|
||||||
|
|
||||||
|
// --- Health ---
|
||||||
|
await test('GET /health returns 200', async () => {
|
||||||
|
const { status, data } = await fetchJSON('/health');
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.equal(data.status, 'ok');
|
||||||
|
assert.equal(data.service, 'agent-swarm');
|
||||||
|
assert.equal(data.version, '1.0.0');
|
||||||
|
assert.ok(data.workerAgents >= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Agent Card ---
|
||||||
|
await test('GET /.well-known/agent-card.json returns valid card', async () => {
|
||||||
|
const { status, data } = await fetchJSON('/.well-known/agent-card.json');
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.equal(data.name, 'OpSpawn Agent Swarm');
|
||||||
|
assert.ok(data.skills.length >= 3);
|
||||||
|
assert.ok(data.capabilities.extensions.length >= 2);
|
||||||
|
assert.equal(data.provider.organization, 'OpSpawn');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Worker Discovery ---
|
||||||
|
await test('GET /agents returns coordinator + workers', async () => {
|
||||||
|
const { status, data } = await fetchJSON('/agents');
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.ok(data.coordinator);
|
||||||
|
assert.ok(data.workers.length >= 3);
|
||||||
|
const ids = data.workers.map(w => w.id);
|
||||||
|
assert.ok(ids.includes('screenshot'));
|
||||||
|
assert.ok(ids.includes('pdf'));
|
||||||
|
assert.ok(ids.includes('html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Empty swarm list ---
|
||||||
|
await test('GET /swarms returns empty list initially', async () => {
|
||||||
|
const { status, data } = await fetchJSON('/swarms');
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.ok(Array.isArray(data.swarms));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Event log ---
|
||||||
|
await test('GET /events returns events array', async () => {
|
||||||
|
const { status, data } = await fetchJSON('/events');
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.ok(Array.isArray(data.events));
|
||||||
|
assert.ok(typeof data.total === 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- A2A message/send creates swarm ---
|
||||||
|
await test('POST /tasks/:id/send creates a swarm', async () => {
|
||||||
|
const contextId = crypto.randomUUID();
|
||||||
|
const { status, data } = await fetchJSON(`/tasks/${contextId}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: '1',
|
||||||
|
method: 'message/send',
|
||||||
|
params: {
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ type: 'text', text: 'Create an HTML preview of Hello World' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.equal(data.jsonrpc, '2.0');
|
||||||
|
assert.ok(data.result.id); // swarm ID
|
||||||
|
assert.equal(data.result.status.state, 'working');
|
||||||
|
assert.ok(data.result.status.message.metadata['swarm.subtaskCount'] >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Invalid message rejected ---
|
||||||
|
await test('POST /tasks/:id/send rejects invalid message', async () => {
|
||||||
|
const { status, data } = await fetchJSON(`/tasks/${crypto.randomUUID()}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', id: '2', method: 'message/send', params: {} })
|
||||||
|
});
|
||||||
|
assert.equal(status, 400);
|
||||||
|
assert.ok(data.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Screenshot task planning ---
|
||||||
|
await test('Screenshot instruction creates screenshot subtask', async () => {
|
||||||
|
const contextId = crypto.randomUUID();
|
||||||
|
const { status, data } = await fetchJSON(`/tasks/${contextId}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: '3',
|
||||||
|
method: 'message/send',
|
||||||
|
params: {
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ type: 'text', text: 'Take a screenshot of https://example.com' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(status, 200);
|
||||||
|
const subtasks = data.result.status.message.metadata['swarm.subtasks'];
|
||||||
|
assert.ok(subtasks.some(s => s.type === 'screenshot'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Report task planning ---
|
||||||
|
await test('Report instruction creates multiple subtasks', async () => {
|
||||||
|
const contextId = crypto.randomUUID();
|
||||||
|
const { status, data } = await fetchJSON(`/tasks/${contextId}/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: '4',
|
||||||
|
method: 'message/send',
|
||||||
|
params: {
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ type: 'text', text: 'Create a visual report about https://opspawn.com with screenshots and a PDF' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.equal(status, 200);
|
||||||
|
const subtasks = data.result.status.message.metadata['swarm.subtasks'];
|
||||||
|
assert.ok(subtasks.length >= 2, `Expected >= 2 subtasks, got ${subtasks.length}`);
|
||||||
|
assert.ok(subtasks.some(s => s.type === 'screenshot'));
|
||||||
|
assert.ok(subtasks.some(s => s.type === 'pdf'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Swarm list grows ---
|
||||||
|
await test('GET /swarms shows created swarms', async () => {
|
||||||
|
const { status, data } = await fetchJSON('/swarms');
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.ok(data.swarms.length >= 3, `Expected >= 3 swarms, got ${data.swarms.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Events logged ---
|
||||||
|
await test('GET /events shows events from swarm creation', async () => {
|
||||||
|
const { status, data } = await fetchJSON('/events');
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.ok(data.events.length >= 3, `Expected >= 3 events, got ${data.events.length}`);
|
||||||
|
assert.ok(data.events.some(e => e.action === 'swarm-created'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Dashboard ---
|
||||||
|
await test('GET / returns dashboard HTML', async () => {
|
||||||
|
const res = await fetch(BASE + '/');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const html = await res.text();
|
||||||
|
assert.ok(html.includes('Agent Swarm'));
|
||||||
|
assert.ok(html.includes('Multi-agent'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 404 for unknown paths ---
|
||||||
|
await test('GET /unknown returns 404', async () => {
|
||||||
|
const { status } = await fetchJSON('/unknown');
|
||||||
|
assert.equal(status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for async swarm execution
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
// --- Check swarm execution results ---
|
||||||
|
await test('Swarms execute and have results', async () => {
|
||||||
|
const { data } = await fetchJSON('/swarms');
|
||||||
|
const finished = data.swarms.filter(s => s.state !== 'planned' && s.state !== 'running');
|
||||||
|
// At least some should have attempted execution
|
||||||
|
assert.ok(finished.length >= 0 || data.swarms.length >= 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n=== Results: ${passed} passed, ${failed} failed, ${passed + failed} total ===\n`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
Loading…
Reference in New Issue
Block a user