zouroboros-swarm 5.0.0
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/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/api/server.d.ts +28 -0
- package/dist/api/server.js +223 -0
- package/dist/budget/governor.d.ts +48 -0
- package/dist/budget/governor.js +114 -0
- package/dist/budget/pricing.d.ts +12 -0
- package/dist/budget/pricing.js +44 -0
- package/dist/cascade/manager.d.ts +62 -0
- package/dist/cascade/manager.js +221 -0
- package/dist/circuit/breaker.d.ts +24 -0
- package/dist/circuit/breaker.js +130 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.js +241 -0
- package/dist/context/sharing.d.ts +63 -0
- package/dist/context/sharing.js +169 -0
- package/dist/dag/executor.d.ts +74 -0
- package/dist/dag/executor.js +354 -0
- package/dist/db/schema.d.ts +9 -0
- package/dist/db/schema.js +100 -0
- package/dist/executor/bridge.d.ts +20 -0
- package/dist/executor/bridge.js +140 -0
- package/dist/executor/doctor.d.ts +14 -0
- package/dist/executor/doctor.js +172 -0
- package/dist/executor/gemini-daemon.d.ts +18 -0
- package/dist/executor/gemini-daemon.js +206 -0
- package/dist/executor/gemini-warmup.d.ts +13 -0
- package/dist/executor/gemini-warmup.js +226 -0
- package/dist/executor/register.d.ts +17 -0
- package/dist/executor/register.js +142 -0
- package/dist/executor/test-harness.d.ts +12 -0
- package/dist/executor/test-harness.js +116 -0
- package/dist/executor/types/executor.d.ts +95 -0
- package/dist/executor/types/executor.js +7 -0
- package/dist/heartbeat/scheduler.d.ts +39 -0
- package/dist/heartbeat/scheduler.js +118 -0
- package/dist/hierarchical.d.ts +28 -0
- package/dist/hierarchical.js +183 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +49 -0
- package/dist/orchestrator.d.ts +48 -0
- package/dist/orchestrator.js +273 -0
- package/dist/rag/enrichment.d.ts +27 -0
- package/dist/rag/enrichment.js +154 -0
- package/dist/rag/index.d.ts +1 -0
- package/dist/rag/index.js +1 -0
- package/dist/registry/loader.d.ts +14 -0
- package/dist/registry/loader.js +38 -0
- package/dist/roles/persona-seeder.d.ts +30 -0
- package/dist/roles/persona-seeder.js +76 -0
- package/dist/roles/registry.d.ts +36 -0
- package/dist/roles/registry.js +92 -0
- package/dist/routing/engine.d.ts +30 -0
- package/dist/routing/engine.js +170 -0
- package/dist/selector/executor-selector.d.ts +25 -0
- package/dist/selector/executor-selector.js +144 -0
- package/dist/stagnation/detector.d.ts +52 -0
- package/dist/stagnation/detector.js +150 -0
- package/dist/streaming/capture.d.ts +51 -0
- package/dist/streaming/capture.js +140 -0
- package/dist/tokens/optimizer.d.ts +61 -0
- package/dist/tokens/optimizer.js +148 -0
- package/dist/transport/acp-transport.d.ts +42 -0
- package/dist/transport/acp-transport.js +252 -0
- package/dist/transport/bridge-transport.d.ts +22 -0
- package/dist/transport/bridge-transport.js +48 -0
- package/dist/transport/factory.d.ts +10 -0
- package/dist/transport/factory.js +33 -0
- package/dist/transport/types.d.ts +49 -0
- package/dist/transport/types.js +7 -0
- package/dist/types.d.ts +195 -0
- package/dist/types.js +6 -0
- package/dist/verification/capabilities.d.ts +60 -0
- package/dist/verification/capabilities.js +279 -0
- package/dist/verification/gap-audit.d.ts +43 -0
- package/dist/verification/gap-audit.js +292 -0
- package/dist/verification/index.d.ts +14 -0
- package/dist/verification/index.js +11 -0
- package/dist/verification/verify-wiring.d.ts +45 -0
- package/dist/verification/verify-wiring.js +290 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 marlandoj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# zouroboros-swarm
|
|
2
|
+
|
|
3
|
+
> Multi-agent orchestration with circuit breakers and 6-signal routing
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Circuit Breaker V2** — CLOSED/OPEN/HALF_OPEN states with category-aware failure tracking
|
|
8
|
+
- **6-Signal Composite Routing** — Capability, health, complexity fit, history, procedure, temporal
|
|
9
|
+
- **Hierarchical Orchestration** — Hermes/Claude parent tasks can self-decompose under centralized delegation policy
|
|
10
|
+
- **Executor Bridges** — Claude Code, Hermes, Gemini, Codex CLI integration
|
|
11
|
+
- **DAG Execution** — Streaming and wave-based task execution modes
|
|
12
|
+
- **Registry-Based** — JSON registry for executor configuration
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install zouroboros-swarm
|
|
18
|
+
# or
|
|
19
|
+
pnpm add zouroboros-swarm
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### ACP Adapter Prerequisites
|
|
23
|
+
|
|
24
|
+
The swarm uses the [Agent Client Protocol (ACP)](https://github.com/agentclientprotocol) to communicate with Claude Code, Codex, and Gemini executors. Install the required adapter binaries before running:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Install all ACP adapters
|
|
28
|
+
bash packages/swarm/scripts/install-acp-adapters.sh
|
|
29
|
+
|
|
30
|
+
# Verify installation
|
|
31
|
+
bash packages/swarm/scripts/install-acp-adapters.sh --check
|
|
32
|
+
|
|
33
|
+
# Update to latest versions
|
|
34
|
+
bash packages/swarm/scripts/install-acp-adapters.sh --update
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Executor | Adapter | npm Package |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| Claude Code | `claude-agent-acp` | `@zed-industries/claude-agent-acp` |
|
|
40
|
+
| Codex | `codex-acp` | `@zed-industries/codex-acp` |
|
|
41
|
+
| Gemini | `gemini --acp` | `@google/gemini-cli` |
|
|
42
|
+
| Hermes | bridge (no ACP adapter) | — |
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { SwarmOrchestrator } from 'zouroboros-swarm';
|
|
48
|
+
|
|
49
|
+
const orchestrator = new SwarmOrchestrator({
|
|
50
|
+
localConcurrency: 8,
|
|
51
|
+
timeoutSeconds: 600,
|
|
52
|
+
routingStrategy: 'balanced',
|
|
53
|
+
dagMode: 'streaming',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const tasks = [
|
|
57
|
+
{ id: '1', persona: 'developer', task: 'Fix the auth bug in login.ts', priority: 'high' },
|
|
58
|
+
{ id: '2', persona: 'reviewer', task: 'Review the PR for error handling', priority: 'medium', dependsOn: ['1'] },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const results = await orchestrator.run(tasks);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## CLI Usage
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Run a swarm campaign
|
|
68
|
+
zouroboros-swarm ./tasks.json
|
|
69
|
+
|
|
70
|
+
# With options
|
|
71
|
+
zouroboros-swarm ./tasks.json --mode waves --concurrency 4 --strategy fast
|
|
72
|
+
|
|
73
|
+
# Inspect a completed run
|
|
74
|
+
zouroboros-swarm status <swarm-id>
|
|
75
|
+
|
|
76
|
+
# Inspect executor routing and delegation history
|
|
77
|
+
zouroboros-swarm history 10
|
|
78
|
+
|
|
79
|
+
# Health check
|
|
80
|
+
zouroboros-swarm doctor
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`status <swarm-id>` now surfaces persisted hierarchical telemetry from the results file, including delegated parent count, child task count, artifact count, reroutes, and effective executors.
|
|
84
|
+
|
|
85
|
+
`history [limit]` reads `executor-history.db` and prints delegation-aware routing history per executor/category, including:
|
|
86
|
+
- base success rate
|
|
87
|
+
- delegated attempt/success rate
|
|
88
|
+
- child success rate
|
|
89
|
+
- average child count
|
|
90
|
+
- average child duration
|
|
91
|
+
|
|
92
|
+
## Task Format
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
[
|
|
96
|
+
{
|
|
97
|
+
"id": "task-1",
|
|
98
|
+
"persona": "developer",
|
|
99
|
+
"task": "Implement user authentication",
|
|
100
|
+
"priority": "high",
|
|
101
|
+
"executor": "claude-code",
|
|
102
|
+
"dependsOn": [],
|
|
103
|
+
"timeoutSeconds": 600
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"id": "task-2",
|
|
107
|
+
"persona": "tester",
|
|
108
|
+
"task": "Write tests for auth",
|
|
109
|
+
"priority": "medium",
|
|
110
|
+
"dependsOn": ["task-1"]
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Hierarchical delegation is available through an optional `delegation` block on each task:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"id": "implementation-safe",
|
|
120
|
+
"executor": "claude-code",
|
|
121
|
+
"task": "Implement the parser cleanup and synthesize the result.",
|
|
122
|
+
"delegation": {
|
|
123
|
+
"mode": "auto",
|
|
124
|
+
"maxChildren": 2,
|
|
125
|
+
"writeScopes": [
|
|
126
|
+
{ "childId": "parser-a", "paths": ["src/parser/a.ts"] },
|
|
127
|
+
{ "childId": "parser-b", "paths": ["src/parser/b.ts"] }
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- `mode: "auto"` enables executor-side self-decomposition when policy allows it.
|
|
134
|
+
- Mutation tasks require disjoint `writeScopes`; otherwise they are forced to remain leaf tasks.
|
|
135
|
+
- Results now persist parent/child telemetry, including `delegated`, `effectiveExecutor`, `childRecords`, and artifact lists.
|
|
136
|
+
|
|
137
|
+
Example status output for a completed hierarchical run:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
🔍 Swarm Status: hierarchical-broader-validation-test
|
|
141
|
+
Status: complete
|
|
142
|
+
Results: ~/.swarm/results/hierarchical-broader-validation-test.json
|
|
143
|
+
Outcome: 4/4 succeeded, 0 failed
|
|
144
|
+
Duration: 4s
|
|
145
|
+
Delegated: 3 parent / 5 child
|
|
146
|
+
Artifacts: 4
|
|
147
|
+
Reroutes: 1
|
|
148
|
+
Executors: hermes, claude-code
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Routing Strategies
|
|
152
|
+
|
|
153
|
+
| Strategy | Best For | Weight Focus |
|
|
154
|
+
|----------|----------|--------------|
|
|
155
|
+
| `fast` | Quick iterations | Complexity fit (40%), Health (20%) |
|
|
156
|
+
| `reliable` | Production tasks | Health (35%), History (18%) |
|
|
157
|
+
| `balanced` | General use | Even distribution |
|
|
158
|
+
| `explore` | New domains | Capability (35%), Complexity (18%) |
|
|
159
|
+
|
|
160
|
+
## Circuit Breaker States
|
|
161
|
+
|
|
162
|
+
- **CLOSED** — Normal operation, requests pass through
|
|
163
|
+
- **OPEN** — Failure threshold exceeded, requests blocked
|
|
164
|
+
- **HALF_OPEN** — Testing if service recovered
|
|
165
|
+
|
|
166
|
+
## Executor Registry
|
|
167
|
+
|
|
168
|
+
Create `~/.zouroboros/executors.json`:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"executors": [
|
|
173
|
+
{
|
|
174
|
+
"id": "claude-code",
|
|
175
|
+
"name": "Claude Code",
|
|
176
|
+
"executor": "local",
|
|
177
|
+
"bridge": "bridges/claude-code-bridge.sh",
|
|
178
|
+
"expertise": ["code-generation", "debugging", "refactoring"],
|
|
179
|
+
"bestFor": ["Complex multi-file changes"]
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm API Server — embedded Hono server with SSE support.
|
|
3
|
+
*
|
|
4
|
+
* Exposes swarm state as REST endpoints + real-time SSE activity stream.
|
|
5
|
+
* Registered as a Zo user service for persistent hosting.
|
|
6
|
+
*/
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { BudgetGovernor } from '../budget/governor.js';
|
|
9
|
+
import { HeartbeatScheduler } from '../heartbeat/scheduler.js';
|
|
10
|
+
import { RoleRegistry } from '../roles/registry.js';
|
|
11
|
+
export interface SwarmAPIConfig {
|
|
12
|
+
port: number;
|
|
13
|
+
authToken?: string;
|
|
14
|
+
dbPath?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SSEEvent {
|
|
17
|
+
type: string;
|
|
18
|
+
data: Record<string, unknown>;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function createSwarmAPI(config: SwarmAPIConfig): {
|
|
22
|
+
app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
|
23
|
+
budget: BudgetGovernor;
|
|
24
|
+
heartbeat: HeartbeatScheduler;
|
|
25
|
+
roles: RoleRegistry;
|
|
26
|
+
broadcastSSE: (event: SSEEvent) => void;
|
|
27
|
+
};
|
|
28
|
+
export declare function startSwarmServer(config?: Partial<SwarmAPIConfig>): void;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm API Server — embedded Hono server with SSE support.
|
|
3
|
+
*
|
|
4
|
+
* Exposes swarm state as REST endpoints + real-time SSE activity stream.
|
|
5
|
+
* Registered as a Zo user service for persistent hosting.
|
|
6
|
+
*/
|
|
7
|
+
import { Hono } from 'hono';
|
|
8
|
+
import { cors } from 'hono/cors';
|
|
9
|
+
import { BudgetGovernor } from '../budget/governor.js';
|
|
10
|
+
import { HeartbeatScheduler } from '../heartbeat/scheduler.js';
|
|
11
|
+
import { RoleRegistry } from '../roles/registry.js';
|
|
12
|
+
import { getDb } from '../db/schema.js';
|
|
13
|
+
export function createSwarmAPI(config) {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
const budget = new BudgetGovernor(config.dbPath);
|
|
16
|
+
const heartbeat = new HeartbeatScheduler(config.dbPath);
|
|
17
|
+
const roles = new RoleRegistry(config.dbPath);
|
|
18
|
+
const db = getDb(config.dbPath);
|
|
19
|
+
const sseClients = new Set();
|
|
20
|
+
const eventLog = [];
|
|
21
|
+
app.use('*', cors());
|
|
22
|
+
if (config.authToken) {
|
|
23
|
+
app.use('/api/swarm/*', async (c, next) => {
|
|
24
|
+
const auth = c.req.header('authorization');
|
|
25
|
+
if (!auth?.startsWith('Bearer ') || auth.slice(7) !== config.authToken) {
|
|
26
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
27
|
+
}
|
|
28
|
+
await next();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function broadcastSSE(event) {
|
|
32
|
+
eventLog.push(event);
|
|
33
|
+
if (eventLog.length > 1000)
|
|
34
|
+
eventLog.splice(0, eventLog.length - 500);
|
|
35
|
+
const payload = `data: ${JSON.stringify(event)}\n\n`;
|
|
36
|
+
for (const controller of sseClients) {
|
|
37
|
+
try {
|
|
38
|
+
controller.enqueue(new TextEncoder().encode(payload));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
sseClients.delete(controller);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
budget.on((evt) => broadcastSSE({ type: evt.type, data: evt.data, timestamp: evt.timestamp }));
|
|
46
|
+
heartbeat.on((evt) => broadcastSSE({
|
|
47
|
+
type: 'heartbeat',
|
|
48
|
+
data: { swarmId: evt.swarmId, beat: evt.beatNumber, status: evt.status },
|
|
49
|
+
timestamp: evt.timestamp,
|
|
50
|
+
}));
|
|
51
|
+
// --- Status ---
|
|
52
|
+
app.get('/api/swarm/status', (c) => {
|
|
53
|
+
return c.json({
|
|
54
|
+
status: 'running',
|
|
55
|
+
timestamp: Date.now(),
|
|
56
|
+
executors: ['claude-code', 'gemini', 'codex', 'hermes'],
|
|
57
|
+
heartbeats: {
|
|
58
|
+
active: Array.from({ length: 4 }, (_, i) => ['claude-code', 'gemini', 'codex', 'hermes'][i])
|
|
59
|
+
.filter(id => heartbeat.isRunning(id)),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
// --- Tasks ---
|
|
64
|
+
app.get('/api/swarm/tasks', (c) => {
|
|
65
|
+
const executor = c.req.query('executor');
|
|
66
|
+
const status = c.req.query('status');
|
|
67
|
+
// Tasks are managed by the orchestrator runtime; return from event log
|
|
68
|
+
const taskEvents = eventLog.filter(e => e.type.startsWith('task:') &&
|
|
69
|
+
(!executor || e.data.executorId === executor) &&
|
|
70
|
+
(!status || e.data.status === status));
|
|
71
|
+
return c.json({ tasks: taskEvents, count: taskEvents.length });
|
|
72
|
+
});
|
|
73
|
+
app.get('/api/swarm/tasks/:id', (c) => {
|
|
74
|
+
const taskId = c.req.param('id');
|
|
75
|
+
const events = eventLog.filter(e => e.data.taskId === taskId);
|
|
76
|
+
return c.json({ taskId, events });
|
|
77
|
+
});
|
|
78
|
+
// --- Budget ---
|
|
79
|
+
app.get('/api/swarm/budget', (c) => {
|
|
80
|
+
const swarmId = c.req.query('swarmId') ?? 'default';
|
|
81
|
+
const state = budget.getState(swarmId);
|
|
82
|
+
return c.json(state);
|
|
83
|
+
});
|
|
84
|
+
app.post('/api/swarm/budget/init', async (c) => {
|
|
85
|
+
const body = await c.req.json();
|
|
86
|
+
budget.initSwarm({
|
|
87
|
+
swarmId: body.swarmId ?? 'default',
|
|
88
|
+
totalBudgetUSD: body.totalBudgetUSD,
|
|
89
|
+
perExecutorLimits: body.perExecutorLimits,
|
|
90
|
+
alertThresholdPct: body.alertThresholdPct,
|
|
91
|
+
hardCapAction: body.hardCapAction ?? 'downgrade',
|
|
92
|
+
});
|
|
93
|
+
return c.json({ success: true });
|
|
94
|
+
});
|
|
95
|
+
// --- Health ---
|
|
96
|
+
app.get('/api/swarm/health', (c) => {
|
|
97
|
+
const executors = ['claude-code', 'gemini', 'codex', 'hermes'];
|
|
98
|
+
const health = {};
|
|
99
|
+
for (const id of executors) {
|
|
100
|
+
health[id] = {
|
|
101
|
+
state: 'CLOSED',
|
|
102
|
+
failures: 0,
|
|
103
|
+
heartbeatActive: heartbeat.isRunning(id),
|
|
104
|
+
beatCount: heartbeat.getBeatCount(id),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return c.json({ executors: health, timestamp: Date.now() });
|
|
108
|
+
});
|
|
109
|
+
// --- Roles CRUD ---
|
|
110
|
+
app.get('/api/swarm/roles', (c) => {
|
|
111
|
+
return c.json({ roles: roles.list() });
|
|
112
|
+
});
|
|
113
|
+
app.get('/api/swarm/roles/:id', (c) => {
|
|
114
|
+
const role = roles.get(c.req.param('id'));
|
|
115
|
+
if (!role)
|
|
116
|
+
return c.json({ error: 'Role not found' }, 404);
|
|
117
|
+
return c.json(role);
|
|
118
|
+
});
|
|
119
|
+
app.post('/api/swarm/roles', async (c) => {
|
|
120
|
+
const body = await c.req.json();
|
|
121
|
+
try {
|
|
122
|
+
const role = roles.create(body);
|
|
123
|
+
broadcastSSE({ type: 'role:created', data: { role }, timestamp: Date.now() });
|
|
124
|
+
return c.json(role, 201);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
return c.json({ error: err.message }, 400);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
app.put('/api/swarm/roles/:id', async (c) => {
|
|
131
|
+
const body = await c.req.json();
|
|
132
|
+
const updated = roles.update(c.req.param('id'), body);
|
|
133
|
+
if (!updated)
|
|
134
|
+
return c.json({ error: 'Role not found' }, 404);
|
|
135
|
+
broadcastSSE({ type: 'role:updated', data: { role: updated }, timestamp: Date.now() });
|
|
136
|
+
return c.json(updated);
|
|
137
|
+
});
|
|
138
|
+
app.delete('/api/swarm/roles/:id', (c) => {
|
|
139
|
+
const deleted = roles.delete(c.req.param('id'));
|
|
140
|
+
if (!deleted)
|
|
141
|
+
return c.json({ error: 'Role not found' }, 404);
|
|
142
|
+
broadcastSSE({ type: 'role:deleted', data: { roleId: c.req.param('id') }, timestamp: Date.now() });
|
|
143
|
+
return c.json({ success: true });
|
|
144
|
+
});
|
|
145
|
+
// --- SSE Activity Stream ---
|
|
146
|
+
app.get('/api/swarm/activity', (c) => {
|
|
147
|
+
const stream = new ReadableStream({
|
|
148
|
+
start(controller) {
|
|
149
|
+
sseClients.add(controller);
|
|
150
|
+
const recent = eventLog.slice(-20);
|
|
151
|
+
for (const event of recent) {
|
|
152
|
+
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
cancel(controller) {
|
|
156
|
+
sseClients.delete(controller);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
return new Response(stream, {
|
|
160
|
+
headers: {
|
|
161
|
+
'Content-Type': 'text/event-stream',
|
|
162
|
+
'Cache-Control': 'no-cache',
|
|
163
|
+
'Connection': 'keep-alive',
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
// --- Dispatch ---
|
|
168
|
+
app.post('/api/swarm/dispatch', async (c) => {
|
|
169
|
+
const body = await c.req.json();
|
|
170
|
+
const swarmId = body.swarmId ?? `swarm-${Date.now()}`;
|
|
171
|
+
broadcastSSE({
|
|
172
|
+
type: 'swarm:dispatched',
|
|
173
|
+
data: { swarmId, taskCount: body.tasks?.length ?? 0 },
|
|
174
|
+
timestamp: Date.now(),
|
|
175
|
+
});
|
|
176
|
+
return c.json({ swarmId, status: 'dispatched', message: 'Swarm dispatch queued' });
|
|
177
|
+
});
|
|
178
|
+
// --- Pause / Resume / Abort ---
|
|
179
|
+
app.post('/api/swarm/pause/:taskId', (c) => {
|
|
180
|
+
const taskId = c.req.param('taskId');
|
|
181
|
+
broadcastSSE({ type: 'task:paused', data: { taskId }, timestamp: Date.now() });
|
|
182
|
+
return c.json({ taskId, status: 'paused' });
|
|
183
|
+
});
|
|
184
|
+
app.post('/api/swarm/resume/:taskId', (c) => {
|
|
185
|
+
const taskId = c.req.param('taskId');
|
|
186
|
+
broadcastSSE({ type: 'task:resumed', data: { taskId }, timestamp: Date.now() });
|
|
187
|
+
return c.json({ taskId, status: 'resumed' });
|
|
188
|
+
});
|
|
189
|
+
app.post('/api/swarm/abort/:swarmId', (c) => {
|
|
190
|
+
const swarmId = c.req.param('swarmId');
|
|
191
|
+
heartbeat.stop(swarmId);
|
|
192
|
+
broadcastSSE({ type: 'swarm:aborted', data: { swarmId }, timestamp: Date.now() });
|
|
193
|
+
return c.json({ swarmId, status: 'aborted' });
|
|
194
|
+
});
|
|
195
|
+
// --- Heartbeat control ---
|
|
196
|
+
app.post('/api/swarm/heartbeat/start', async (c) => {
|
|
197
|
+
const body = await c.req.json();
|
|
198
|
+
heartbeat.start({
|
|
199
|
+
swarmId: body.swarmId ?? 'default',
|
|
200
|
+
intervalMs: body.intervalMs ?? 60000,
|
|
201
|
+
maxBeats: body.maxBeats ?? 0,
|
|
202
|
+
onIdle: body.onIdle ?? 'sleep',
|
|
203
|
+
});
|
|
204
|
+
return c.json({ success: true, swarmId: body.swarmId ?? 'default' });
|
|
205
|
+
});
|
|
206
|
+
app.post('/api/swarm/heartbeat/stop', async (c) => {
|
|
207
|
+
const body = await c.req.json();
|
|
208
|
+
heartbeat.stop(body.swarmId ?? 'default');
|
|
209
|
+
return c.json({ success: true });
|
|
210
|
+
});
|
|
211
|
+
return { app, budget, heartbeat, roles, broadcastSSE };
|
|
212
|
+
}
|
|
213
|
+
export function startSwarmServer(config) {
|
|
214
|
+
const port = config?.port ?? parseInt(process.env.PORT ?? '3847', 10);
|
|
215
|
+
const authToken = config?.authToken ?? process.env.SWARM_API_TOKEN;
|
|
216
|
+
const dbPath = config?.dbPath;
|
|
217
|
+
const { app } = createSwarmAPI({ port, authToken, dbPath });
|
|
218
|
+
Bun.serve({ port, fetch: app.fetch });
|
|
219
|
+
console.log(`Swarm API server listening on port ${port}`);
|
|
220
|
+
}
|
|
221
|
+
if (import.meta.main) {
|
|
222
|
+
startSwarmServer();
|
|
223
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget Governor — per-executor and per-swarm cost tracking with hard caps.
|
|
3
|
+
*
|
|
4
|
+
* Tracks token usage, normalizes to USD, emits alerts, and enforces caps.
|
|
5
|
+
* Hard cap action: "downgrade" switches remaining tasks to cheapest executor.
|
|
6
|
+
*/
|
|
7
|
+
export type HardCapAction = 'pause' | 'abort' | 'downgrade';
|
|
8
|
+
export interface BudgetConfig {
|
|
9
|
+
swarmId: string;
|
|
10
|
+
totalBudgetUSD: number;
|
|
11
|
+
perExecutorLimits?: Record<string, number>;
|
|
12
|
+
alertThresholdPct?: number;
|
|
13
|
+
hardCapAction?: HardCapAction;
|
|
14
|
+
}
|
|
15
|
+
export interface BudgetState {
|
|
16
|
+
swarmId: string;
|
|
17
|
+
totalSpentUSD: number;
|
|
18
|
+
totalBudgetUSD: number;
|
|
19
|
+
remaining: number;
|
|
20
|
+
perExecutor: Record<string, number>;
|
|
21
|
+
alertFired: boolean;
|
|
22
|
+
capReached: boolean;
|
|
23
|
+
hardCapAction: HardCapAction;
|
|
24
|
+
}
|
|
25
|
+
export interface BudgetEvent {
|
|
26
|
+
type: 'budget:update' | 'budget:alert' | 'budget:cap' | 'budget:downgrade';
|
|
27
|
+
swarmId: string;
|
|
28
|
+
data: Record<string, unknown>;
|
|
29
|
+
timestamp: number;
|
|
30
|
+
}
|
|
31
|
+
type BudgetListener = (event: BudgetEvent) => void;
|
|
32
|
+
export declare class BudgetGovernor {
|
|
33
|
+
private db;
|
|
34
|
+
private listeners;
|
|
35
|
+
constructor(dbPath?: string);
|
|
36
|
+
on(listener: BudgetListener): void;
|
|
37
|
+
private emit;
|
|
38
|
+
initSwarm(config: BudgetConfig): void;
|
|
39
|
+
recordUsage(swarmId: string, executorId: string, model: string, inputTokens: number, outputTokens: number): BudgetState;
|
|
40
|
+
getState(swarmId: string): BudgetState;
|
|
41
|
+
checkExecutorLimit(swarmId: string, executorId: string): boolean;
|
|
42
|
+
getDowngradeTarget(executorId: string): {
|
|
43
|
+
executorId: string;
|
|
44
|
+
model: string;
|
|
45
|
+
};
|
|
46
|
+
private getConfig;
|
|
47
|
+
}
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget Governor — per-executor and per-swarm cost tracking with hard caps.
|
|
3
|
+
*
|
|
4
|
+
* Tracks token usage, normalizes to USD, emits alerts, and enforces caps.
|
|
5
|
+
* Hard cap action: "downgrade" switches remaining tasks to cheapest executor.
|
|
6
|
+
*/
|
|
7
|
+
import { getDb } from '../db/schema.js';
|
|
8
|
+
import { estimateCostUSD, getCheapestModel } from './pricing.js';
|
|
9
|
+
export class BudgetGovernor {
|
|
10
|
+
db;
|
|
11
|
+
listeners = [];
|
|
12
|
+
constructor(dbPath) {
|
|
13
|
+
this.db = getDb(dbPath);
|
|
14
|
+
}
|
|
15
|
+
on(listener) {
|
|
16
|
+
this.listeners.push(listener);
|
|
17
|
+
}
|
|
18
|
+
emit(event) {
|
|
19
|
+
for (const listener of this.listeners) {
|
|
20
|
+
try {
|
|
21
|
+
listener(event);
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
initSwarm(config) {
|
|
27
|
+
this.db.run(`INSERT OR REPLACE INTO budget_config (swarm_id, total_budget_usd, alert_threshold_pct, hard_cap_action)
|
|
28
|
+
VALUES (?, ?, ?, ?)`, [config.swarmId, config.totalBudgetUSD, config.alertThresholdPct ?? 80, config.hardCapAction ?? 'downgrade']);
|
|
29
|
+
if (config.perExecutorLimits) {
|
|
30
|
+
const stmt = this.db.prepare('INSERT OR REPLACE INTO budget_per_executor (swarm_id, executor_id, limit_usd) VALUES (?, ?, ?)');
|
|
31
|
+
for (const [execId, limit] of Object.entries(config.perExecutorLimits)) {
|
|
32
|
+
stmt.run(config.swarmId, execId, limit);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
recordUsage(swarmId, executorId, model, inputTokens, outputTokens) {
|
|
37
|
+
const cost = estimateCostUSD(model, inputTokens, outputTokens);
|
|
38
|
+
const totalTokens = inputTokens + outputTokens;
|
|
39
|
+
this.db.run(`INSERT INTO swarm_budget (swarm_id, executor_id, tokens_used, cost_usd, updated_at)
|
|
40
|
+
VALUES (?, ?, ?, ?, unixepoch())
|
|
41
|
+
ON CONFLICT(swarm_id, executor_id) DO UPDATE SET
|
|
42
|
+
tokens_used = tokens_used + excluded.tokens_used,
|
|
43
|
+
cost_usd = cost_usd + excluded.cost_usd,
|
|
44
|
+
updated_at = unixepoch()`, [swarmId, executorId, totalTokens, cost]);
|
|
45
|
+
const state = this.getState(swarmId);
|
|
46
|
+
this.emit({
|
|
47
|
+
type: 'budget:update',
|
|
48
|
+
swarmId,
|
|
49
|
+
data: { executorId, cost, totalSpent: state.totalSpentUSD },
|
|
50
|
+
timestamp: Date.now(),
|
|
51
|
+
});
|
|
52
|
+
// Check alert threshold
|
|
53
|
+
const config = this.getConfig(swarmId);
|
|
54
|
+
if (config) {
|
|
55
|
+
const pct = (state.totalSpentUSD / config.total_budget_usd) * 100;
|
|
56
|
+
if (pct >= config.alert_threshold_pct && !state.alertFired) {
|
|
57
|
+
this.emit({
|
|
58
|
+
type: 'budget:alert',
|
|
59
|
+
swarmId,
|
|
60
|
+
data: { percentUsed: pct, spent: state.totalSpentUSD, budget: config.total_budget_usd },
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (state.totalSpentUSD >= config.total_budget_usd) {
|
|
65
|
+
this.emit({
|
|
66
|
+
type: 'budget:cap',
|
|
67
|
+
swarmId,
|
|
68
|
+
data: { action: config.hard_cap_action, spent: state.totalSpentUSD },
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return state;
|
|
74
|
+
}
|
|
75
|
+
getState(swarmId) {
|
|
76
|
+
const config = this.getConfig(swarmId);
|
|
77
|
+
const totalBudget = config?.total_budget_usd ?? 0;
|
|
78
|
+
const hardCapAction = (config?.hard_cap_action ?? 'downgrade');
|
|
79
|
+
const alertThreshold = config?.alert_threshold_pct ?? 80;
|
|
80
|
+
const rows = this.db.query('SELECT executor_id, cost_usd FROM swarm_budget WHERE swarm_id = ?').all(swarmId);
|
|
81
|
+
const perExecutor = {};
|
|
82
|
+
let totalSpent = 0;
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
perExecutor[row.executor_id] = row.cost_usd;
|
|
85
|
+
totalSpent += row.cost_usd;
|
|
86
|
+
}
|
|
87
|
+
const pct = totalBudget > 0 ? (totalSpent / totalBudget) * 100 : 0;
|
|
88
|
+
return {
|
|
89
|
+
swarmId,
|
|
90
|
+
totalSpentUSD: totalSpent,
|
|
91
|
+
totalBudgetUSD: totalBudget,
|
|
92
|
+
remaining: Math.max(0, totalBudget - totalSpent),
|
|
93
|
+
perExecutor,
|
|
94
|
+
alertFired: pct >= alertThreshold,
|
|
95
|
+
capReached: totalBudget > 0 && totalSpent >= totalBudget,
|
|
96
|
+
hardCapAction,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
checkExecutorLimit(swarmId, executorId) {
|
|
100
|
+
const limit = this.db.query('SELECT limit_usd FROM budget_per_executor WHERE swarm_id = ? AND executor_id = ?').get(swarmId, executorId);
|
|
101
|
+
if (!limit)
|
|
102
|
+
return true;
|
|
103
|
+
const usage = this.db.query('SELECT cost_usd FROM swarm_budget WHERE swarm_id = ? AND executor_id = ?').get(swarmId, executorId);
|
|
104
|
+
return (usage?.cost_usd ?? 0) < limit.limit_usd;
|
|
105
|
+
}
|
|
106
|
+
getDowngradeTarget(executorId) {
|
|
107
|
+
const cheapestModel = getCheapestModel(executorId);
|
|
108
|
+
const cheapestExecutor = executorId === 'hermes' ? 'hermes' : 'gemini';
|
|
109
|
+
return { executorId: cheapestExecutor, model: cheapestModel };
|
|
110
|
+
}
|
|
111
|
+
getConfig(swarmId) {
|
|
112
|
+
return this.db.query('SELECT * FROM budget_config WHERE swarm_id = ?').get(swarmId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model pricing tables for budget normalization.
|
|
3
|
+
*
|
|
4
|
+
* Prices are in USD per 1M tokens. Updated to reflect published rates.
|
|
5
|
+
*/
|
|
6
|
+
export interface ModelPricing {
|
|
7
|
+
inputPer1M: number;
|
|
8
|
+
outputPer1M: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function getModelPricing(model: string): ModelPricing;
|
|
11
|
+
export declare function estimateCostUSD(model: string, inputTokens: number, outputTokens: number): number;
|
|
12
|
+
export declare function getCheapestModel(executorId: string): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model pricing tables for budget normalization.
|
|
3
|
+
*
|
|
4
|
+
* Prices are in USD per 1M tokens. Updated to reflect published rates.
|
|
5
|
+
*/
|
|
6
|
+
const PRICING = {
|
|
7
|
+
// Anthropic
|
|
8
|
+
'opus': { inputPer1M: 15.00, outputPer1M: 75.00 },
|
|
9
|
+
'claude-opus-4-6': { inputPer1M: 15.00, outputPer1M: 75.00 },
|
|
10
|
+
'sonnet': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
11
|
+
'claude-sonnet-4-6': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
12
|
+
'haiku': { inputPer1M: 0.25, outputPer1M: 1.25 },
|
|
13
|
+
'claude-haiku-4-5': { inputPer1M: 0.25, outputPer1M: 1.25 },
|
|
14
|
+
// Google
|
|
15
|
+
'gemini-2.5-pro': { inputPer1M: 1.25, outputPer1M: 10.00 },
|
|
16
|
+
'gemini-2.5-flash': { inputPer1M: 0.15, outputPer1M: 0.60 },
|
|
17
|
+
'pro': { inputPer1M: 1.25, outputPer1M: 10.00 },
|
|
18
|
+
'flash': { inputPer1M: 0.15, outputPer1M: 0.60 },
|
|
19
|
+
// OpenAI
|
|
20
|
+
'gpt-5.x': { inputPer1M: 2.50, outputPer1M: 10.00 },
|
|
21
|
+
'gpt-4.1': { inputPer1M: 2.00, outputPer1M: 8.00 },
|
|
22
|
+
'o3': { inputPer1M: 10.00, outputPer1M: 40.00 },
|
|
23
|
+
// Free / BYOK
|
|
24
|
+
'byok': { inputPer1M: 0, outputPer1M: 0 },
|
|
25
|
+
'free': { inputPer1M: 0, outputPer1M: 0 },
|
|
26
|
+
};
|
|
27
|
+
export function getModelPricing(model) {
|
|
28
|
+
const key = model.toLowerCase();
|
|
29
|
+
return PRICING[key] ?? { inputPer1M: 1.00, outputPer1M: 5.00 };
|
|
30
|
+
}
|
|
31
|
+
export function estimateCostUSD(model, inputTokens, outputTokens) {
|
|
32
|
+
const pricing = getModelPricing(model);
|
|
33
|
+
return (inputTokens / 1_000_000) * pricing.inputPer1M +
|
|
34
|
+
(outputTokens / 1_000_000) * pricing.outputPer1M;
|
|
35
|
+
}
|
|
36
|
+
export function getCheapestModel(executorId) {
|
|
37
|
+
switch (executorId) {
|
|
38
|
+
case 'hermes': return 'byok';
|
|
39
|
+
case 'gemini': return 'flash';
|
|
40
|
+
case 'codex': return 'gpt-4.1';
|
|
41
|
+
case 'claude-code': return 'haiku';
|
|
42
|
+
default: return 'byok';
|
|
43
|
+
}
|
|
44
|
+
}
|