hive-am 1.4.0 → 1.6.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/CHANGELOG.md +52 -1
- package/README.md +6 -9
- package/bin/hive-am.mjs +242 -31
- package/bin/run.mjs +135 -0
- package/package.json +1 -1
- package/server/dist/connections/format.js +26 -0
- package/server/dist/connections/index.js +2 -0
- package/server/dist/connections/manager.js +18 -6
- package/server/dist/connections/router.js +43 -13
- package/server/dist/connections/secrets.js +9 -1
- package/server/dist/connections/slack.js +488 -0
- package/server/dist/connections/store.js +2 -0
- package/server/dist/db/migrations.js +80 -0
- package/server/dist/db.js +26 -13
- package/server/dist/dispatches.js +126 -0
- package/server/dist/efforts.js +105 -0
- package/server/dist/http/routes/agents.js +4 -3
- package/server/dist/http/routes/meta.js +12 -0
- package/server/dist/http/routes/notebook.js +10 -2
- package/server/dist/http/routes/orchestration.js +13 -2
- package/server/dist/http/routes/resources.js +3 -0
- package/server/dist/http/routes/types.js +18 -1
- package/server/dist/http/validate.js +27 -4
- package/server/dist/index.js +6 -1
- package/server/dist/instructions.js +11 -3
- package/server/dist/objects/agent-db.js +258 -0
- package/server/dist/objects/database.js +496 -34
- package/server/dist/objects/index.js +16 -8
- package/server/dist/providers/kiro.js +7 -1
- package/server/dist/providers/opencode-server.js +11 -1
- package/server/dist/providers/opencode.js +4 -3
- package/server/dist/providers/spawn.js +38 -4
- package/server/dist/retention.js +52 -0
- package/server/dist/runtime.js +69 -27
- package/server/dist/services/agents.js +53 -1
- package/server/dist/services/connections.js +18 -3
- package/server/dist/services/notebook.js +7 -3
- package/server/dist/skills/defaults.js +41 -5
- package/server/dist/skills/notebook.js +57 -13
- package/server/dist/watch.js +1 -1
- package/server/mcp/dispatch.mjs +116 -5
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/app-build-manifest.json +98 -82
- package/web/.next/app-path-routes-manifest.json +7 -7
- package/web/.next/build-manifest.json +2 -2
- package/web/.next/prerender-manifest.json +51 -51
- package/web/.next/server/app/_not-found/page.js +2 -2
- package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/_not-found.html +1 -1
- package/web/.next/server/app/_not-found.rsc +3 -3
- package/web/.next/server/app/agents/[id]/page.js +4 -4
- package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents/page.js +2 -2
- package/web/.next/server/app/agents/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents.html +1 -1
- package/web/.next/server/app/agents.rsc +21 -18
- package/web/.next/server/app/chat/page.js +2 -2
- package/web/.next/server/app/chat/page.js.nft.json +1 -1
- package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/chat.html +1 -1
- package/web/.next/server/app/chat.rsc +4 -4
- package/web/.next/server/app/connections/page.js +2 -2
- package/web/.next/server/app/connections/page.js.nft.json +1 -1
- package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/connections.html +1 -1
- package/web/.next/server/app/connections.rsc +4 -4
- package/web/.next/server/app/index.html +1 -1
- package/web/.next/server/app/index.rsc +4 -4
- package/web/.next/server/app/page.js +2 -2
- package/web/.next/server/app/page.js.nft.json +1 -1
- package/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations/page.js +2 -2
- package/web/.next/server/app/relations/page.js.nft.json +1 -1
- package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations.html +1 -1
- package/web/.next/server/app/relations.rsc +4 -4
- package/web/.next/server/app/resources/[id]/page.js +2 -2
- package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
- package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/resources/page.js +2 -2
- package/web/.next/server/app/resources/page.js.nft.json +1 -1
- package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/resources.html +1 -1
- package/web/.next/server/app/resources.rsc +4 -4
- package/web/.next/server/app/schedules/page.js +2 -2
- package/web/.next/server/app/schedules/page.js.nft.json +1 -1
- package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/schedules.html +1 -1
- package/web/.next/server/app/schedules.rsc +4 -4
- package/web/.next/server/app/sessions/page.js +2 -2
- package/web/.next/server/app/sessions/page.js.nft.json +1 -1
- package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/sessions.html +1 -1
- package/web/.next/server/app/sessions.rsc +4 -4
- package/web/.next/server/app/skills/page.js +2 -2
- package/web/.next/server/app/skills/page.js.nft.json +1 -1
- package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/skills.html +1 -1
- package/web/.next/server/app/skills.rsc +4 -4
- package/web/.next/server/app/types/page.js +2 -2
- package/web/.next/server/app/types/page.js.nft.json +1 -1
- package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/types.html +1 -1
- package/web/.next/server/app/types.rsc +4 -4
- package/web/.next/server/app-paths-manifest.json +7 -7
- package/web/.next/server/chunks/{505.js → 111.js} +1 -1
- package/web/.next/server/chunks/143.js +1 -1
- package/web/.next/server/chunks/213.js +1 -1
- package/web/.next/server/chunks/368.js +1 -1
- package/web/.next/server/chunks/403.js +1 -1
- package/web/.next/server/chunks/612.js +1 -1
- package/web/.next/server/chunks/741.js +2 -0
- package/web/.next/server/chunks/837.js +1 -0
- package/web/.next/server/chunks/{435.js → 939.js} +3 -3
- package/web/.next/server/pages/404.html +1 -1
- package/web/.next/server/pages/500.html +1 -1
- package/web/.next/server/server-reference-manifest.json +1 -1
- package/web/.next/static/chunks/162-68ba190086f5ad39.js +1 -0
- package/web/.next/static/chunks/214-35b88db02113ae02.js +1 -0
- package/web/.next/static/chunks/281-4506bc98b7111729.js +1 -0
- package/web/.next/static/chunks/491-196bcb3dd11bb460.js +1 -0
- package/web/.next/static/chunks/517-8f2db464d10a4624.js +1 -0
- package/web/.next/static/chunks/{586-f7017d585a84738d.js → 586-4f67b823df060b46.js} +1 -1
- package/web/.next/static/chunks/587-3c53d91a1c4f99d6.js +1 -0
- package/web/.next/static/chunks/606-14fb5aaa4d0ff22d.js +1 -0
- package/web/.next/static/chunks/642-dc874c8caf203ee1.js +1 -0
- package/web/.next/static/chunks/{705-f81face97b1bb1af.js → 705-ce86175dea00e6a9.js} +1 -1
- package/web/.next/static/chunks/757-03361e7a97a605a2.js +1 -0
- package/web/.next/static/chunks/771-db19da9b1a24820c.js +1 -0
- package/web/.next/static/chunks/app/agents/[id]/page-803d53aedd10c8e5.js +1 -0
- package/web/.next/static/chunks/app/agents/layout-71dd98d696935e29.js +1 -0
- package/web/.next/static/chunks/app/agents/page-9da499db6d22731f.js +1 -0
- package/web/.next/static/chunks/app/chat/{page-5261b12b7d50f5d1.js → page-383bc24e72dab7fa.js} +1 -1
- package/web/.next/static/chunks/app/connections/page-b0ea2eae62cef74e.js +1 -0
- package/web/.next/static/chunks/app/layout-cb5af29b0742c513.js +1 -0
- package/web/.next/static/chunks/app/page-5e547e87de23d9fa.js +1 -0
- package/web/.next/static/chunks/app/relations/page-02710fe1f8a670a9.js +1 -0
- package/web/.next/static/chunks/app/resources/[id]/page-1e2afddcb1f04884.js +1 -0
- package/web/.next/static/chunks/app/resources/{page-61cccd8fdcc2f6c8.js → page-195b0af83b8ace0d.js} +1 -1
- package/web/.next/static/chunks/app/sessions/page-85638705fdc18174.js +1 -0
- package/web/.next/static/chunks/app/types/page-c4c2b2217c4667e8.js +1 -0
- package/web/.next/static/css/c7faf690373117bb.css +1 -0
- package/web/.next/trace +30 -30
- package/web/.next/types/routes.d.ts +2 -1
- package/web/.next/types/validator.ts +9 -0
- package/web/.next/server/chunks/274.js +0 -1
- package/web/.next/static/chunks/281-113274e705940a55.js +0 -1
- package/web/.next/static/chunks/316-26da9cc233ba7557.js +0 -1
- package/web/.next/static/chunks/391-18768499e741112e.js +0 -1
- package/web/.next/static/chunks/517-373a80c8ba432bdc.js +0 -1
- package/web/.next/static/chunks/562-7c2e33d838e869bf.js +0 -1
- package/web/.next/static/chunks/587-8e4d2be01ba7d48f.js +0 -1
- package/web/.next/static/chunks/606-c9a19918b02a98d0.js +0 -1
- package/web/.next/static/chunks/656-f537149fe28f79aa.js +0 -1
- package/web/.next/static/chunks/app/agents/[id]/page-6ed6edb3e74b0ebe.js +0 -1
- package/web/.next/static/chunks/app/agents/page-9bb8cba69776c719.js +0 -1
- package/web/.next/static/chunks/app/connections/page-eb11cfb1ffeab3ca.js +0 -1
- package/web/.next/static/chunks/app/layout-fad968a635f9a4b3.js +0 -1
- package/web/.next/static/chunks/app/page-99e0351e6ccaa384.js +0 -1
- package/web/.next/static/chunks/app/relations/page-a44fd515a3b0525e.js +0 -1
- package/web/.next/static/chunks/app/resources/[id]/page-fc494e85b01e8cb7.js +0 -1
- package/web/.next/static/chunks/app/sessions/page-a7534ebd90e982d6.js +0 -1
- package/web/.next/static/chunks/app/types/page-ebd37e220f77c1f3.js +0 -1
- package/web/.next/static/css/582723fcea1f9887.css +0 -1
- /package/web/.next/static/{YT6HM6qmegMJaBMX5QvuP → tdnUXVa01m25EjOobxVd1}/_buildManifest.js +0 -0
- /package/web/.next/static/{YT6HM6qmegMJaBMX5QvuP → tdnUXVa01m25EjOobxVd1}/_ssgManifest.js +0 -0
package/server/dist/db.js
CHANGED
|
@@ -106,7 +106,7 @@ export const types = {
|
|
|
106
106
|
get(id) { const r = q('SELECT * FROM agent_types WHERE id=?').get(id); return r ? typeOf(r) : undefined; },
|
|
107
107
|
create: db.transaction((p) => {
|
|
108
108
|
const id = uid();
|
|
109
|
-
q('INSERT INTO agent_types (id,name,description,role,provider,model,system_prompt,permission,color,created_at) VALUES (
|
|
109
|
+
q('INSERT INTO agent_types (id,name,description,role,provider,model,effort,system_prompt,permission,color,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)').run(id, p.name, p.description ?? '', p.role, p.provider, p.model ?? '', p.effort ?? '', p.system_prompt ?? '', (p.permission ?? 'acceptEdits'), p.color ?? '', now());
|
|
110
110
|
types.setSkills(id, p.skill_ids ?? [], p.skill_loads);
|
|
111
111
|
return types.get(id);
|
|
112
112
|
}),
|
|
@@ -115,8 +115,8 @@ export const types = {
|
|
|
115
115
|
if (!cur)
|
|
116
116
|
return;
|
|
117
117
|
const n = { ...cur, ...p };
|
|
118
|
-
q('UPDATE agent_types SET name=?, description=?, role=?, provider=?, model=?, system_prompt=?, permission=?, color=? WHERE id=?')
|
|
119
|
-
.run(n.name, n.description, n.role, n.provider, n.model, n.system_prompt, n.permission, n.color, id);
|
|
118
|
+
q('UPDATE agent_types SET name=?, description=?, role=?, provider=?, model=?, effort=?, system_prompt=?, permission=?, color=? WHERE id=?')
|
|
119
|
+
.run(n.name, n.description, n.role, n.provider, n.model, n.effort ?? '', n.system_prompt, n.permission, n.color, id);
|
|
120
120
|
if (p.skill_ids || p.skill_loads)
|
|
121
121
|
types.setSkills(id, p.skill_ids ?? cur.skill_ids, p.skill_loads);
|
|
122
122
|
return types.get(id);
|
|
@@ -197,7 +197,7 @@ export const agents = {
|
|
|
197
197
|
/** The agent, its skills and its workers are written together: either all of it exists afterwards or none. */
|
|
198
198
|
create: db.transaction((p) => {
|
|
199
199
|
const id = uid(), t = now();
|
|
200
|
-
q('INSERT INTO agents (id,name,description,role,type_id,provider,model,system_prompt,permission,cwd,session_id,status,created_at,updated_at,colony_id,overrides,browser) VALUES (
|
|
200
|
+
q('INSERT INTO agents (id,name,description,role,type_id,provider,model,effort,system_prompt,permission,cwd,session_id,status,created_at,updated_at,colony_id,overrides,browser,cloned_from) VALUES (?,?,?,?,?,?,?,?,?,?,?,NULL,?,?,?,?,?,?,?)').run(id, p.name, p.description ?? '', p.role, p.type_id ?? null, p.provider, p.model ?? '', p.effort ?? '', p.system_prompt ?? '', (p.permission ?? 'acceptEdits'), p.cwd ?? '', 'idle', t, t, p.colony_id ?? null, JSON.stringify(p.overrides ?? []), p.browser && p.provider === 'claude' ? 1 : 0, p.cloned_from ?? null);
|
|
201
201
|
agents.setSkills(id, p.skill_ids ?? [], p.skill_loads);
|
|
202
202
|
agents.setWorkers(id, p.worker_ids ?? []);
|
|
203
203
|
return agents.get(id);
|
|
@@ -207,8 +207,8 @@ export const agents = {
|
|
|
207
207
|
if (!cur)
|
|
208
208
|
return;
|
|
209
209
|
const n = { ...cur, ...p };
|
|
210
|
-
q('UPDATE agents SET name=?, description=?, role=?, type_id=?, provider=?, model=?, system_prompt=?, permission=?, cwd=?, colony_id=?, overrides=?, browser=?, updated_at=? WHERE id=?')
|
|
211
|
-
.run(n.name, n.description, n.role, n.type_id, n.provider, n.model, n.system_prompt, n.permission, n.cwd, n.colony_id ?? null, JSON.stringify(n.overrides ?? []), n.browser && n.provider === 'claude' ? 1 : 0, now(), id);
|
|
210
|
+
q('UPDATE agents SET name=?, description=?, role=?, type_id=?, provider=?, model=?, effort=?, system_prompt=?, permission=?, cwd=?, colony_id=?, overrides=?, browser=?, updated_at=? WHERE id=?')
|
|
211
|
+
.run(n.name, n.description, n.role, n.type_id, n.provider, n.model, n.effort ?? '', n.system_prompt, n.permission, n.cwd, n.colony_id ?? null, JSON.stringify(n.overrides ?? []), n.browser && n.provider === 'claude' ? 1 : 0, now(), id);
|
|
212
212
|
if (p.skill_ids || p.skill_loads)
|
|
213
213
|
agents.setSkills(id, p.skill_ids ?? cur.skill_ids, p.skill_loads);
|
|
214
214
|
if (p.worker_ids)
|
|
@@ -220,16 +220,17 @@ export const agents = {
|
|
|
220
220
|
setSession: db.transaction((a, sessionId) => {
|
|
221
221
|
q('UPDATE agents SET session_id=?, session_cwd=?, instr_hash=NULL, updated_at=? WHERE id=?').run(sessionId, sessionId ? a.effective.cwd : null, now(), a.id);
|
|
222
222
|
if (sessionId) {
|
|
223
|
-
|
|
223
|
+
// The permission is written once, when the session is first seen: it never changes on conflict, or a later change of permission would be hidden.
|
|
224
|
+
q(`INSERT INTO agent_sessions (agent_id, provider, session_id, cwd, first_seen, last_seen, permission) VALUES (?,?,?,?,?,?,?)
|
|
224
225
|
ON CONFLICT(agent_id, session_id) DO UPDATE SET last_seen=excluded.last_seen`)
|
|
225
|
-
.run(a.id, a.provider, sessionId, a.effective.cwd, now(), now());
|
|
226
|
+
.run(a.id, a.provider, sessionId, a.effective.cwd, now(), now(), a.effective.permission);
|
|
226
227
|
}
|
|
227
228
|
}),
|
|
228
229
|
/** Logs a native session without making it the agent's current conversation (used for delegations). */
|
|
229
230
|
recordSession(a, sessionId, kind, fromId, task) {
|
|
230
|
-
q(`INSERT INTO agent_sessions (agent_id, provider, session_id, cwd, first_seen, last_seen, kind, from_id, task) VALUES (
|
|
231
|
+
q(`INSERT INTO agent_sessions (agent_id, provider, session_id, cwd, first_seen, last_seen, kind, from_id, task, permission) VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
231
232
|
ON CONFLICT(agent_id, session_id) DO UPDATE SET last_seen=excluded.last_seen`)
|
|
232
|
-
.run(a.id, a.provider, sessionId, a.effective.cwd, now(), now(), kind, fromId ?? null, task ? task.slice(0, 500) : null);
|
|
233
|
+
.run(a.id, a.provider, sessionId, a.effective.cwd, now(), now(), kind, fromId ?? null, task ? task.slice(0, 500) : null, a.effective.permission);
|
|
233
234
|
},
|
|
234
235
|
setSkills: (id, ids, loads) => assignSkills('agent', id, ids, loads),
|
|
235
236
|
/** Replaces the workers an orchestrator may dispatch to (never itself). */
|
|
@@ -244,16 +245,28 @@ export const agents = {
|
|
|
244
245
|
allSessions: () => q(`SELECT s.*, a.name agent_name, a.role agent_role, f.name from_name FROM agent_sessions s JOIN agents a ON a.id=s.agent_id LEFT JOIN agents f ON f.id=s.from_id ORDER BY s.last_seen DESC`).all(),
|
|
245
246
|
};
|
|
246
247
|
// ---- dispatch log ----
|
|
248
|
+
// A delegation is `queued` until the worker's turn starts (it waits behind the worker's other turns), then `running`, then `done`, `failed`
|
|
249
|
+
// or `interrupted` (the server stopped while it was queued or running).
|
|
247
250
|
export const dispatches = {
|
|
248
|
-
start(from, to, task) {
|
|
251
|
+
start(from, to, task, mode = 'sync', origin = null) {
|
|
249
252
|
const id = uid();
|
|
250
|
-
q('INSERT INTO dispatches (id, from_id, to_id, task, status, result, created_at, finished_at) VALUES (?,?,?,?,?,NULL,?,NULL)').run(id, from, to, task, '
|
|
253
|
+
q('INSERT INTO dispatches (id, from_id, to_id, task, status, result, created_at, finished_at, mode, origin) VALUES (?,?,?,?,?,NULL,?,NULL,?,?)').run(id, from, to, task, 'queued', now(), mode, origin);
|
|
251
254
|
return id;
|
|
252
255
|
},
|
|
256
|
+
get: (id) => q('SELECT * FROM dispatches WHERE id=?').get(id),
|
|
257
|
+
setRunning: (id) => q("UPDATE dispatches SET status='running' WHERE id=? AND status='queued'").run(id),
|
|
253
258
|
setSession: (id, sessionId) => q('UPDATE dispatches SET session_id=? WHERE id=?').run(sessionId, id),
|
|
254
259
|
finish: (id, status, result) => q('UPDATE dispatches SET status=?, result=?, finished_at=? WHERE id=?').run(status, result.slice(0, 20000), now(), id),
|
|
260
|
+
markNotified: (id) => q('UPDATE dispatches SET notified_at=? WHERE id=? AND notified_at IS NULL').run(now(), id),
|
|
261
|
+
markRead: (id) => q('UPDATE dispatches SET read_at=? WHERE id=? AND read_at IS NULL').run(now(), id),
|
|
262
|
+
/** Background tasks of an orchestrator that have not finished yet. */
|
|
263
|
+
unfinished: (from) => q("SELECT COUNT(*) AS n FROM dispatches WHERE from_id=? AND mode='background' AND status IN ('queued','running')").get(from).n,
|
|
264
|
+
/** Finished background tasks whose result was not handed to the orchestrator yet. */
|
|
265
|
+
undelivered: () => q("SELECT * FROM dispatches WHERE mode='background' AND status NOT IN ('queued','running') AND notified_at IS NULL ORDER BY created_at").all(),
|
|
266
|
+
/** At startup nothing is running any more: what was queued or running is interrupted (a task is never resumed). */
|
|
267
|
+
interruptLeftovers: () => q("UPDATE dispatches SET status='interrupted', finished_at=? WHERE status IN ('queued','running')").run(now()).changes,
|
|
255
268
|
recent: (limit = 50) => q('SELECT * FROM dispatches ORDER BY created_at DESC LIMIT ?').all(limit),
|
|
256
269
|
};
|
|
257
270
|
// Agents left 'running' by a crash/blackout are simply idle again: their session is intact on disk.
|
|
258
271
|
q("UPDATE agents SET status='idle' WHERE status='running'").run();
|
|
259
|
-
|
|
272
|
+
dispatches.interruptLeftovers();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { agents, dispatches } from './db.js';
|
|
2
|
+
import { threads } from './connections/store.js';
|
|
3
|
+
import { liveOrigin, notifyAgentsChanged, startDispatch } from './runtime.js';
|
|
4
|
+
import { deliverTo } from './wake.js';
|
|
5
|
+
import { DomainError } from './errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* Delegations that outlive a request. A long task held on one HTTP request broke when the client gave up waiting (Node's fetch stops at
|
|
8
|
+
* five minutes without headers), and the orchestrator lost the answer. Now a task is stored and queued at once and its result is read
|
|
9
|
+
* from the `dispatches` table: in the background the orchestrator is told when it ends, in the place where it asked (the chat thread or
|
|
10
|
+
* its web conversation); the synchronous `dispatch` tool polls `dispatch_status` with short calls until it ends.
|
|
11
|
+
*/
|
|
12
|
+
export class DispatchError extends DomainError {
|
|
13
|
+
}
|
|
14
|
+
export const MAX_BACKGROUND = 10; // unfinished background tasks per orchestrator
|
|
15
|
+
export const MAX_WAIT_S = 60; // the longest one `dispatch_status` call waits
|
|
16
|
+
const POLL_MS = 250;
|
|
17
|
+
const RETRY_MS = Number(process.env.HIVE_AM_WAKE_RETRY_MS) || 5000; // wait before trying a delivery again (as wake-ups do)
|
|
18
|
+
const FINISHED = new Set(['done', 'failed', 'interrupted']);
|
|
19
|
+
const NO_TEXT = '(the subagent finished without a text answer)';
|
|
20
|
+
const nameOf = (id) => agents.get(id)?.name ?? 'a removed agent';
|
|
21
|
+
/** Starts a delegation without waiting for it. In the background, its end is announced to the orchestrator; a synchronous one is polled. */
|
|
22
|
+
export async function startTask(fromId, workerName, task, background) {
|
|
23
|
+
if (background && dispatches.unfinished(fromId) >= MAX_BACKGROUND)
|
|
24
|
+
throw new DispatchError(`You already have ${MAX_BACKGROUND} background tasks that have not finished, the most allowed. Wait for one to finish (you are told, or ask with dispatch_status) before sending another.`);
|
|
25
|
+
let origin = null;
|
|
26
|
+
if (background) {
|
|
27
|
+
// The answer goes back where the orchestrator was asked, as a wake-up does: the chat thread it is answering now, or its web conversation.
|
|
28
|
+
const o = liveOrigin(fromId);
|
|
29
|
+
const thread = o ? threads.get(o.threadId) : undefined;
|
|
30
|
+
if (o && thread)
|
|
31
|
+
origin = JSON.stringify({ connection_id: o.connectionId, thread_id: thread.id, req_id: o.group && o.userId ? o.userId : null, req_name: o.group && o.userId ? o.userName : null });
|
|
32
|
+
}
|
|
33
|
+
let started;
|
|
34
|
+
try {
|
|
35
|
+
started = startDispatch(fromId, workerName, task, background ? 'background' : 'sync', origin);
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
throw new DispatchError(e instanceof Error ? e.message : String(e));
|
|
39
|
+
}
|
|
40
|
+
if (background)
|
|
41
|
+
void started.done.then(() => announce(started.id), () => announce(started.id));
|
|
42
|
+
notifyAgentsChanged(); // the interface reloads the dispatch log
|
|
43
|
+
// The worker's turn starts on the next tick when it is free: answer the state it is really in (running, or queued behind other work).
|
|
44
|
+
await new Promise((r) => setImmediate(r));
|
|
45
|
+
return { id: started.id, agent: started.worker.name, status: dispatches.get(started.id)?.status ?? 'queued', mode: background ? 'background' : 'sync' };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The state of a task, for the orchestrator that started it. With `waitSeconds` it waits up to that long for the end (at most MAX_WAIT_S,
|
|
49
|
+
* so no single request is long) and answers the state it has then. A finished result read here counts as read.
|
|
50
|
+
*/
|
|
51
|
+
export async function taskStatus(fromId, id, waitSeconds) {
|
|
52
|
+
if (!id)
|
|
53
|
+
throw new DispatchError('id is required');
|
|
54
|
+
let row = dispatches.get(id);
|
|
55
|
+
if (!row)
|
|
56
|
+
throw new DispatchError(`There is no delegated task with id "${id}".`, 'not_found');
|
|
57
|
+
if (row.from_id !== fromId)
|
|
58
|
+
throw new DispatchError(`Task "${id}" was not started by you.`, 'forbidden');
|
|
59
|
+
const asked = waitSeconds === undefined || waitSeconds === null || waitSeconds === '' ? 0 : Number(waitSeconds);
|
|
60
|
+
if (!Number.isFinite(asked) || asked < 0)
|
|
61
|
+
throw new DispatchError(`wait_seconds must be a number from 0 to ${MAX_WAIT_S}.`);
|
|
62
|
+
const wait = Math.min(asked, MAX_WAIT_S);
|
|
63
|
+
const until = Date.now() + wait * 1000;
|
|
64
|
+
while (!FINISHED.has(row.status) && Date.now() < until) {
|
|
65
|
+
await new Promise((r) => setTimeout(r, Math.min(POLL_MS, until - Date.now())));
|
|
66
|
+
row = dispatches.get(id) ?? row;
|
|
67
|
+
}
|
|
68
|
+
const out = { id, agent: nameOf(row.to_id), status: row.status, mode: row.mode };
|
|
69
|
+
if (FINISHED.has(row.status)) {
|
|
70
|
+
out.result = resultText(row);
|
|
71
|
+
dispatches.markRead(id);
|
|
72
|
+
}
|
|
73
|
+
if (asked > MAX_WAIT_S)
|
|
74
|
+
out.note = `wait_seconds is capped at ${MAX_WAIT_S}: waited ${MAX_WAIT_S} s at most.`;
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** What a finished task says: its answer, its error, or why it stopped. */
|
|
78
|
+
function resultText(row) {
|
|
79
|
+
if (row.status === 'interrupted')
|
|
80
|
+
return `hive-am restarted while ${nameOf(row.to_id)} was working on it; check the branch or the files and send the task again if needed.`;
|
|
81
|
+
if (row.status === 'failed')
|
|
82
|
+
return row.result || 'The turn failed without saying why.';
|
|
83
|
+
return row.result || NO_TEXT;
|
|
84
|
+
}
|
|
85
|
+
const noticeText = (row) => `Delegated task ${row.id} to ${nameOf(row.to_id)} finished (${row.status}). Result:\n${resultText(row)}`;
|
|
86
|
+
const NEXT = 'Carry on with the work this task was part of and tell the person what came of it';
|
|
87
|
+
const timers = new Map();
|
|
88
|
+
/** Hands the result of a finished background task to the orchestrator, once. Tried again while the platform is not connected yet. */
|
|
89
|
+
async function announce(id, tries = 0) {
|
|
90
|
+
timers.delete(id);
|
|
91
|
+
const row = dispatches.get(id);
|
|
92
|
+
if (!row || row.mode !== 'background' || row.notified_at || !FINISHED.has(row.status))
|
|
93
|
+
return;
|
|
94
|
+
const o = row.origin ? JSON.parse(row.origin) : null;
|
|
95
|
+
const to = { agent_id: row.from_id, connection_id: o?.connection_id ?? null, thread_id: o?.thread_id ?? null, req_id: o?.req_id ?? null, req_name: o?.req_name ?? null };
|
|
96
|
+
const again = () => {
|
|
97
|
+
if (tries >= 12) {
|
|
98
|
+
dispatches.markNotified(id);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const t = setTimeout(() => { void announce(id, tries + 1); }, RETRY_MS);
|
|
102
|
+
t.unref?.();
|
|
103
|
+
timers.set(id, t);
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
// The row is marked before the turn starts, so a restart in between cannot announce it twice.
|
|
107
|
+
const mention = to.req_name ? `hive-am mentions ${to.req_name} at the start of your first reply, so do not mention them yourself. ` : '';
|
|
108
|
+
const out = await deliverTo(to, `${noticeText(row)}\n\n${mention}${NEXT} with channel_reply.`, `🔔 ${noticeText(row)}\n\n${NEXT}.`, () => { dispatches.markNotified(id); });
|
|
109
|
+
if (out === 'retry')
|
|
110
|
+
again();
|
|
111
|
+
// Nobody to tell (the thread is gone, muted or reassigned): the result stays readable with dispatch_status.
|
|
112
|
+
else if (out === 'dropped')
|
|
113
|
+
dispatches.markNotified(id);
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
console.warn(`[dispatch] the result of task ${id} could not be handed to ${nameOf(row.from_id)}: ${e instanceof Error ? e.message : e}`);
|
|
117
|
+
if (!dispatches.get(id)?.notified_at)
|
|
118
|
+
again();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** At startup: tasks left queued or running are interrupted, and every finished background task not announced yet is announced now. */
|
|
122
|
+
export function armDispatchNotices() {
|
|
123
|
+
dispatches.interruptLeftovers();
|
|
124
|
+
for (const row of dispatches.undelivered())
|
|
125
|
+
void announce(row.id);
|
|
126
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DATA_DIR } from './db.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reasoning effort: how hard the model thinks in a turn. Claude Code and Kiro take `--effort <level>`; OpenCode takes it as a variant of the
|
|
6
|
+
* model (`provider/model#variant`) and the variants are whatever the chosen model has, so what OpenCode and Kiro report is learned and stored
|
|
7
|
+
* on disk (`reasoning-efforts.json`), next to what is learned about context windows. An empty effort means the CLI's own default: no flag and no `#variant`.
|
|
8
|
+
*/
|
|
9
|
+
export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
10
|
+
export const validEffort = (v) => typeof v === 'string' && EFFORTS.includes(v);
|
|
11
|
+
/** What an OpenCode variant id can look like (`0`..`5`, `none`, `high`, `xhigh`...). */
|
|
12
|
+
const VARIANT_ID = /^[\w.-]{1,24}$/;
|
|
13
|
+
const FILE = join(DATA_DIR, 'reasoning-efforts.json');
|
|
14
|
+
let known = null;
|
|
15
|
+
const key = (provider, model) => `${provider}:${model}`;
|
|
16
|
+
function load() {
|
|
17
|
+
if (known)
|
|
18
|
+
return known;
|
|
19
|
+
try {
|
|
20
|
+
known = existsSync(FILE) ? JSON.parse(readFileSync(FILE, 'utf8')) : {};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
known = {};
|
|
24
|
+
}
|
|
25
|
+
return known;
|
|
26
|
+
}
|
|
27
|
+
/** Remembers the levels models take. Writes the file only when something changed. A model with no levels is remembered as having none. */
|
|
28
|
+
export function learnEfforts(provider, models) {
|
|
29
|
+
const all = load();
|
|
30
|
+
let changed = false;
|
|
31
|
+
for (const [model, variants] of Object.entries(models)) {
|
|
32
|
+
if (!model || !Array.isArray(variants))
|
|
33
|
+
continue;
|
|
34
|
+
const clean = variants.filter((v) => v && VARIANT_ID.test(String(v.id))).map((v) => ({ id: String(v.id), ...(typeof v.level === 'string' && v.level ? { level: v.level } : {}) }));
|
|
35
|
+
const k = key(provider, model);
|
|
36
|
+
if (JSON.stringify(all[k]) !== JSON.stringify(clean)) {
|
|
37
|
+
all[k] = clean;
|
|
38
|
+
changed = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (changed) {
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(FILE, JSON.stringify(all, null, 1));
|
|
44
|
+
}
|
|
45
|
+
catch { /* best effort: it is learned again next time */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** What was learned about a model, or null when nothing was. */
|
|
49
|
+
export const learnedEfforts = (provider, model) => load()[key(provider, model)] ?? null;
|
|
50
|
+
/** The levels the form offers for a provider and (for OpenCode and Kiro) a model. */
|
|
51
|
+
export function effortOptions(provider, model) {
|
|
52
|
+
const shared = EFFORTS.map((id) => ({ id, level: id }));
|
|
53
|
+
if (provider === 'claude')
|
|
54
|
+
return { options: shared, known: true };
|
|
55
|
+
if (provider === 'kiro') {
|
|
56
|
+
const learned = model ? learnedEfforts('kiro', model) : null;
|
|
57
|
+
return learned && learned.length ? { options: learned.map((v) => ({ id: v.id, level: v.id })), known: true } : { options: shared, known: !!learned };
|
|
58
|
+
}
|
|
59
|
+
if (model.includes('#'))
|
|
60
|
+
return { options: [], known: true }; // the model id carries its variant already: nothing is added to it
|
|
61
|
+
const learned = model ? learnedEfforts('opencode', model) : null;
|
|
62
|
+
return learned ? { options: learned, known: true } : { options: [], known: false };
|
|
63
|
+
}
|
|
64
|
+
/** Why an effort cannot be stored for this provider and model, or null when it can. An empty effort always can. */
|
|
65
|
+
export function checkEffort(provider, model, effort) {
|
|
66
|
+
if (effort === undefined || effort === null || effort === '')
|
|
67
|
+
return null;
|
|
68
|
+
if (typeof effort !== 'string')
|
|
69
|
+
return 'effort must be text';
|
|
70
|
+
if (provider === 'opencode') {
|
|
71
|
+
if (model.includes('#'))
|
|
72
|
+
return 'This model id already names its variant: leave the effort empty.';
|
|
73
|
+
const learned = model ? learnedEfforts('opencode', model) : null;
|
|
74
|
+
if (!learned)
|
|
75
|
+
return VARIANT_ID.test(effort) ? null : `"${effort}" is not a variant name`;
|
|
76
|
+
if (!learned.length)
|
|
77
|
+
return `${model} takes no effort variant.`;
|
|
78
|
+
return learned.some((v) => v.id === effort) ? null : `${model} has no "${effort}" variant (it has ${learned.map((v) => v.id).join(', ')}).`;
|
|
79
|
+
}
|
|
80
|
+
if (!validEffort(effort))
|
|
81
|
+
return `effort must be empty or one of ${EFFORTS.join(', ')}`;
|
|
82
|
+
if (provider === 'kiro' && model) {
|
|
83
|
+
const l = learnedEfforts('kiro', model);
|
|
84
|
+
if (l && l.length && !l.some((v) => v.id === effort))
|
|
85
|
+
return `${model} takes ${l.map((v) => v.id).join(', ')}, not "${effort}".`;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* What to pass to the CLI for an effort asked at turn time (the agent's, or a connection's). A level that does not apply to the model comes
|
|
91
|
+
* out empty (the CLI's default) instead of failing the turn; for OpenCode a level name is turned into the variant the model has for it.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveEffort(provider, model, effort) {
|
|
94
|
+
if (!effort)
|
|
95
|
+
return '';
|
|
96
|
+
if (provider === 'opencode') {
|
|
97
|
+
if (model.includes('#'))
|
|
98
|
+
return '';
|
|
99
|
+
const learned = model ? learnedEfforts('opencode', model) : null;
|
|
100
|
+
if (!learned)
|
|
101
|
+
return VARIANT_ID.test(effort) ? effort : '';
|
|
102
|
+
return learned.find((v) => v.id === effort)?.id ?? learned.find((v) => v.level === effort)?.id ?? '';
|
|
103
|
+
}
|
|
104
|
+
return checkEffort(provider, model, effort) === null ? effort : '';
|
|
105
|
+
}
|
|
@@ -10,14 +10,15 @@ import { liveTurn, notifyAgentsChanged, queueDepth, sendTurn, stopAgent } from '
|
|
|
10
10
|
import { sessionStats } from '../../stats.js';
|
|
11
11
|
import { summaryOf } from '../../sessions-summary.js';
|
|
12
12
|
import { emptyUsage } from '../../pricing.js';
|
|
13
|
-
import { agentOrFail, createAgent, removeAgent, resumeSession, sessionToRead, updateAgent } from '../../services/agents.js';
|
|
13
|
+
import { agentOrFail, cloneAgent, createAgent, permissionStale, removeAgent, resumeSession, sessionToRead, updateAgent } from '../../services/agents.js';
|
|
14
14
|
import { UPLOAD_LIMIT, body } from '../body.js';
|
|
15
15
|
import { HttpError, bad, notFound } from '../errors.js';
|
|
16
16
|
import { route } from '../router.js';
|
|
17
17
|
import { qpath, text, validateAgentInput } from '../validate.js';
|
|
18
|
-
route('GET', '/api/agents', () => agents.list().map((a) => ({ ...a, queued: queueDepth(a.id), live: !!liveTurn(a.id) })));
|
|
18
|
+
route('GET', '/api/agents', () => agents.list().map((a) => ({ ...a, queued: queueDepth(a.id), live: !!liveTurn(a.id), permission_stale: permissionStale(a) })));
|
|
19
19
|
route('POST', '/api/agents', async ({ req }) => createAgent(validateAgentInput(await body(req))));
|
|
20
|
-
route('GET', '/api/agents/:id', ({ params }) => agentOrFail(params[0]));
|
|
20
|
+
route('GET', '/api/agents/:id', ({ params }) => { const a = agentOrFail(params[0]); return { ...a, permission_stale: permissionStale(a) }; });
|
|
21
|
+
route('POST', '/api/agents/:id/clone', async ({ req, params }) => cloneAgent(params[0], await body(req)));
|
|
21
22
|
route('PATCH', '/api/agents/:id', async ({ req, params }) => updateAgent(params[0], validateAgentInput(await body(req), true)));
|
|
22
23
|
route('DELETE', '/api/agents/:id', ({ params }) => { removeAgent(params[0]); return { ok: true }; });
|
|
23
24
|
route('POST', '/api/agents/:id/messages', async ({ req, params }) => {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { DATA_DIR } from '../../db.js';
|
|
2
|
+
import { effortOptions, learnedEfforts } from '../../efforts.js';
|
|
1
3
|
import { listModels } from '../../models.js';
|
|
4
|
+
import { learnOpencodeWindows } from '../../providers/opencode-server.js';
|
|
2
5
|
import { isProvider, providerStatus } from '../../services/providers.js';
|
|
3
6
|
import { notFound } from '../errors.js';
|
|
4
7
|
import { route } from '../router.js';
|
|
@@ -9,3 +12,12 @@ route('GET', '/api/providers/:p/models', async ({ params }) => {
|
|
|
9
12
|
throw notFound('Unknown provider');
|
|
10
13
|
return listModels(params[0]);
|
|
11
14
|
});
|
|
15
|
+
// The reasoning levels the agent form offers for a provider and model. OpenCode's depend on the model: when they are not known yet its catalog is read first.
|
|
16
|
+
route('GET', '/api/providers/:p/efforts', async ({ params, url }) => {
|
|
17
|
+
if (!isProvider(params[0]))
|
|
18
|
+
throw notFound('Unknown provider');
|
|
19
|
+
const model = url.searchParams.get('model') ?? '';
|
|
20
|
+
if (params[0] === 'opencode' && model && !learnedEfforts('opencode', model))
|
|
21
|
+
await learnOpencodeWindows(DATA_DIR, null);
|
|
22
|
+
return effortOptions(params[0], model);
|
|
23
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { liveIsDirect } from '../../runtime.js';
|
|
1
|
+
import { liveIsDirect, notifyAgentsChanged } from '../../runtime.js';
|
|
2
2
|
import { notebooks } from '../../skills/notebook.js';
|
|
3
3
|
import { NOTEBOOK_SKILL_ID } from '../../skills/defaults.js';
|
|
4
4
|
import { nbView, noteSource, notebookAgent, notebookEnabled } from '../../services/notebook.js';
|
|
@@ -23,7 +23,7 @@ route('POST', '/api/notebook/rewrite', async ({ req }) => {
|
|
|
23
23
|
route('GET', '/api/agents/:id/notebook', ({ params }) => {
|
|
24
24
|
const a = agentOrFail(params[0]);
|
|
25
25
|
const n = notebooks.get(a.id);
|
|
26
|
-
return { ...nbView(n), updated_at: n.updated_at, updated_by: n.updated_by, enabled: notebookEnabled(a), skill_id: NOTEBOOK_SKILL_ID };
|
|
26
|
+
return { ...nbView(n), updated_at: n.updated_at, updated_by: n.updated_by, enabled: notebookEnabled(a), skill_id: NOTEBOOK_SKILL_ID, shared_with: notebooks.sharedWith(a.id) };
|
|
27
27
|
});
|
|
28
28
|
route('PUT', '/api/agents/:id/notebook', async ({ req, params }) => {
|
|
29
29
|
requireLocal(req, 'The notebook can only be edited from the local app');
|
|
@@ -32,3 +32,11 @@ route('PUT', '/api/agents/:id/notebook', async ({ req, params }) => {
|
|
|
32
32
|
const n = notebooks.edit(a.id, text(p.content), typeof p.version === 'number' ? p.version : undefined);
|
|
33
33
|
return { ...nbView(n), updated_at: n.updated_at, updated_by: n.updated_by };
|
|
34
34
|
});
|
|
35
|
+
// Leaves the notebook it shares: the agent keeps a copy of the current text of its own and the others keep the shared one.
|
|
36
|
+
route('POST', '/api/agents/:id/notebook/separate', ({ req, params }) => {
|
|
37
|
+
requireLocal(req, 'The notebook can only be changed from the local app');
|
|
38
|
+
const a = agentOrFail(params[0]);
|
|
39
|
+
const n = notebooks.separate(a.id);
|
|
40
|
+
notifyAgentsChanged();
|
|
41
|
+
return { ...nbView(n), updated_at: n.updated_at, updated_by: n.updated_by, shared_with: notebooks.sharedWith(a.id) };
|
|
42
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { agents, dispatches } from '../../db.js';
|
|
2
|
+
import { startTask, taskStatus } from '../../dispatches.js';
|
|
2
3
|
import { dispatch, liveTurn, notifyAgentsChanged } from '../../runtime.js';
|
|
3
4
|
import { agentOrFail } from '../../services/agents.js';
|
|
4
5
|
import { body } from '../body.js';
|
|
5
6
|
import { bad } from '../errors.js';
|
|
6
7
|
import { route } from '../router.js';
|
|
7
|
-
import { strList } from '../validate.js';
|
|
8
|
+
import { strList, text } from '../validate.js';
|
|
8
9
|
route('GET', '/api/orchestrators/:id/workers', ({ params }) => {
|
|
9
10
|
const o = agentOrFail(params[0]);
|
|
10
11
|
return o.worker_ids.map((id) => agents.get(id)).filter((w) => !!w).map((w) => ({ name: w.name, role: w.role, description: w.description, provider: w.provider, busy: !!liveTurn(w.id) }));
|
|
@@ -18,10 +19,14 @@ route('PUT', '/api/orchestrators/:id/workers', async ({ req, params }) => {
|
|
|
18
19
|
notifyAgentsChanged();
|
|
19
20
|
return agents.get(o.id);
|
|
20
21
|
});
|
|
22
|
+
// `background: true` returns at once and the orchestrator is told the result later; `poll: true` returns at once too, for a caller that waits
|
|
23
|
+
// with /api/dispatch/status (the synchronous `dispatch` tool). With neither, the request is held until the worker answers, as it always was.
|
|
21
24
|
route('POST', '/api/dispatch', async ({ req }) => {
|
|
22
|
-
const { from, agent, task } = await body(req);
|
|
25
|
+
const { from, agent, task, background, poll } = await body(req);
|
|
23
26
|
if (!from || !agent || !task)
|
|
24
27
|
throw bad('from, agent and task are required');
|
|
28
|
+
if (background === true || poll === true)
|
|
29
|
+
return startTask(text(from), text(agent), text(task), background === true);
|
|
25
30
|
try {
|
|
26
31
|
return await dispatch(from, agent, task);
|
|
27
32
|
}
|
|
@@ -29,4 +34,10 @@ route('POST', '/api/dispatch', async ({ req }) => {
|
|
|
29
34
|
throw bad(e instanceof Error ? e.message : String(e));
|
|
30
35
|
}
|
|
31
36
|
});
|
|
37
|
+
route('POST', '/api/dispatch/status', async ({ req }) => {
|
|
38
|
+
const { from, id, wait_seconds } = await body(req);
|
|
39
|
+
if (!from)
|
|
40
|
+
throw bad('from is required');
|
|
41
|
+
return taskStatus(text(from), text(id), wait_seconds);
|
|
42
|
+
});
|
|
32
43
|
route('GET', '/api/dispatches', () => dispatches.recent());
|
|
@@ -2,6 +2,7 @@ import { notifyAgentsChanged } from '../../runtime.js';
|
|
|
2
2
|
import { DRIVER_PROPS } from '../../objects/driver-props.js';
|
|
3
3
|
import { composeServices, listContainers, listImages } from '../../objects/docker-info.js';
|
|
4
4
|
import { agentObjectAction, agentObjectLogs, agentObjects } from '../../objects/agent.js';
|
|
5
|
+
import { agentDb } from '../../objects/agent-db.js';
|
|
5
6
|
import { dbColumnsOf, dbHistory, dbHistoryClear, dbRun, dbSavedAdd, dbSavedList, dbSavedRemove, dbSavedUpdate, dbSchemaOf, dbTestConfig, createObject, statsOf, httpDescribe, httpSession, httpSessionClear, httpSessionSet, httpInspect, httpExport, httpFileRead, httpFileWrite, httpFileCreate, httpRun, httpHistory, httpDocs, httpHistoryClear, httpScan, listObjects, setObjectManager, setAgentObjects, objectAction, objectServiceAction, listTemplates, createFromTemplate, objectInspect, objectLogs, removeObject, updateObject } from '../../objects/index.js';
|
|
6
7
|
import { body } from '../body.js';
|
|
7
8
|
import { notFound } from '../errors.js';
|
|
@@ -20,6 +21,8 @@ const guarded = (path, fn, method = 'GET') => route(method, path, (ctx) => { loc
|
|
|
20
21
|
route('POST', '/api/agent-objects/list', async ({ req }) => { const b = await body(req); return { objects: agentObjects(b.from) }; });
|
|
21
22
|
route('POST', '/api/agent-objects/action', async ({ req }) => { const b = await body(req); return agentObjectAction(b.from, b.name, b.action); });
|
|
22
23
|
route('POST', '/api/agent-objects/logs', async ({ req }) => { const b = await body(req); return agentObjectLogs(b.from, b.name, b.tail); });
|
|
24
|
+
// The tools `object_db_*` of the "Database management" skill: one route, the operation in the path; scope and permission are checked in objects/agent-db.ts.
|
|
25
|
+
route('POST', '/api/agent-objects/db/:op', async ({ req, params }) => agentDb(params[0], await body(req)));
|
|
23
26
|
route('GET', '/api/objects', () => listObjects());
|
|
24
27
|
write('POST', '/api/objects', (b) => createObject(b));
|
|
25
28
|
write('PATCH', '/api/objects/:id', (b, { params }) => updateObject(params[0], b));
|
|
@@ -4,6 +4,7 @@ import { body } from '../body.js';
|
|
|
4
4
|
import { bad, notFound } from '../errors.js';
|
|
5
5
|
import { route } from '../router.js';
|
|
6
6
|
import { isProvider } from '../../services/providers.js';
|
|
7
|
+
import { checkEffort } from '../../efforts.js';
|
|
7
8
|
import { requireName, text, validateAgentInput } from '../validate.js';
|
|
8
9
|
route('GET', '/api/types', () => types.list());
|
|
9
10
|
route('POST', '/api/types', async ({ req }) => {
|
|
@@ -11,6 +12,9 @@ route('POST', '/api/types', async ({ req }) => {
|
|
|
11
12
|
const name = requireName(p.name);
|
|
12
13
|
if (!isProvider(p.provider))
|
|
13
14
|
throw bad('Pick a provider');
|
|
15
|
+
const why = checkEffort(p.provider, text(p.model), p.effort);
|
|
16
|
+
if (why)
|
|
17
|
+
throw bad(why);
|
|
14
18
|
try {
|
|
15
19
|
return types.create({ ...p, name });
|
|
16
20
|
}
|
|
@@ -19,7 +23,20 @@ route('POST', '/api/types', async ({ req }) => {
|
|
|
19
23
|
}
|
|
20
24
|
});
|
|
21
25
|
route('PATCH', '/api/types/:id', async ({ req, params }) => {
|
|
22
|
-
const
|
|
26
|
+
const cur = types.get(params[0]);
|
|
27
|
+
if (!cur)
|
|
28
|
+
throw notFound('Type not found');
|
|
29
|
+
const p = await body(req);
|
|
30
|
+
// The effort has to apply to the provider and model the type will have; one that no longer does is cleared.
|
|
31
|
+
const provider = isProvider(p.provider) ? p.provider : cur.provider, model = p.model !== undefined ? text(p.model) : cur.model;
|
|
32
|
+
if (p.effort !== undefined) {
|
|
33
|
+
const why = checkEffort(provider, model, p.effort);
|
|
34
|
+
if (why)
|
|
35
|
+
throw bad(why);
|
|
36
|
+
}
|
|
37
|
+
else if (cur.effort && checkEffort(provider, model, cur.effort) !== null)
|
|
38
|
+
p.effort = '';
|
|
39
|
+
const t = types.update(params[0], p);
|
|
23
40
|
if (!t)
|
|
24
41
|
throw notFound('Type not found');
|
|
25
42
|
return t;
|
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { agents, colonies } from '../db.js';
|
|
3
3
|
import { validVision } from '../connections/vision.js';
|
|
4
4
|
import { isProvider } from '../services/providers.js';
|
|
5
|
+
import { EFFORTS, validEffort } from '../efforts.js';
|
|
5
6
|
import { bad } from './errors.js';
|
|
6
7
|
/** Plain value helpers: what a field is when it may be missing or of the wrong type. */
|
|
7
8
|
export const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
@@ -29,6 +30,8 @@ export function validateAgentInput(p, partial = false) {
|
|
|
29
30
|
checkFolder(p.cwd);
|
|
30
31
|
if (p.browser !== undefined && typeof p.browser !== 'boolean')
|
|
31
32
|
throw bad('browser must be true or false');
|
|
33
|
+
if (p.effort !== undefined && (typeof p.effort !== 'string' || p.effort.length > 24))
|
|
34
|
+
throw bad('effort must be text');
|
|
32
35
|
if (p.colony_id && (typeof p.colony_id !== 'string' || !colonies.get(p.colony_id)))
|
|
33
36
|
throw bad('That colony no longer exists');
|
|
34
37
|
return p;
|
|
@@ -39,22 +42,42 @@ export function validateColony(p, partial = false) {
|
|
|
39
42
|
checkFolder(p.cwd);
|
|
40
43
|
return p;
|
|
41
44
|
}
|
|
45
|
+
const MAX_CHATS = 50;
|
|
46
|
+
const MAX_CHAT_MEMBERS = 50;
|
|
47
|
+
/** What a person's id looks like on each platform: Telegram user ids are positive numbers; Slack member ids are `U…` (or `W…` on Enterprise Grid). */
|
|
48
|
+
const MEMBER_ID = { telegram: /^\d+$/, slack: /^[UW][A-Z0-9]{2,}$/ };
|
|
49
|
+
/** One authorized group: its mode, and when it only admits some people, who they are. */
|
|
50
|
+
function validateChat(kind, c) {
|
|
51
|
+
const r = isRecord(c) ? c : {};
|
|
52
|
+
if (r.mode !== undefined && r.mode !== 'everyone' && r.mode !== 'members')
|
|
53
|
+
throw bad('chat mode must be everyone or members');
|
|
54
|
+
if (r.members === undefined)
|
|
55
|
+
return;
|
|
56
|
+
if (!Array.isArray(r.members) || r.members.length > MAX_CHAT_MEMBERS)
|
|
57
|
+
throw bad(`a chat can list at most ${MAX_CHAT_MEMBERS} members`);
|
|
58
|
+
const shape = MEMBER_ID[kind];
|
|
59
|
+
if (shape && r.members.some((m) => !shape.test(trimmed(isRecord(m) ? m.id : m))))
|
|
60
|
+
throw bad('member ids must be numbers on Telegram and start with U or W on Slack');
|
|
61
|
+
}
|
|
42
62
|
export const CONNECTION_KINDS = ['telegram', 'slack'];
|
|
43
63
|
const REQUIRED = { telegram: ['token'], slack: ['bot_token', 'app_token'] };
|
|
44
64
|
export function validateConnection(kind, config, agentId, enabled) {
|
|
45
65
|
const missing = (REQUIRED[kind] ?? []).filter((k) => typeof config[k] !== 'string' || !config[k].trim());
|
|
46
66
|
if (missing.length)
|
|
47
67
|
throw bad(`Missing: ${missing.join(', ')}`);
|
|
48
|
-
if (config.effort !== undefined &&
|
|
49
|
-
throw bad(
|
|
68
|
+
if (config.effort !== undefined && config.effort !== '' && !validEffort(config.effort))
|
|
69
|
+
throw bad(`effort must be empty or one of ${EFFORTS.join(', ')}`);
|
|
50
70
|
if (config.on_silent !== undefined && !['notice', 'send_text', 'ignore'].includes(config.on_silent))
|
|
51
71
|
throw bad('on_silent must be notice, send_text or ignore');
|
|
52
72
|
if (config.group_mode !== undefined && !['mention', 'open'].includes(config.group_mode))
|
|
53
73
|
throw bad('group_mode must be mention or open');
|
|
54
74
|
if (config.vision !== undefined && config.vision !== null && !validVision(config.vision))
|
|
55
75
|
throw bad('vision must be { provider, model } or empty');
|
|
56
|
-
if (config.chats !== undefined && (!Array.isArray(config.chats) || config.chats.length >
|
|
57
|
-
throw bad(
|
|
76
|
+
if (config.chats !== undefined && (!Array.isArray(config.chats) || config.chats.length > MAX_CHATS))
|
|
77
|
+
throw bad(`chats must be a list of at most ${MAX_CHATS} groups`);
|
|
78
|
+
if (Array.isArray(config.chats))
|
|
79
|
+
for (const c of config.chats)
|
|
80
|
+
validateChat(kind, c);
|
|
58
81
|
if (config.aliases !== undefined && (!Array.isArray(config.aliases) || config.aliases.length > 10))
|
|
59
82
|
throw bad('aliases must be a list of at most 10 names');
|
|
60
83
|
if (agentId) {
|
package/server/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import './connections/index.js';
|
|
|
10
10
|
import { startAll, stopAll } from './connections/manager.js';
|
|
11
11
|
import { addReactionsToChannelSkill, migrateChannelSkill } from './connections/channel-skill.js';
|
|
12
12
|
import { armWakeups } from './wake.js';
|
|
13
|
+
import { armDispatchNotices } from './dispatches.js';
|
|
13
14
|
import { armSchedules } from './schedules.js';
|
|
14
15
|
import { armWatches } from './watch.js';
|
|
15
16
|
import { armObjects } from './objects/index.js';
|
|
@@ -21,6 +22,7 @@ dropStarterSkills();
|
|
|
21
22
|
migrateChannelSkill();
|
|
22
23
|
addReactionsToChannelSkill();
|
|
23
24
|
armWakeups();
|
|
25
|
+
armDispatchNotices();
|
|
24
26
|
armSchedules();
|
|
25
27
|
armWatches();
|
|
26
28
|
armObjects();
|
|
@@ -71,12 +73,15 @@ async function shutdown(why, code) {
|
|
|
71
73
|
catch (e) {
|
|
72
74
|
console.error(`[server] shutdown, ${name}:`, e);
|
|
73
75
|
} };
|
|
74
|
-
|
|
76
|
+
// `server.close` only ends once every connection is gone, and a WebSocket is not one `closeAllConnections` can close: stop taking requests first,
|
|
77
|
+
// drop the sockets, and only then wait for the server (waiting first made every shutdown last the full 5 s and left the database unclosed).
|
|
78
|
+
const httpClosed = new Promise((done) => { server.close(() => done()); server.closeAllConnections(); });
|
|
75
79
|
await step('websockets', () => { for (const w of [wss, termWss]) {
|
|
76
80
|
for (const c of w.clients)
|
|
77
81
|
c.terminate();
|
|
78
82
|
w.close();
|
|
79
83
|
} });
|
|
84
|
+
await step('http', () => httpClosed);
|
|
80
85
|
await step('terminals', closeAllTerminals);
|
|
81
86
|
await step('connections', stopAll);
|
|
82
87
|
await step('database', () => db.close());
|