@kernel.chat/kbot 3.51.0 → 3.52.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/README.md +43 -9
- package/dist/agent-protocol.test.d.ts +2 -0
- package/dist/agent-protocol.test.d.ts.map +1 -0
- package/dist/agent-protocol.test.js +730 -0
- package/dist/agent-protocol.test.js.map +1 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +34 -10
- package/dist/agent.js.map +1 -1
- package/dist/auth.js +3 -3
- package/dist/auth.js.map +1 -1
- package/dist/bench.d.ts +64 -0
- package/dist/bench.d.ts.map +1 -0
- package/dist/bench.js +973 -0
- package/dist/bench.js.map +1 -0
- package/dist/cli.js +144 -29
- package/dist/cli.js.map +1 -1
- package/dist/cloud-agent.d.ts +77 -0
- package/dist/cloud-agent.d.ts.map +1 -0
- package/dist/cloud-agent.js +743 -0
- package/dist/cloud-agent.js.map +1 -0
- package/dist/context.test.d.ts +2 -0
- package/dist/context.test.d.ts.map +1 -0
- package/dist/context.test.js +561 -0
- package/dist/context.test.js.map +1 -0
- package/dist/evolution.d.ts.map +1 -1
- package/dist/evolution.js +4 -1
- package/dist/evolution.js.map +1 -1
- package/dist/github-release.d.ts +61 -0
- package/dist/github-release.d.ts.map +1 -0
- package/dist/github-release.js +451 -0
- package/dist/github-release.js.map +1 -0
- package/dist/graph-memory.test.d.ts +2 -0
- package/dist/graph-memory.test.d.ts.map +1 -0
- package/dist/graph-memory.test.js +946 -0
- package/dist/graph-memory.test.js.map +1 -0
- package/dist/init-science.d.ts +43 -0
- package/dist/init-science.d.ts.map +1 -0
- package/dist/init-science.js +477 -0
- package/dist/init-science.js.map +1 -0
- package/dist/lab.d.ts +45 -0
- package/dist/lab.d.ts.map +1 -0
- package/dist/lab.js +1020 -0
- package/dist/lab.js.map +1 -0
- package/dist/lsp-deep.d.ts +101 -0
- package/dist/lsp-deep.d.ts.map +1 -0
- package/dist/lsp-deep.js +689 -0
- package/dist/lsp-deep.js.map +1 -0
- package/dist/memory.test.d.ts +2 -0
- package/dist/memory.test.d.ts.map +1 -0
- package/dist/memory.test.js +369 -0
- package/dist/memory.test.js.map +1 -0
- package/dist/multi-session.d.ts +164 -0
- package/dist/multi-session.d.ts.map +1 -0
- package/dist/multi-session.js +885 -0
- package/dist/multi-session.js.map +1 -0
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +5 -2
- package/dist/self-eval.js.map +1 -1
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +0 -1
- package/dist/streaming.js.map +1 -1
- package/dist/teach.d.ts +136 -0
- package/dist/teach.d.ts.map +1 -0
- package/dist/teach.js +915 -0
- package/dist/teach.js.map +1 -0
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js.map +1 -1
- package/dist/tools/browser-agent.js +2 -2
- package/dist/tools/browser-agent.js.map +1 -1
- package/dist/tools/forge.d.ts.map +1 -1
- package/dist/tools/forge.js +15 -26
- package/dist/tools/forge.js.map +1 -1
- package/dist/tools/git.d.ts.map +1 -1
- package/dist/tools/git.js +10 -7
- package/dist/tools/git.js.map +1 -1
- package/dist/voice-realtime.d.ts +54 -0
- package/dist/voice-realtime.d.ts.map +1 -0
- package/dist/voice-realtime.js +805 -0
- package/dist/voice-realtime.js.map +1 -0
- package/package.json +10 -3
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
// kbot Cloud Agent — Persistent background agent execution over HTTP
|
|
2
|
+
//
|
|
3
|
+
// Extends `kbot serve` with long-running agent orchestration:
|
|
4
|
+
// kbot serve --cloud
|
|
5
|
+
//
|
|
6
|
+
// Endpoints:
|
|
7
|
+
// POST /agents — Create a cloud agent
|
|
8
|
+
// GET /agents — List all agents
|
|
9
|
+
// GET /agents/:id — Get agent status + results
|
|
10
|
+
// POST /agents/:id/pause — Pause agent
|
|
11
|
+
// POST /agents/:id/resume — Resume agent
|
|
12
|
+
// DELETE /agents/:id — Kill agent
|
|
13
|
+
// POST /agents/:id/message — Send a message to running agent
|
|
14
|
+
// GET /agents/:id/stream — SSE stream of agent events
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { runAgent } from './agent.js';
|
|
21
|
+
import { ResponseStream } from './streaming.js';
|
|
22
|
+
// ── Constants ──
|
|
23
|
+
const MAX_CONCURRENT_AGENTS = 4;
|
|
24
|
+
const PERSIST_DIR = join(homedir(), '.kbot', 'cloud-agents');
|
|
25
|
+
const CRON_CHECK_INTERVAL_MS = 60_000; // check cron schedules every 60s
|
|
26
|
+
// ── State ──
|
|
27
|
+
const agents = new Map();
|
|
28
|
+
const runningAbortControllers = new Map();
|
|
29
|
+
const sseListeners = new Map();
|
|
30
|
+
let cronIntervalId = null;
|
|
31
|
+
// ── Persistence ──
|
|
32
|
+
function ensurePersistDir() {
|
|
33
|
+
if (!existsSync(PERSIST_DIR)) {
|
|
34
|
+
mkdirSync(PERSIST_DIR, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function persistAgent(agent) {
|
|
38
|
+
ensurePersistDir();
|
|
39
|
+
const filePath = join(PERSIST_DIR, `${agent.id}.json`);
|
|
40
|
+
await writeFile(filePath, JSON.stringify(agent, null, 2), 'utf-8');
|
|
41
|
+
}
|
|
42
|
+
async function loadPersistedAgents() {
|
|
43
|
+
ensurePersistDir();
|
|
44
|
+
const files = readdirSync(PERSIST_DIR).filter(f => f.endsWith('.json'));
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = await readFile(join(PERSIST_DIR, file), 'utf-8');
|
|
48
|
+
const agent = JSON.parse(raw);
|
|
49
|
+
// Normalize older persisted agents that lack pendingMessages
|
|
50
|
+
if (!agent.pendingMessages)
|
|
51
|
+
agent.pendingMessages = [];
|
|
52
|
+
agents.set(agent.id, agent);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Skip corrupted files
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function removePersistedAgent(id) {
|
|
60
|
+
const filePath = join(PERSIST_DIR, `${id}.json`);
|
|
61
|
+
try {
|
|
62
|
+
unlinkSync(filePath);
|
|
63
|
+
}
|
|
64
|
+
catch { /* already gone */ }
|
|
65
|
+
}
|
|
66
|
+
// ── SSE Helpers ──
|
|
67
|
+
function emitSSE(agentId, event, data) {
|
|
68
|
+
const listeners = sseListeners.get(agentId);
|
|
69
|
+
if (!listeners || listeners.size === 0)
|
|
70
|
+
return;
|
|
71
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
72
|
+
for (const res of listeners) {
|
|
73
|
+
try {
|
|
74
|
+
res.write(payload);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
listeners.delete(res);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function addSSEListener(agentId, res) {
|
|
82
|
+
if (!sseListeners.has(agentId))
|
|
83
|
+
sseListeners.set(agentId, new Set());
|
|
84
|
+
sseListeners.get(agentId).add(res);
|
|
85
|
+
}
|
|
86
|
+
function removeSSEListener(agentId, res) {
|
|
87
|
+
sseListeners.get(agentId)?.delete(res);
|
|
88
|
+
}
|
|
89
|
+
// ── Webhook ──
|
|
90
|
+
async function postWebhook(url, payload) {
|
|
91
|
+
try {
|
|
92
|
+
await fetch(url, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json' },
|
|
95
|
+
body: JSON.stringify(payload),
|
|
96
|
+
signal: AbortSignal.timeout(15_000),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Webhook delivery is best-effort; don't crash the agent
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse a standard 5-field cron expression into expanded arrays.
|
|
105
|
+
*
|
|
106
|
+
* Supports:
|
|
107
|
+
* - Wildcards: *
|
|
108
|
+
* - Ranges: 1-5
|
|
109
|
+
* - Steps: *\/5, 1-10/2
|
|
110
|
+
* - Lists: 1,3,5
|
|
111
|
+
* - Combinations: 1-5,10,15-20/2
|
|
112
|
+
*
|
|
113
|
+
* Fields: minute (0-59), hour (0-23), day-of-month (1-31), month (1-12), day-of-week (0-6, Sun=0)
|
|
114
|
+
*/
|
|
115
|
+
export function parseCron(expr) {
|
|
116
|
+
const parts = expr.trim().split(/\s+/);
|
|
117
|
+
if (parts.length !== 5) {
|
|
118
|
+
throw new Error(`Invalid cron expression: expected 5 fields, got ${parts.length}`);
|
|
119
|
+
}
|
|
120
|
+
const ranges = [
|
|
121
|
+
[0, 59], // minute
|
|
122
|
+
[0, 23], // hour
|
|
123
|
+
[1, 31], // dayOfMonth
|
|
124
|
+
[1, 12], // month
|
|
125
|
+
[0, 6], // dayOfWeek
|
|
126
|
+
];
|
|
127
|
+
function expandField(field, min, max) {
|
|
128
|
+
const values = new Set();
|
|
129
|
+
for (const segment of field.split(',')) {
|
|
130
|
+
// Handle step: */N or range/N
|
|
131
|
+
const stepMatch = segment.match(/^(.+)\/(\d+)$/);
|
|
132
|
+
const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
|
|
133
|
+
const base = stepMatch ? stepMatch[1] : segment;
|
|
134
|
+
let rangeStart = min;
|
|
135
|
+
let rangeEnd = max;
|
|
136
|
+
if (base === '*') {
|
|
137
|
+
// full range
|
|
138
|
+
}
|
|
139
|
+
else if (base.includes('-')) {
|
|
140
|
+
const [lo, hi] = base.split('-').map(Number);
|
|
141
|
+
if (isNaN(lo) || isNaN(hi) || lo < min || hi > max || lo > hi) {
|
|
142
|
+
throw new Error(`Invalid cron range: ${base} (valid: ${min}-${max})`);
|
|
143
|
+
}
|
|
144
|
+
rangeStart = lo;
|
|
145
|
+
rangeEnd = hi;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const val = parseInt(base, 10);
|
|
149
|
+
if (isNaN(val) || val < min || val > max) {
|
|
150
|
+
throw new Error(`Invalid cron value: ${base} (valid: ${min}-${max})`);
|
|
151
|
+
}
|
|
152
|
+
if (step === 1) {
|
|
153
|
+
values.add(val);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
rangeStart = val;
|
|
157
|
+
rangeEnd = max;
|
|
158
|
+
}
|
|
159
|
+
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
|
160
|
+
values.add(i);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return Array.from(values).sort((a, b) => a - b);
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
minute: expandField(parts[0], ranges[0][0], ranges[0][1]),
|
|
167
|
+
hour: expandField(parts[1], ranges[1][0], ranges[1][1]),
|
|
168
|
+
dayOfMonth: expandField(parts[2], ranges[2][0], ranges[2][1]),
|
|
169
|
+
month: expandField(parts[3], ranges[3][0], ranges[3][1]),
|
|
170
|
+
dayOfWeek: expandField(parts[4], ranges[4][0], ranges[4][1]),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export function shouldRunAt(cron, date) {
|
|
174
|
+
return (cron.minute.includes(date.getMinutes()) &&
|
|
175
|
+
cron.hour.includes(date.getHours()) &&
|
|
176
|
+
cron.month.includes(date.getMonth() + 1) &&
|
|
177
|
+
// Standard cron: if both day-of-month and day-of-week are restricted, match either
|
|
178
|
+
(cron.dayOfMonth.includes(date.getDate()) || cron.dayOfWeek.includes(date.getDay())));
|
|
179
|
+
}
|
|
180
|
+
// ── Agent Execution ──
|
|
181
|
+
function countRunningAgents() {
|
|
182
|
+
let count = 0;
|
|
183
|
+
for (const a of agents.values()) {
|
|
184
|
+
if (a.status === 'running')
|
|
185
|
+
count++;
|
|
186
|
+
}
|
|
187
|
+
return count;
|
|
188
|
+
}
|
|
189
|
+
function promoteQueuedAgent() {
|
|
190
|
+
for (const agent of agents.values()) {
|
|
191
|
+
if (agent.status === 'queued') {
|
|
192
|
+
executeAgent(agent);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function executeAgent(agent) {
|
|
198
|
+
agent.status = 'running';
|
|
199
|
+
agent.lastActiveAt = new Date().toISOString();
|
|
200
|
+
emitSSE(agent.id, 'status', { status: 'running', iteration: agent.currentIteration });
|
|
201
|
+
await persistAgent(agent);
|
|
202
|
+
const ac = new AbortController();
|
|
203
|
+
runningAbortControllers.set(agent.id, ac);
|
|
204
|
+
const iterations = agent.maxIterations ?? 1;
|
|
205
|
+
try {
|
|
206
|
+
while (agent.currentIteration < iterations && agent.status === 'running') {
|
|
207
|
+
const iterationIndex = agent.currentIteration;
|
|
208
|
+
const startTime = Date.now();
|
|
209
|
+
// Build the task message, appending pending messages if any
|
|
210
|
+
let taskMessage = agent.task;
|
|
211
|
+
if (agent.pendingMessages.length > 0) {
|
|
212
|
+
taskMessage += '\n\n--- Additional context ---\n' + agent.pendingMessages.join('\n');
|
|
213
|
+
agent.pendingMessages = [];
|
|
214
|
+
}
|
|
215
|
+
// Iteration context for multi-iteration agents
|
|
216
|
+
if (iterations > 1) {
|
|
217
|
+
taskMessage = `[Iteration ${iterationIndex + 1}/${iterations}] ${taskMessage}`;
|
|
218
|
+
}
|
|
219
|
+
emitSSE(agent.id, 'iteration_start', { iteration: iterationIndex + 1, total: iterations });
|
|
220
|
+
// Create a ResponseStream to capture tool calls and content
|
|
221
|
+
const stream = new ResponseStream();
|
|
222
|
+
const toolCalls = [];
|
|
223
|
+
stream.on((event) => {
|
|
224
|
+
if (event.type === 'tool_call_start') {
|
|
225
|
+
toolCalls.push(event.name);
|
|
226
|
+
emitSSE(agent.id, 'tool_call', { tool: event.name, id: event.id });
|
|
227
|
+
}
|
|
228
|
+
if (event.type === 'tool_call_end') {
|
|
229
|
+
emitSSE(agent.id, 'tool_call_end', { tool: event.name, id: event.id });
|
|
230
|
+
}
|
|
231
|
+
if (event.type === 'content_delta') {
|
|
232
|
+
emitSSE(agent.id, 'content_delta', { delta: event.text });
|
|
233
|
+
}
|
|
234
|
+
if (event.type === 'error') {
|
|
235
|
+
emitSSE(agent.id, 'error', { error: event.message });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
const agentOpts = {
|
|
239
|
+
agent: agent.agent,
|
|
240
|
+
stream: true,
|
|
241
|
+
responseStream: stream,
|
|
242
|
+
};
|
|
243
|
+
let response;
|
|
244
|
+
try {
|
|
245
|
+
response = await runAgent(taskMessage, agentOpts);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
// If this is an abort (paused or killed), stop cleanly
|
|
249
|
+
if (ac.signal.aborted)
|
|
250
|
+
return;
|
|
251
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
252
|
+
agent.error = errorMsg;
|
|
253
|
+
agent.status = 'failed';
|
|
254
|
+
agent.lastActiveAt = new Date().toISOString();
|
|
255
|
+
emitSSE(agent.id, 'error', { error: errorMsg, iteration: iterationIndex + 1 });
|
|
256
|
+
emitSSE(agent.id, 'status', { status: 'failed' });
|
|
257
|
+
await persistAgent(agent);
|
|
258
|
+
runningAbortControllers.delete(agent.id);
|
|
259
|
+
promoteQueuedAgent();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const duration = Date.now() - startTime;
|
|
263
|
+
const result = {
|
|
264
|
+
iteration: iterationIndex + 1,
|
|
265
|
+
content: response.content,
|
|
266
|
+
toolCalls,
|
|
267
|
+
tokens: {
|
|
268
|
+
input: response.usage?.input_tokens ?? 0,
|
|
269
|
+
output: response.usage?.output_tokens ?? 0,
|
|
270
|
+
},
|
|
271
|
+
duration,
|
|
272
|
+
timestamp: new Date().toISOString(),
|
|
273
|
+
};
|
|
274
|
+
agent.results.push(result);
|
|
275
|
+
agent.currentIteration = iterationIndex + 1;
|
|
276
|
+
agent.lastActiveAt = new Date().toISOString();
|
|
277
|
+
emitSSE(agent.id, 'iteration_complete', {
|
|
278
|
+
iteration: iterationIndex + 1,
|
|
279
|
+
content: response.content.slice(0, 500), // preview
|
|
280
|
+
toolCalls: toolCalls.length,
|
|
281
|
+
duration,
|
|
282
|
+
});
|
|
283
|
+
// Webhook delivery
|
|
284
|
+
if (agent.webhook) {
|
|
285
|
+
await postWebhook(agent.webhook, {
|
|
286
|
+
agentId: agent.id,
|
|
287
|
+
agentName: agent.name,
|
|
288
|
+
event: 'iteration_complete',
|
|
289
|
+
result,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
await persistAgent(agent);
|
|
293
|
+
// Check if paused during iteration
|
|
294
|
+
if (agent.status !== 'running')
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// All iterations completed
|
|
298
|
+
if (agent.status === 'running') {
|
|
299
|
+
agent.status = 'completed';
|
|
300
|
+
agent.lastActiveAt = new Date().toISOString();
|
|
301
|
+
emitSSE(agent.id, 'status', { status: 'completed' });
|
|
302
|
+
if (agent.webhook) {
|
|
303
|
+
await postWebhook(agent.webhook, {
|
|
304
|
+
agentId: agent.id,
|
|
305
|
+
agentName: agent.name,
|
|
306
|
+
event: 'completed',
|
|
307
|
+
totalIterations: agent.currentIteration,
|
|
308
|
+
results: agent.results,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
await persistAgent(agent);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
runningAbortControllers.delete(agent.id);
|
|
316
|
+
promoteQueuedAgent();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// ── CRUD Operations ──
|
|
320
|
+
export async function createCloudAgent(req) {
|
|
321
|
+
if (!req.task || req.task.trim().length === 0) {
|
|
322
|
+
throw new Error('Missing required field: task');
|
|
323
|
+
}
|
|
324
|
+
// Validate cron expression if provided
|
|
325
|
+
if (req.schedule) {
|
|
326
|
+
parseCron(req.schedule); // throws on invalid
|
|
327
|
+
}
|
|
328
|
+
const agent = {
|
|
329
|
+
id: randomUUID(),
|
|
330
|
+
name: req.name || `agent-${Date.now().toString(36)}`,
|
|
331
|
+
status: 'queued',
|
|
332
|
+
task: req.task,
|
|
333
|
+
agent: req.agent,
|
|
334
|
+
schedule: req.schedule,
|
|
335
|
+
webhook: req.webhook,
|
|
336
|
+
maxIterations: req.maxIterations ?? 1,
|
|
337
|
+
currentIteration: 0,
|
|
338
|
+
results: [],
|
|
339
|
+
createdAt: new Date().toISOString(),
|
|
340
|
+
lastActiveAt: new Date().toISOString(),
|
|
341
|
+
pendingMessages: [],
|
|
342
|
+
};
|
|
343
|
+
agents.set(agent.id, agent);
|
|
344
|
+
await persistAgent(agent);
|
|
345
|
+
// If scheduled, don't start immediately — the cron scheduler will trigger it
|
|
346
|
+
if (agent.schedule) {
|
|
347
|
+
agent.status = 'paused';
|
|
348
|
+
emitSSE(agent.id, 'status', { status: 'paused', reason: 'scheduled' });
|
|
349
|
+
await persistAgent(agent);
|
|
350
|
+
return agent;
|
|
351
|
+
}
|
|
352
|
+
// Start immediately if under concurrency limit
|
|
353
|
+
if (countRunningAgents() < MAX_CONCURRENT_AGENTS) {
|
|
354
|
+
// Fire and forget — executeAgent manages its own lifecycle
|
|
355
|
+
executeAgent(agent).catch(() => { });
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
emitSSE(agent.id, 'status', { status: 'queued', position: countQueuePosition(agent.id) });
|
|
359
|
+
}
|
|
360
|
+
return agent;
|
|
361
|
+
}
|
|
362
|
+
export function listCloudAgents() {
|
|
363
|
+
return Array.from(agents.values()).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
364
|
+
}
|
|
365
|
+
export function getCloudAgent(id) {
|
|
366
|
+
return agents.get(id);
|
|
367
|
+
}
|
|
368
|
+
export async function pauseCloudAgent(id) {
|
|
369
|
+
const agent = agents.get(id);
|
|
370
|
+
if (!agent)
|
|
371
|
+
throw new Error(`Agent not found: ${id}`);
|
|
372
|
+
if (agent.status !== 'running' && agent.status !== 'queued') {
|
|
373
|
+
throw new Error(`Cannot pause agent in status: ${agent.status}`);
|
|
374
|
+
}
|
|
375
|
+
agent.status = 'paused';
|
|
376
|
+
agent.lastActiveAt = new Date().toISOString();
|
|
377
|
+
// Abort the running execution
|
|
378
|
+
const ac = runningAbortControllers.get(id);
|
|
379
|
+
if (ac) {
|
|
380
|
+
ac.abort();
|
|
381
|
+
runningAbortControllers.delete(id);
|
|
382
|
+
}
|
|
383
|
+
emitSSE(id, 'status', { status: 'paused' });
|
|
384
|
+
await persistAgent(agent);
|
|
385
|
+
return agent;
|
|
386
|
+
}
|
|
387
|
+
export async function resumeCloudAgent(id) {
|
|
388
|
+
const agent = agents.get(id);
|
|
389
|
+
if (!agent)
|
|
390
|
+
throw new Error(`Agent not found: ${id}`);
|
|
391
|
+
if (agent.status !== 'paused') {
|
|
392
|
+
throw new Error(`Cannot resume agent in status: ${agent.status}`);
|
|
393
|
+
}
|
|
394
|
+
if (countRunningAgents() < MAX_CONCURRENT_AGENTS) {
|
|
395
|
+
executeAgent(agent).catch(() => { });
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
agent.status = 'queued';
|
|
399
|
+
emitSSE(id, 'status', { status: 'queued', position: countQueuePosition(id) });
|
|
400
|
+
await persistAgent(agent);
|
|
401
|
+
}
|
|
402
|
+
return agent;
|
|
403
|
+
}
|
|
404
|
+
export async function killCloudAgent(id) {
|
|
405
|
+
const agent = agents.get(id);
|
|
406
|
+
if (!agent)
|
|
407
|
+
throw new Error(`Agent not found: ${id}`);
|
|
408
|
+
// Abort if running
|
|
409
|
+
const ac = runningAbortControllers.get(id);
|
|
410
|
+
if (ac) {
|
|
411
|
+
ac.abort();
|
|
412
|
+
runningAbortControllers.delete(id);
|
|
413
|
+
}
|
|
414
|
+
// Close SSE connections
|
|
415
|
+
const listeners = sseListeners.get(id);
|
|
416
|
+
if (listeners) {
|
|
417
|
+
for (const res of listeners) {
|
|
418
|
+
try {
|
|
419
|
+
res.end();
|
|
420
|
+
}
|
|
421
|
+
catch { /* ignore */ }
|
|
422
|
+
}
|
|
423
|
+
sseListeners.delete(id);
|
|
424
|
+
}
|
|
425
|
+
agents.delete(id);
|
|
426
|
+
removePersistedAgent(id);
|
|
427
|
+
}
|
|
428
|
+
export function sendMessageToAgent(id, message) {
|
|
429
|
+
const agent = agents.get(id);
|
|
430
|
+
if (!agent)
|
|
431
|
+
throw new Error(`Agent not found: ${id}`);
|
|
432
|
+
if (agent.status !== 'running' && agent.status !== 'paused') {
|
|
433
|
+
throw new Error(`Cannot send message to agent in status: ${agent.status}`);
|
|
434
|
+
}
|
|
435
|
+
agent.pendingMessages.push(message);
|
|
436
|
+
emitSSE(id, 'message_queued', { message: message.slice(0, 200) });
|
|
437
|
+
return agent;
|
|
438
|
+
}
|
|
439
|
+
// ── Queue Helpers ──
|
|
440
|
+
function countQueuePosition(id) {
|
|
441
|
+
let pos = 0;
|
|
442
|
+
for (const agent of agents.values()) {
|
|
443
|
+
if (agent.status === 'queued') {
|
|
444
|
+
pos++;
|
|
445
|
+
if (agent.id === id)
|
|
446
|
+
return pos;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return pos;
|
|
450
|
+
}
|
|
451
|
+
// ── Cron Scheduler ──
|
|
452
|
+
function startCronScheduler() {
|
|
453
|
+
if (cronIntervalId)
|
|
454
|
+
return;
|
|
455
|
+
cronIntervalId = setInterval(() => {
|
|
456
|
+
const now = new Date();
|
|
457
|
+
for (const agent of agents.values()) {
|
|
458
|
+
if (!agent.schedule || agent.status !== 'paused')
|
|
459
|
+
continue;
|
|
460
|
+
try {
|
|
461
|
+
const cron = parseCron(agent.schedule);
|
|
462
|
+
if (shouldRunAt(cron, now)) {
|
|
463
|
+
// Reset iteration for a new scheduled run
|
|
464
|
+
agent.currentIteration = 0;
|
|
465
|
+
agent.results = [];
|
|
466
|
+
agent.error = undefined;
|
|
467
|
+
if (countRunningAgents() < MAX_CONCURRENT_AGENTS) {
|
|
468
|
+
executeAgent(agent).catch(() => { });
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
agent.status = 'queued';
|
|
472
|
+
emitSSE(agent.id, 'status', { status: 'queued', reason: 'cron_triggered' });
|
|
473
|
+
persistAgent(agent).catch(() => { });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// Invalid cron — skip silently, it was validated on creation
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}, CRON_CHECK_INTERVAL_MS);
|
|
482
|
+
// Don't let the interval keep the process alive
|
|
483
|
+
if (cronIntervalId && typeof cronIntervalId === 'object' && 'unref' in cronIntervalId) {
|
|
484
|
+
cronIntervalId.unref();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function stopCronScheduler() {
|
|
488
|
+
if (cronIntervalId) {
|
|
489
|
+
clearInterval(cronIntervalId);
|
|
490
|
+
cronIntervalId = null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// ── HTTP Route Handler ──
|
|
494
|
+
function cors(res) {
|
|
495
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
496
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
497
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
498
|
+
}
|
|
499
|
+
function json(res, status, data) {
|
|
500
|
+
cors(res);
|
|
501
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
502
|
+
res.end(JSON.stringify(data));
|
|
503
|
+
}
|
|
504
|
+
function readBody(req) {
|
|
505
|
+
return new Promise((resolve, reject) => {
|
|
506
|
+
const chunks = [];
|
|
507
|
+
req.on('data', (c) => chunks.push(c));
|
|
508
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
509
|
+
req.on('error', reject);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
function stripSensitiveFields(agent) {
|
|
513
|
+
// Return a plain object without internal pendingMessages when listing
|
|
514
|
+
const { pendingMessages: _pm, ...rest } = agent;
|
|
515
|
+
return {
|
|
516
|
+
...rest,
|
|
517
|
+
pendingMessageCount: agent.pendingMessages.length,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Returns an async route handler function that can be mounted in the kbot serve HTTP server.
|
|
522
|
+
*
|
|
523
|
+
* Usage in serve.ts:
|
|
524
|
+
* const cloudRoutes = getCloudAgentRoutes()
|
|
525
|
+
* // Inside the request handler:
|
|
526
|
+
* if (await cloudRoutes(req, res)) return // handled
|
|
527
|
+
*/
|
|
528
|
+
export function getCloudAgentRoutes() {
|
|
529
|
+
// Initialize: load persisted agents and start cron scheduler
|
|
530
|
+
let initialized = false;
|
|
531
|
+
let initPromise = null;
|
|
532
|
+
async function ensureInit() {
|
|
533
|
+
if (initialized)
|
|
534
|
+
return;
|
|
535
|
+
if (!initPromise) {
|
|
536
|
+
initPromise = (async () => {
|
|
537
|
+
await loadPersistedAgents();
|
|
538
|
+
startCronScheduler();
|
|
539
|
+
// Resume any agents that were running when server last stopped
|
|
540
|
+
for (const agent of agents.values()) {
|
|
541
|
+
if (agent.status === 'running') {
|
|
542
|
+
// They weren't gracefully stopped — mark as paused so user can resume
|
|
543
|
+
agent.status = 'paused';
|
|
544
|
+
agent.error = 'Server restarted — agent paused. Resume with POST /agents/:id/resume';
|
|
545
|
+
await persistAgent(agent);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
initialized = true;
|
|
549
|
+
})();
|
|
550
|
+
}
|
|
551
|
+
await initPromise;
|
|
552
|
+
}
|
|
553
|
+
return async (req, res) => {
|
|
554
|
+
const url = new URL(req.url || '/', 'http://localhost');
|
|
555
|
+
const path = url.pathname;
|
|
556
|
+
// Only handle /agents routes
|
|
557
|
+
if (!path.startsWith('/agents'))
|
|
558
|
+
return false;
|
|
559
|
+
// CORS preflight
|
|
560
|
+
if (req.method === 'OPTIONS') {
|
|
561
|
+
cors(res);
|
|
562
|
+
res.writeHead(204);
|
|
563
|
+
res.end();
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
await ensureInit();
|
|
567
|
+
try {
|
|
568
|
+
// POST /agents — create a cloud agent
|
|
569
|
+
if (path === '/agents' && req.method === 'POST') {
|
|
570
|
+
const body = JSON.parse(await readBody(req));
|
|
571
|
+
const agent = await createCloudAgent(body);
|
|
572
|
+
json(res, 201, stripSensitiveFields(agent));
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
// GET /agents — list all agents
|
|
576
|
+
if (path === '/agents' && req.method === 'GET') {
|
|
577
|
+
const status = url.searchParams.get('status') || undefined;
|
|
578
|
+
let list = listCloudAgents();
|
|
579
|
+
if (status) {
|
|
580
|
+
list = list.filter(a => a.status === status);
|
|
581
|
+
}
|
|
582
|
+
json(res, 200, {
|
|
583
|
+
agents: list.map(stripSensitiveFields),
|
|
584
|
+
count: list.length,
|
|
585
|
+
running: countRunningAgents(),
|
|
586
|
+
maxConcurrent: MAX_CONCURRENT_AGENTS,
|
|
587
|
+
});
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
// Match /agents/:id or /agents/:id/action
|
|
591
|
+
const idMatch = path.match(/^\/agents\/([a-f0-9-]{36})(?:\/(\w+))?$/);
|
|
592
|
+
if (!idMatch) {
|
|
593
|
+
json(res, 404, { error: 'Not found' });
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
const agentId = idMatch[1];
|
|
597
|
+
const action = idMatch[2]; // pause, resume, message, stream — or undefined for GET/DELETE
|
|
598
|
+
// GET /agents/:id — get agent details
|
|
599
|
+
if (!action && req.method === 'GET') {
|
|
600
|
+
const agent = getCloudAgent(agentId);
|
|
601
|
+
if (!agent) {
|
|
602
|
+
json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
json(res, 200, stripSensitiveFields(agent));
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
// DELETE /agents/:id — kill agent
|
|
609
|
+
if (!action && req.method === 'DELETE') {
|
|
610
|
+
try {
|
|
611
|
+
await killCloudAgent(agentId);
|
|
612
|
+
json(res, 200, { ok: true, deleted: agentId });
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
json(res, 404, { error: err instanceof Error ? err.message : String(err) });
|
|
616
|
+
}
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
// POST /agents/:id/pause
|
|
620
|
+
if (action === 'pause' && req.method === 'POST') {
|
|
621
|
+
try {
|
|
622
|
+
const agent = await pauseCloudAgent(agentId);
|
|
623
|
+
json(res, 200, stripSensitiveFields(agent));
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
json(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
627
|
+
}
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
// POST /agents/:id/resume
|
|
631
|
+
if (action === 'resume' && req.method === 'POST') {
|
|
632
|
+
try {
|
|
633
|
+
const agent = await resumeCloudAgent(agentId);
|
|
634
|
+
json(res, 200, stripSensitiveFields(agent));
|
|
635
|
+
}
|
|
636
|
+
catch (err) {
|
|
637
|
+
json(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
638
|
+
}
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
// POST /agents/:id/message — inject a message
|
|
642
|
+
if (action === 'message' && req.method === 'POST') {
|
|
643
|
+
const body = JSON.parse(await readBody(req));
|
|
644
|
+
const message = body.message;
|
|
645
|
+
if (!message) {
|
|
646
|
+
json(res, 400, { error: 'Missing "message" field' });
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
const agent = sendMessageToAgent(agentId, message);
|
|
651
|
+
json(res, 200, {
|
|
652
|
+
ok: true,
|
|
653
|
+
pendingMessageCount: agent.pendingMessages.length,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
catch (err) {
|
|
657
|
+
json(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
658
|
+
}
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
// GET /agents/:id/stream — SSE event stream
|
|
662
|
+
if (action === 'stream' && req.method === 'GET') {
|
|
663
|
+
const agent = getCloudAgent(agentId);
|
|
664
|
+
if (!agent) {
|
|
665
|
+
json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
cors(res);
|
|
669
|
+
res.writeHead(200, {
|
|
670
|
+
'Content-Type': 'text/event-stream',
|
|
671
|
+
'Cache-Control': 'no-cache',
|
|
672
|
+
'Connection': 'keep-alive',
|
|
673
|
+
});
|
|
674
|
+
// Send current state as initial event
|
|
675
|
+
res.write(`event: connected\ndata: ${JSON.stringify({
|
|
676
|
+
agentId: agent.id,
|
|
677
|
+
name: agent.name,
|
|
678
|
+
status: agent.status,
|
|
679
|
+
currentIteration: agent.currentIteration,
|
|
680
|
+
totalIterations: agent.maxIterations ?? 1,
|
|
681
|
+
})}\n\n`);
|
|
682
|
+
addSSEListener(agentId, res);
|
|
683
|
+
// Keep-alive ping every 30s
|
|
684
|
+
const pingInterval = setInterval(() => {
|
|
685
|
+
try {
|
|
686
|
+
res.write(': ping\n\n');
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
clearInterval(pingInterval);
|
|
690
|
+
removeSSEListener(agentId, res);
|
|
691
|
+
}
|
|
692
|
+
}, 30_000);
|
|
693
|
+
req.on('close', () => {
|
|
694
|
+
clearInterval(pingInterval);
|
|
695
|
+
removeSSEListener(agentId, res);
|
|
696
|
+
});
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
json(res, 404, { error: `Unknown action: ${action}` });
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
catch (err) {
|
|
703
|
+
json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
// ── Cleanup ──
|
|
709
|
+
export function shutdownCloudAgents() {
|
|
710
|
+
stopCronScheduler();
|
|
711
|
+
// Abort all running agents
|
|
712
|
+
for (const [id, ac] of runningAbortControllers) {
|
|
713
|
+
ac.abort();
|
|
714
|
+
const agent = agents.get(id);
|
|
715
|
+
if (agent) {
|
|
716
|
+
agent.status = 'paused';
|
|
717
|
+
agent.error = 'Server shutting down';
|
|
718
|
+
// Synchronous write on shutdown — best effort
|
|
719
|
+
try {
|
|
720
|
+
writeFileSync(join(PERSIST_DIR, `${id}.json`), JSON.stringify(agent, null, 2), 'utf-8');
|
|
721
|
+
}
|
|
722
|
+
catch { /* best effort */ }
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
runningAbortControllers.clear();
|
|
726
|
+
// Close all SSE connections
|
|
727
|
+
for (const listeners of sseListeners.values()) {
|
|
728
|
+
for (const res of listeners) {
|
|
729
|
+
try {
|
|
730
|
+
res.end();
|
|
731
|
+
}
|
|
732
|
+
catch { /* ignore */ }
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
sseListeners.clear();
|
|
736
|
+
}
|
|
737
|
+
// ── Re-exports for convenience ──
|
|
738
|
+
// parseCron, shouldRunAt, ParsedCron are exported at their declaration sites above
|
|
739
|
+
// CloudAgent, AgentResult are exported at their interface declarations above
|
|
740
|
+
// createCloudAgent, listCloudAgents, getCloudAgent, pauseCloudAgent,
|
|
741
|
+
// resumeCloudAgent, killCloudAgent, getCloudAgentRoutes, shutdownCloudAgents
|
|
742
|
+
// are exported at their function declarations above
|
|
743
|
+
//# sourceMappingURL=cloud-agent.js.map
|