@stratusagent/gateway 0.4.0 → 0.7.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/dist/index.d.ts +230 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +466 -119
- package/dist/index.js.map +1 -1
- package/dist/schedules.d.ts +137 -0
- package/dist/schedules.d.ts.map +1 -0
- package/dist/schedules.js +427 -0
- package/dist/schedules.js.map +1 -0
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -2,10 +2,11 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { chmodSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
-
import { AgentRegistry, AgentRunner, EventBus, RunAbortedError, ToolRegistry, readPendingApproval, } from '@stratusagent/core';
|
|
5
|
+
import { AgentRegistry, AgentRunner, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, readPendingApproval, } from '@stratusagent/core';
|
|
6
6
|
import { createDelegateTool, createRememberTool, } from '@stratusagent/agents';
|
|
7
7
|
import { createLocalCommandExecutor } from '@stratusagent/executor-local';
|
|
8
|
-
import {
|
|
8
|
+
import { loadPlugins, } from '@stratusagent/plugins';
|
|
9
|
+
import { createDemoTool, createFileMemoryStore, createRuntimeProvider, DEFAULT_STRATUS_AGENT, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, memoryFilePath, migrateLegacyMemory, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, applySoulPins, stratusHomePath, workspacesDirPath, withLegacyDefaultMemories, } from '@stratusagent/state';
|
|
9
10
|
const SESSIONS_DB_FILENAME = 'sessions.db';
|
|
10
11
|
const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
|
11
12
|
/**
|
|
@@ -107,11 +108,74 @@ export class SqliteSessionStore {
|
|
|
107
108
|
.all(status);
|
|
108
109
|
return rows.map((row) => row.id);
|
|
109
110
|
}
|
|
110
|
-
/**
|
|
111
|
-
|
|
111
|
+
/**
|
|
112
|
+
* When each agent last did anything, and how many of its sessions are live
|
|
113
|
+
* right now.
|
|
114
|
+
*
|
|
115
|
+
* One aggregate over the indexed columns — no conversation body is
|
|
116
|
+
* deserialized, for the same reason `listIdsByStatus` returns ids: a
|
|
117
|
+
* roster view asks this on every load, and it must not cost a JSON parse
|
|
118
|
+
* per session to answer.
|
|
119
|
+
*
|
|
120
|
+
* Both halves are needed and neither is sufficient. `lastActiveAt` alone
|
|
121
|
+
* reads a turn parked on a human as idle, because the save that recorded
|
|
122
|
+
* the park is the last thing that touched the row — and a turn waiting
|
|
123
|
+
* twenty minutes on an approval is exactly when someone wants to see the
|
|
124
|
+
* agent lit. `activeSessions` alone loses an agent that finished a moment
|
|
125
|
+
* ago. The caller decides what window counts as "recent"; a daemon that
|
|
126
|
+
* baked one in would need upgrading to change it.
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* How many sessions are in each state.
|
|
130
|
+
*
|
|
131
|
+
* A grouped count, not a listing that gets counted. The table grows for the
|
|
132
|
+
* life of an install and a health endpoint is polled, so materialising one
|
|
133
|
+
* object per historical session to produce five numbers gets steadily
|
|
134
|
+
* slower at exactly the thing meant to report that the daemon is fine.
|
|
135
|
+
*/
|
|
136
|
+
countByStatus() {
|
|
137
|
+
const rows = this.db
|
|
138
|
+
.prepare('SELECT status, COUNT(*) AS total FROM sessions GROUP BY status')
|
|
139
|
+
.all();
|
|
140
|
+
const counts = {};
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
counts[row.status] = Number(row.total);
|
|
143
|
+
}
|
|
144
|
+
return counts;
|
|
145
|
+
}
|
|
146
|
+
lastActivityByAgent() {
|
|
147
|
+
const rows = this.db
|
|
148
|
+
.prepare(`
|
|
149
|
+
SELECT agent_id,
|
|
150
|
+
MAX(updated_at) AS last_active_at,
|
|
151
|
+
SUM(CASE WHEN status IN ('running', 'pending_approval') THEN 1 ELSE 0 END) AS active_sessions
|
|
152
|
+
FROM sessions
|
|
153
|
+
GROUP BY agent_id
|
|
154
|
+
`)
|
|
155
|
+
.all();
|
|
156
|
+
const activity = {};
|
|
157
|
+
for (const row of rows) {
|
|
158
|
+
activity[row.agent_id] = {
|
|
159
|
+
lastActiveAt: row.last_active_at,
|
|
160
|
+
activeSessions: Number(row.active_sessions),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return activity;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Newest-first session listing for one agent (or all agents).
|
|
167
|
+
*
|
|
168
|
+
* `limit` is not decoration: this table grows for the life of an install,
|
|
169
|
+
* and a surface that renders "recent conversations" would otherwise pull
|
|
170
|
+
* every session anyone has ever had to show ten of them.
|
|
171
|
+
*/
|
|
172
|
+
list(agentId, limit) {
|
|
173
|
+
// -1 is SQLite's "no limit", so one prepared statement serves both cases
|
|
174
|
+
// rather than four.
|
|
175
|
+
const bound = limit !== undefined && Number.isInteger(limit) && limit >= 0 ? limit : -1;
|
|
112
176
|
const rows = (agentId
|
|
113
|
-
? this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC').all(agentId)
|
|
114
|
-
: this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions ORDER BY updated_at DESC').all());
|
|
177
|
+
? this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC LIMIT ?').all(agentId, bound)
|
|
178
|
+
: this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?').all(bound));
|
|
115
179
|
return rows.map((row) => ({
|
|
116
180
|
id: row.id,
|
|
117
181
|
agentId: row.agent_id,
|
|
@@ -125,64 +189,15 @@ export class SqliteSessionStore {
|
|
|
125
189
|
}
|
|
126
190
|
}
|
|
127
191
|
/**
|
|
128
|
-
*
|
|
129
|
-
* daemon-wide defaults the pins outrank.
|
|
192
|
+
* A soul's provider/model pins, and the daemon-wide defaults they demote.
|
|
130
193
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
194
|
+
* The implementation lives in `@stratusagent/state`, beside the resolver
|
|
195
|
+
* whose precedence it manipulates — it never had a gateway dependency. It is
|
|
196
|
+
* re-exported here because this is where callers were told to import it from,
|
|
197
|
+
* and because "what will a served agent actually resolve to" is a gateway
|
|
198
|
+
* question even when the rule answering it is not gateway code.
|
|
135
199
|
*/
|
|
136
|
-
export
|
|
137
|
-
selection.presetSoul = pins;
|
|
138
|
-
if (!pins.provider && !pins.model) {
|
|
139
|
-
return { selection, env };
|
|
140
|
-
}
|
|
141
|
-
const processEnv = { ...(env.processEnv ?? process.env) };
|
|
142
|
-
// The daemon-wide default can come from the selection, the environment,
|
|
143
|
-
// or the config file. A config with no provider key predates the
|
|
144
|
-
// anthropic option and is openai-specific (the resolver treats it that
|
|
145
|
-
// way), so a real file without the key still names openai as the
|
|
146
|
-
// default. Env values normalize exactly as the resolver normalizes
|
|
147
|
-
// them: an empty or whitespace-padded STRATUS_PROVIDER is no default at
|
|
148
|
-
// all, not a mismatching one.
|
|
149
|
-
const defaultProvider = context.selectionProvider
|
|
150
|
-
?? readNonEmptyString(processEnv.STRATUS_PROVIDER)
|
|
151
|
-
?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER)
|
|
152
|
-
?? context.configProvider
|
|
153
|
-
?? (context.configPresent ? 'openai' : undefined);
|
|
154
|
-
if (pins.provider) {
|
|
155
|
-
delete selection.provider;
|
|
156
|
-
delete processEnv.STRATUS_PROVIDER;
|
|
157
|
-
delete processEnv.STRATUSCLAW_PROVIDER;
|
|
158
|
-
if (defaultProvider !== undefined && defaultProvider !== 'demo' && pins.provider !== defaultProvider) {
|
|
159
|
-
// The default model, endpoint, and generic credentials were all
|
|
160
|
-
// chosen for the default provider — none may ride along to the
|
|
161
|
-
// soul's: a base URL would point the pinned provider at the wrong
|
|
162
|
-
// service, and a generic API key would be sent to it. With NO
|
|
163
|
-
// default selected anywhere — or the credential-less demo provider
|
|
164
|
-
// as the default — there is nothing those values could have been
|
|
165
|
-
// chosen for except whatever provider the soul selects — exactly
|
|
166
|
-
// the resolver's own reading of a generic credential — so they stay.
|
|
167
|
-
delete selection.model;
|
|
168
|
-
delete processEnv.STRATUS_MODEL;
|
|
169
|
-
delete processEnv.STRATUSCLAW_MODEL;
|
|
170
|
-
delete selection.baseUrl;
|
|
171
|
-
delete processEnv.STRATUS_BASE_URL;
|
|
172
|
-
delete processEnv.STRATUSCLAW_BASE_URL;
|
|
173
|
-
delete processEnv.STRATUS_API_KEY;
|
|
174
|
-
delete processEnv.STRATUSCLAW_API_KEY;
|
|
175
|
-
delete processEnv.STRATUS_API_KEY_ENV;
|
|
176
|
-
delete processEnv.STRATUSCLAW_API_KEY_ENV;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (pins.model) {
|
|
180
|
-
delete selection.model;
|
|
181
|
-
delete processEnv.STRATUS_MODEL;
|
|
182
|
-
delete processEnv.STRATUSCLAW_MODEL;
|
|
183
|
-
}
|
|
184
|
-
return { selection, env: { ...env, processEnv } };
|
|
185
|
-
};
|
|
200
|
+
export { applySoulPins } from '@stratusagent/state';
|
|
186
201
|
/**
|
|
187
202
|
* Recorded on a turn the previous process was still running when it died.
|
|
188
203
|
*
|
|
@@ -298,7 +313,7 @@ export const createGateway = (options = {}) => {
|
|
|
298
313
|
// Identity, not presence: a request that already settled is gone from
|
|
299
314
|
// the registry, and comparing against this exact function is what
|
|
300
315
|
// makes every ending — click, timeout, abort, shutdown — idempotent.
|
|
301
|
-
if (pendingApprovals.get(requestId) !== settle) {
|
|
316
|
+
if (pendingApprovals.get(requestId)?.settle !== settle) {
|
|
302
317
|
return;
|
|
303
318
|
}
|
|
304
319
|
pendingApprovals.delete(requestId);
|
|
@@ -318,13 +333,31 @@ export const createGateway = (options = {}) => {
|
|
|
318
333
|
approvalEmissions.add(emitted);
|
|
319
334
|
void emitted.finally(() => approvalEmissions.delete(emitted));
|
|
320
335
|
};
|
|
321
|
-
pendingApprovals.set(requestId, settle);
|
|
322
336
|
// What is left of the window, not a fresh one: a re-asked request
|
|
323
337
|
// carries when it originally parked, and the elapsed time counts.
|
|
338
|
+
// Computed before registration because the summary below quotes the
|
|
339
|
+
// deadline, and a listing that disagreed with the announcing event about
|
|
340
|
+
// when a request expires would be worse than one that said nothing.
|
|
324
341
|
const alreadyWaitedMs = request.parkedAt ? Date.now() - Date.parse(request.parkedAt) : 0;
|
|
325
342
|
const remainingTimeoutMs = effectiveApprovalTimeoutMs > 0
|
|
326
343
|
? effectiveApprovalTimeoutMs - (Number.isFinite(alreadyWaitedMs) ? Math.max(0, alreadyWaitedMs) : 0)
|
|
327
344
|
: 0;
|
|
345
|
+
const expiresAt = remainingTimeoutMs > 0
|
|
346
|
+
? new Date(Date.now() + remainingTimeoutMs).toISOString()
|
|
347
|
+
: undefined;
|
|
348
|
+
pendingApprovals.set(requestId, {
|
|
349
|
+
settle,
|
|
350
|
+
pending: {
|
|
351
|
+
requestId,
|
|
352
|
+
sessionId: request.session.id,
|
|
353
|
+
agentId: request.session.agent.id,
|
|
354
|
+
call: request.call,
|
|
355
|
+
risk: request.risk,
|
|
356
|
+
parkedAt: request.parkedAt ?? new Date().toISOString(),
|
|
357
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
358
|
+
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
359
|
+
},
|
|
360
|
+
});
|
|
328
361
|
if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
|
|
329
362
|
// Nothing left to wait with. Settled here rather than armed with a
|
|
330
363
|
// zero timer, which `setTimeout` would still run a tick later — long
|
|
@@ -366,9 +399,7 @@ export const createGateway = (options = {}) => {
|
|
|
366
399
|
call: request.call,
|
|
367
400
|
risk: request.risk,
|
|
368
401
|
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
369
|
-
...(
|
|
370
|
-
? { expiresAt: new Date(Date.now() + remainingTimeoutMs).toISOString() }
|
|
371
|
-
: {}),
|
|
402
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
372
403
|
});
|
|
373
404
|
// Drained at shutdown alongside the resolutions: a channel that has not
|
|
374
405
|
// yet been told a request exists cannot be told it was denied either.
|
|
@@ -379,11 +410,11 @@ export const createGateway = (options = {}) => {
|
|
|
379
410
|
});
|
|
380
411
|
});
|
|
381
412
|
const resolveApproval = (input) => {
|
|
382
|
-
const
|
|
383
|
-
if (!
|
|
413
|
+
const parked = pendingApprovals.get(input.requestId);
|
|
414
|
+
if (!parked) {
|
|
384
415
|
return false;
|
|
385
416
|
}
|
|
386
|
-
settle(input.answer, input.reason ?? 'decided', input.actor);
|
|
417
|
+
parked.settle(input.answer, input.reason ?? 'decided', input.actor);
|
|
387
418
|
return true;
|
|
388
419
|
};
|
|
389
420
|
/**
|
|
@@ -525,20 +556,37 @@ export const createGateway = (options = {}) => {
|
|
|
525
556
|
lastDefaultAgentId = id;
|
|
526
557
|
return id;
|
|
527
558
|
};
|
|
559
|
+
/**
|
|
560
|
+
* Build the roster from what is on disk right now.
|
|
561
|
+
*
|
|
562
|
+
* Runs at start and again on every `reloadRoster()`, which is why it
|
|
563
|
+
* tracks what it registered rather than only adding: a soul file deleted
|
|
564
|
+
* since the last pass has to stop being dispatchable, and re-registering
|
|
565
|
+
* the survivors over a map nothing removes from would leave it addressable
|
|
566
|
+
* — by id, and from every channel — for the rest of the daemon's life.
|
|
567
|
+
*/
|
|
528
568
|
const loadRoster = async () => {
|
|
569
|
+
const seen = new Set();
|
|
570
|
+
const registerFresh = (source) => {
|
|
571
|
+
registerSource(source);
|
|
572
|
+
seen.add(source.definition.id);
|
|
573
|
+
};
|
|
529
574
|
// The built-in id is reserved BEFORE ordinary roster entries load: a
|
|
530
575
|
// roster file that happens to declare id "stratus" must not hijack
|
|
531
576
|
// the documented built-in fallback for agentId-less dispatches — the
|
|
532
577
|
// guard below skips it with a warning. Only the explicitly
|
|
533
578
|
// configured default soul may take the id over (defaultAgentId
|
|
534
579
|
// replaces a pathless source).
|
|
535
|
-
|
|
580
|
+
registerFresh({ definition: { ...DEFAULT_STRATUS_AGENT } });
|
|
536
581
|
// Throws on two roster files claiming one id: that is a collision with
|
|
537
582
|
// no correct winner, and refusing to serve beats letting sort order
|
|
538
583
|
// decide whose sessions, memory, and credentials an agent inherits.
|
|
539
584
|
const entries = await loadRosterSouls(env, warn);
|
|
540
585
|
for (const entry of entries) {
|
|
541
|
-
|
|
586
|
+
// Against ids claimed by THIS pass, not by any earlier one: a reload
|
|
587
|
+
// must be free to re-register the agent it just re-read, and only the
|
|
588
|
+
// built-in above can legitimately block a roster file.
|
|
589
|
+
if (seen.has(entry.soul.agent.id)) {
|
|
542
590
|
// Only reachable for the reserved built-in id now — roster-vs-
|
|
543
591
|
// roster duplicates never get this far. Reserved is not the same
|
|
544
592
|
// failure as duplicated: a soul may not take the fallback over,
|
|
@@ -546,11 +594,52 @@ export const createGateway = (options = {}) => {
|
|
|
546
594
|
warn(`agent id ${entry.soul.agent.id} is reserved for the built-in agent; ignoring ${entry.path}`);
|
|
547
595
|
continue;
|
|
548
596
|
}
|
|
549
|
-
|
|
597
|
+
registerFresh({ definition: entry.soul.agent, soulPath: entry.path, soul: entry.soul });
|
|
550
598
|
}
|
|
551
599
|
// The configured default soul is part of the roster too — it is what
|
|
552
|
-
// an agentId-less dispatch answers as.
|
|
553
|
-
|
|
600
|
+
// an agentId-less dispatch answers as. It may live outside the agents
|
|
601
|
+
// directory, and it survives a read failure by name, so what it
|
|
602
|
+
// resolves to is kept whether or not this pass re-registered it.
|
|
603
|
+
seen.add(await defaultAgentId());
|
|
604
|
+
for (const id of [...sources.keys()]) {
|
|
605
|
+
if (!seen.has(id)) {
|
|
606
|
+
sources.delete(id);
|
|
607
|
+
registry.unregister(id);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Advisory, per the skills spec: a skill may say which toolsets its
|
|
611
|
+
// procedure expects (`requires:`), and an agent enabling it without
|
|
612
|
+
// them gets a warning at load — never a refusal, because a skill is
|
|
613
|
+
// prose and can degrade. The check itself is the kernel's
|
|
614
|
+
// (`missingSkillRequirements`), shared with the CLI's local runs so
|
|
615
|
+
// both hosts warn about the same configuration.
|
|
616
|
+
for (const agent of registry.list()) {
|
|
617
|
+
for (const { skill, missing } of missingSkillRequirements(agent, skillCatalog)) {
|
|
618
|
+
warn(`agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
/**
|
|
623
|
+
* Roster rebuilds, one at a time.
|
|
624
|
+
*
|
|
625
|
+
* `loadRoster` prunes by comparing the registry against the ids *this pass*
|
|
626
|
+
* saw, so two overlapping passes destroy each other's work: the older one
|
|
627
|
+
* snapshots a roster without agent B, the newer one registers B and
|
|
628
|
+
* finishes, and then the older one reaches its prune and unregisters B —
|
|
629
|
+
* which two clients creating agents at once will do, each having been told
|
|
630
|
+
* 201 for an agent that is no longer dispatchable.
|
|
631
|
+
*
|
|
632
|
+
* A chain rather than a lock: a reload is idempotent, so a caller arriving
|
|
633
|
+
* mid-rebuild wants the *next* complete one, and waiting for it is exactly
|
|
634
|
+
* what this gives them.
|
|
635
|
+
*/
|
|
636
|
+
let reloads = Promise.resolve();
|
|
637
|
+
const reloadsInOrder = async () => {
|
|
638
|
+
const next = reloads.then(loadRoster, loadRoster);
|
|
639
|
+
// Swallowed for the chain only — the caller still sees the rejection, but
|
|
640
|
+
// a roster that failed to load must not block every reload after it.
|
|
641
|
+
reloads = next.catch(() => undefined);
|
|
642
|
+
await next;
|
|
554
643
|
};
|
|
555
644
|
/**
|
|
556
645
|
* A session pins its agent id, never a snapshot of the agent: the soul
|
|
@@ -592,8 +681,23 @@ export const createGateway = (options = {}) => {
|
|
|
592
681
|
};
|
|
593
682
|
// ---- runner pool --------------------------------------------------------
|
|
594
683
|
const tools = new ToolRegistry();
|
|
684
|
+
// Which package each tool came from. Kernel tools are absent from this
|
|
685
|
+
// map, which is how `tools()` reports them as the kernel's.
|
|
686
|
+
const toolProvenance = new Map();
|
|
687
|
+
let loadedPlugins = [];
|
|
688
|
+
let pluginFailures = [];
|
|
689
|
+
const skillCatalog = new SkillRegistry();
|
|
690
|
+
let operatorSkills = [];
|
|
595
691
|
tools.register(createDemoTool());
|
|
596
692
|
tools.register(createRememberTool(memory));
|
|
693
|
+
// Registered here rather than left to the first runner, so `tools()`
|
|
694
|
+
// lists the reader before anything dispatches. The allowlist resolver
|
|
695
|
+
// mirrors the runner's own sourcing — the definition travelling with the
|
|
696
|
+
// session, then the roster — so the tool refuses exactly what the
|
|
697
|
+
// runner's gates refuse.
|
|
698
|
+
tools.register(createSkillReadTool(skillCatalog, {
|
|
699
|
+
allowlistFor: (session) => session.agent.skills ?? registry.get(session.agent.id)?.skills,
|
|
700
|
+
}));
|
|
597
701
|
// Delegation is dispatcher-backed, not runner-capturing: the target agent
|
|
598
702
|
// runs through the same per-provider routing as a direct dispatch, so a
|
|
599
703
|
// delegated specialist uses their own provider and credentials.
|
|
@@ -647,6 +751,7 @@ export const createGateway = (options = {}) => {
|
|
|
647
751
|
store,
|
|
648
752
|
bus,
|
|
649
753
|
agents: registry,
|
|
754
|
+
skills: skillCatalog,
|
|
650
755
|
memory,
|
|
651
756
|
streaming: true,
|
|
652
757
|
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
@@ -840,6 +945,16 @@ export const createGateway = (options = {}) => {
|
|
|
840
945
|
const sessionChains = new Map();
|
|
841
946
|
const inflight = new Set();
|
|
842
947
|
let stopping = false;
|
|
948
|
+
/**
|
|
949
|
+
* The turn id running on each session, for callers that named one.
|
|
950
|
+
*
|
|
951
|
+
* Set when the chained work actually starts — not when the dispatch was
|
|
952
|
+
* queued — because a message waiting behind another turn has not begun,
|
|
953
|
+
* and stamping events with it would attribute the running turn's output to
|
|
954
|
+
* the queued one. Single-flight per session is what makes one entry per
|
|
955
|
+
* session sufficient.
|
|
956
|
+
*/
|
|
957
|
+
const activeTurns = new Map();
|
|
843
958
|
/**
|
|
844
959
|
* The runtime one agent would run on right now — config snapshot, soul
|
|
845
960
|
* pins, degradation and all.
|
|
@@ -1154,7 +1269,137 @@ export const createGateway = (options = {}) => {
|
|
|
1154
1269
|
if (stopping) {
|
|
1155
1270
|
throw new Error('The gateway is stopping and no longer accepts new work.');
|
|
1156
1271
|
}
|
|
1157
|
-
|
|
1272
|
+
const { turnId } = input;
|
|
1273
|
+
if (turnId === undefined) {
|
|
1274
|
+
return onSessionChain(input.sessionId, () => dispatchInternal(input));
|
|
1275
|
+
}
|
|
1276
|
+
return onSessionChain(input.sessionId, async () => {
|
|
1277
|
+
activeTurns.set(input.sessionId, turnId);
|
|
1278
|
+
try {
|
|
1279
|
+
return await dispatchInternal(input);
|
|
1280
|
+
}
|
|
1281
|
+
finally {
|
|
1282
|
+
// Unconditional, and safe to be: the chain guarantees no other turn
|
|
1283
|
+
// for this session ran between the set above and here, so this can
|
|
1284
|
+
// only ever be clearing its own entry.
|
|
1285
|
+
activeTurns.delete(input.sessionId);
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
};
|
|
1289
|
+
/**
|
|
1290
|
+
* Load what the config asked for, and say what did not load.
|
|
1291
|
+
*
|
|
1292
|
+
* A plugin that fails is a warning rather than a refusal to start: a
|
|
1293
|
+
* daemon that will not boot because of a mistyped package name takes the
|
|
1294
|
+
* whole fleet down over one line of config, and the agents whose tools
|
|
1295
|
+
* did load are still worth serving. What is never degraded is the
|
|
1296
|
+
* security half — an undeclared name or a collision refuses that plugin
|
|
1297
|
+
* whole, inside the loader.
|
|
1298
|
+
*/
|
|
1299
|
+
const startPlugins = async () => {
|
|
1300
|
+
const configured = options.plugins ?? {};
|
|
1301
|
+
if (Object.keys(configured).length === 0) {
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
const result = await loadPlugins({
|
|
1305
|
+
config: configured,
|
|
1306
|
+
host: options.pluginHost ?? {
|
|
1307
|
+
resolve: (specifier) => import.meta.resolve(specifier),
|
|
1308
|
+
import: (specifier) => import(specifier),
|
|
1309
|
+
},
|
|
1310
|
+
tools,
|
|
1311
|
+
skills: skillCatalog,
|
|
1312
|
+
bus,
|
|
1313
|
+
workspaceRoot: workspacesDirPath(env),
|
|
1314
|
+
});
|
|
1315
|
+
loadedPlugins = result.loaded;
|
|
1316
|
+
pluginFailures = result.failures;
|
|
1317
|
+
for (const plugin of result.loaded) {
|
|
1318
|
+
for (const tool of plugin.tools) {
|
|
1319
|
+
toolProvenance.set(tool.name, { package: plugin.package, trusted: plugin.trusted });
|
|
1320
|
+
}
|
|
1321
|
+
const contributed = [
|
|
1322
|
+
...plugin.tools.map((tool) => `${tool.name} (${tool.risk})`),
|
|
1323
|
+
...plugin.skills.map((skill) => `skill ${skill.id}`),
|
|
1324
|
+
];
|
|
1325
|
+
log(`plugin ${plugin.package} loaded — ${contributed.join(', ') || 'no tools'}`);
|
|
1326
|
+
}
|
|
1327
|
+
for (const failure of result.failures) {
|
|
1328
|
+
warn(`plugin ${failure.package} did not load: ${failure.reason}`);
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
/**
|
|
1332
|
+
* Load the operator's `~/.stratus/skills/` into the catalog. Before the
|
|
1333
|
+
* plugins, deliberately: an operator's bare id outranks a plugin's bare
|
|
1334
|
+
* alias for the same name, and the plugin's skill stays reachable by its
|
|
1335
|
+
* qualified `<package>:<skill>` form.
|
|
1336
|
+
*/
|
|
1337
|
+
const startSkills = async () => {
|
|
1338
|
+
operatorSkills = await loadOperatorSkills(env, skillCatalog, warn);
|
|
1339
|
+
for (const skill of operatorSkills) {
|
|
1340
|
+
log(`skill ${skill.id} loaded from ${skill.path}`);
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
/**
|
|
1344
|
+
* Everything after the plugins: the roster, the channels, and the sweeps
|
|
1345
|
+
* that need both. Split out so `start()` can wrap it in the cleanup a
|
|
1346
|
+
* failure here requires — see the call site.
|
|
1347
|
+
*/
|
|
1348
|
+
const startServing = async () => {
|
|
1349
|
+
await loadRoster();
|
|
1350
|
+
const named = registry.list().map((agent) => agent.name).join(', ');
|
|
1351
|
+
log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
|
|
1352
|
+
// Read before anything can dispatch, so this is exactly what the
|
|
1353
|
+
// last process left running and not a turn of ours caught in flight.
|
|
1354
|
+
// Applied further down, once there is somewhere for the failure to
|
|
1355
|
+
// be heard.
|
|
1356
|
+
const abandoned = await listAbandonedTurns();
|
|
1357
|
+
// Channels come up after the roster so their first inbound message
|
|
1358
|
+
// already has agents to dispatch to. One failing adapter must not
|
|
1359
|
+
// keep the rest (or the gateway) down.
|
|
1360
|
+
for (const adapter of options.channels ?? []) {
|
|
1361
|
+
try {
|
|
1362
|
+
await adapter.start(gateway);
|
|
1363
|
+
startedChannels.push(adapter);
|
|
1364
|
+
}
|
|
1365
|
+
catch (error) {
|
|
1366
|
+
warn(`channel ${adapter.name} failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
1367
|
+
// A start() that rejected may still hold sockets or listeners it
|
|
1368
|
+
// acquired before failing; it never reaches startedChannels, so
|
|
1369
|
+
// this is its only cleanup.
|
|
1370
|
+
try {
|
|
1371
|
+
await adapter.stop();
|
|
1372
|
+
}
|
|
1373
|
+
catch (stopError) {
|
|
1374
|
+
warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
// Last, and not awaited: recovery re-asks, so it needs the channels
|
|
1379
|
+
// above already listening — but a turn parked behind a slow approver
|
|
1380
|
+
// must not hold up start(), or a daemon with one outstanding request
|
|
1381
|
+
// would refuse to finish booting.
|
|
1382
|
+
void recoverParkedTurns().catch((error) => {
|
|
1383
|
+
warn(`recovering parked turns failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1384
|
+
});
|
|
1385
|
+
// Not awaited for the same reason, and independent of the sweep
|
|
1386
|
+
// above: these sessions are not parked, so the two never touch the
|
|
1387
|
+
// same one.
|
|
1388
|
+
void failAbandonedTurns(abandoned).catch((error) => {
|
|
1389
|
+
warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1390
|
+
});
|
|
1391
|
+
};
|
|
1392
|
+
/** Release what the plugins acquired, reporting rather than throwing. */
|
|
1393
|
+
const disposePlugins = async () => {
|
|
1394
|
+
await Promise.allSettled(loadedPlugins.map(async (plugin) => {
|
|
1395
|
+
try {
|
|
1396
|
+
await plugin.instance.dispose?.();
|
|
1397
|
+
}
|
|
1398
|
+
catch (error) {
|
|
1399
|
+
warn(`plugin ${plugin.package} failed to shut down: ${error instanceof Error ? error.message : String(error)}`);
|
|
1400
|
+
}
|
|
1401
|
+
}));
|
|
1402
|
+
loadedPlugins = [];
|
|
1158
1403
|
};
|
|
1159
1404
|
const startedChannels = [];
|
|
1160
1405
|
const gateway = {
|
|
@@ -1162,48 +1407,26 @@ export const createGateway = (options = {}) => {
|
|
|
1162
1407
|
store,
|
|
1163
1408
|
async start() {
|
|
1164
1409
|
await migrateLegacyMemory(env);
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
// acquired before failing; it never reaches startedChannels, so
|
|
1185
|
-
// this is its only cleanup.
|
|
1186
|
-
try {
|
|
1187
|
-
await adapter.stop();
|
|
1188
|
-
}
|
|
1189
|
-
catch (stopError) {
|
|
1190
|
-
warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1410
|
+
// Before the roster and before channels: a turn must never arrive for
|
|
1411
|
+
// an agent whose soul lists a tool the daemon has not registered yet,
|
|
1412
|
+
// which would refuse the call as "not permitted" and blame the soul.
|
|
1413
|
+
// Skills first for the same reason — and before the plugins, so the
|
|
1414
|
+
// operator's bare ids win their aliases (see startSkills).
|
|
1415
|
+
await startSkills();
|
|
1416
|
+
await startPlugins();
|
|
1417
|
+
try {
|
|
1418
|
+
await startServing();
|
|
1419
|
+
}
|
|
1420
|
+
catch (error) {
|
|
1421
|
+
// Everything after the plugins loaded is inside that try for one
|
|
1422
|
+
// reason: a `start()` that rejects never reaches its caller's
|
|
1423
|
+
// shutdown path — `stratus serve` awaits it *before* the try/finally
|
|
1424
|
+
// that calls `stop()` — so a duplicate agent id in the roster would
|
|
1425
|
+
// leave a plugin's browser, socket, or subscription held for the
|
|
1426
|
+
// life of a process that is on its way out.
|
|
1427
|
+
await disposePlugins();
|
|
1428
|
+
throw error;
|
|
1193
1429
|
}
|
|
1194
|
-
// Last, and not awaited: recovery re-asks, so it needs the channels
|
|
1195
|
-
// above already listening — but a turn parked behind a slow approver
|
|
1196
|
-
// must not hold up start(), or a daemon with one outstanding request
|
|
1197
|
-
// would refuse to finish booting.
|
|
1198
|
-
void recoverParkedTurns().catch((error) => {
|
|
1199
|
-
warn(`recovering parked turns failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1200
|
-
});
|
|
1201
|
-
// Not awaited for the same reason, and independent of the sweep
|
|
1202
|
-
// above: these sessions are not parked, so the two never touch the
|
|
1203
|
-
// same one.
|
|
1204
|
-
void failAbandonedTurns(abandoned).catch((error) => {
|
|
1205
|
-
warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1206
|
-
});
|
|
1207
1430
|
},
|
|
1208
1431
|
// Drain: channels stop taking messages, in-flight turns finish, new
|
|
1209
1432
|
// dispatches are refused, then the database closes. SIGTERM handling
|
|
@@ -1217,8 +1440,8 @@ export const createGateway = (options = {}) => {
|
|
|
1217
1440
|
// outstanding approval timeout — a shutdown would hang for as long as
|
|
1218
1441
|
// the longest request had left. Each denial is a real decision the
|
|
1219
1442
|
// turn continues from, so the drain below still finishes those turns.
|
|
1220
|
-
for (const
|
|
1221
|
-
settle('deny', 'cancelled');
|
|
1443
|
+
for (const parked of [...pendingApprovals.values()]) {
|
|
1444
|
+
parked.settle('deny', 'cancelled');
|
|
1222
1445
|
}
|
|
1223
1446
|
// Let those resolutions reach their subscribers before the channels
|
|
1224
1447
|
// go down, or a channel that renders approvals never learns to
|
|
@@ -1227,13 +1450,137 @@ export const createGateway = (options = {}) => {
|
|
|
1227
1450
|
await Promise.allSettled(startedChannels.map((adapter) => adapter.stop()));
|
|
1228
1451
|
startedChannels.length = 0;
|
|
1229
1452
|
await Promise.allSettled([...inflight]);
|
|
1453
|
+
// After the turns that might still be using them. A plugin holding a
|
|
1454
|
+
// browser is the reason this exists, and closing it out from under a
|
|
1455
|
+
// running screenshot would fail that turn rather than tidy up.
|
|
1456
|
+
await disposePlugins();
|
|
1230
1457
|
store.close();
|
|
1231
1458
|
log('stratusd stopped');
|
|
1232
1459
|
},
|
|
1233
1460
|
dispatch,
|
|
1461
|
+
async sessionRouting(sessionId) {
|
|
1462
|
+
const session = await store.get(sessionId);
|
|
1463
|
+
return session
|
|
1464
|
+
? { agentId: session.agent.id, metadata: session.metadata ?? {} }
|
|
1465
|
+
: undefined;
|
|
1466
|
+
},
|
|
1234
1467
|
agents() {
|
|
1235
1468
|
return registry.list();
|
|
1236
1469
|
},
|
|
1470
|
+
async reloadRoster() {
|
|
1471
|
+
await reloadsInOrder();
|
|
1472
|
+
const roster = registry.list();
|
|
1473
|
+
log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
|
|
1474
|
+
return roster;
|
|
1475
|
+
},
|
|
1476
|
+
activeTurnId(sessionId) {
|
|
1477
|
+
return activeTurns.get(sessionId);
|
|
1478
|
+
},
|
|
1479
|
+
pendingApprovals() {
|
|
1480
|
+
return [...pendingApprovals.values()]
|
|
1481
|
+
.map((parked) => parked.pending)
|
|
1482
|
+
.sort((a, b) => a.parkedAt.localeCompare(b.parkedAt));
|
|
1483
|
+
},
|
|
1484
|
+
tools() {
|
|
1485
|
+
// From the registry, not from the load records: the risk reported is
|
|
1486
|
+
// the one a call will actually be judged at, floor applied, and a
|
|
1487
|
+
// second list assembled from manifests would drift from it the first
|
|
1488
|
+
// time a floor was raised.
|
|
1489
|
+
return tools.describe().map((descriptor) => {
|
|
1490
|
+
const provenance = toolProvenance.get(descriptor.name);
|
|
1491
|
+
return {
|
|
1492
|
+
name: descriptor.name,
|
|
1493
|
+
...(descriptor.description ? { description: descriptor.description } : {}),
|
|
1494
|
+
risk: descriptor.risk ?? 'gated',
|
|
1495
|
+
...(provenance ? { package: provenance.package, trusted: provenance.trusted } : {}),
|
|
1496
|
+
};
|
|
1497
|
+
});
|
|
1498
|
+
},
|
|
1499
|
+
skills() {
|
|
1500
|
+
// From the registry, not from the load records: the registry is the
|
|
1501
|
+
// live answer for which bare aliases still resolve — a plugin loaded
|
|
1502
|
+
// later can have retired one a record still remembers.
|
|
1503
|
+
const provenance = new Map();
|
|
1504
|
+
for (const info of operatorSkills) {
|
|
1505
|
+
provenance.set(info.id, { path: info.path });
|
|
1506
|
+
}
|
|
1507
|
+
for (const plugin of loadedPlugins) {
|
|
1508
|
+
for (const skill of plugin.skills) {
|
|
1509
|
+
provenance.set(skill.id, { package: plugin.package, path: skill.path });
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return skillCatalog.list().map((skill) => {
|
|
1513
|
+
const alias = skillCatalog.idsFor(skill.id).find((id) => id !== skill.id);
|
|
1514
|
+
const from = provenance.get(skill.id);
|
|
1515
|
+
return {
|
|
1516
|
+
id: skill.id,
|
|
1517
|
+
name: skill.name,
|
|
1518
|
+
description: skill.description,
|
|
1519
|
+
...(alias !== undefined ? { alias } : {}),
|
|
1520
|
+
...(from?.package !== undefined ? { package: from.package } : {}),
|
|
1521
|
+
...(from !== undefined ? { path: from.path } : {}),
|
|
1522
|
+
};
|
|
1523
|
+
});
|
|
1524
|
+
},
|
|
1525
|
+
plugins() {
|
|
1526
|
+
const loaded = loadedPlugins.map((plugin) => ({
|
|
1527
|
+
package: plugin.package,
|
|
1528
|
+
name: plugin.name,
|
|
1529
|
+
trusted: plugin.trusted,
|
|
1530
|
+
tools: plugin.tools.map((tool) => ({
|
|
1531
|
+
name: tool.name,
|
|
1532
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
1533
|
+
risk: tool.risk,
|
|
1534
|
+
package: plugin.package,
|
|
1535
|
+
trusted: plugin.trusted,
|
|
1536
|
+
})),
|
|
1537
|
+
// The alias comes from the live registry, exactly as `skills()`
|
|
1538
|
+
// derives it: a plugin loaded later can have retired it, and this
|
|
1539
|
+
// listing must not advertise a bare id `skill.read` would refuse.
|
|
1540
|
+
skills: plugin.skills.map((skill) => {
|
|
1541
|
+
const alias = skillCatalog.idsFor(skill.id).find((id) => id !== skill.id);
|
|
1542
|
+
return {
|
|
1543
|
+
id: skill.id,
|
|
1544
|
+
name: skill.name,
|
|
1545
|
+
description: skill.description,
|
|
1546
|
+
...(alias !== undefined ? { alias } : {}),
|
|
1547
|
+
package: plugin.package,
|
|
1548
|
+
path: skill.path,
|
|
1549
|
+
};
|
|
1550
|
+
}),
|
|
1551
|
+
}));
|
|
1552
|
+
const failed = pluginFailures.map((failure) => ({
|
|
1553
|
+
package: failure.package,
|
|
1554
|
+
error: failure.reason,
|
|
1555
|
+
}));
|
|
1556
|
+
return [...loaded, ...failed];
|
|
1557
|
+
},
|
|
1558
|
+
async servedSouls() {
|
|
1559
|
+
// From the registry rather than `sources` directly, so the order and
|
|
1560
|
+
// the membership are the roster's own — one entry per agent `agents()`
|
|
1561
|
+
// reports, and no entry for a source the registry no longer serves.
|
|
1562
|
+
return Promise.all(registry.list().map(async (agent) => {
|
|
1563
|
+
const source = sources.get(agent.id);
|
|
1564
|
+
// Re-read, not re-registered: this is a read, and a caller asking
|
|
1565
|
+
// what the daemon would bill must not reshape the roster as a side
|
|
1566
|
+
// effect. `refreshAgent` still owns that on the dispatch path.
|
|
1567
|
+
const fresh = source?.soulPath
|
|
1568
|
+
? await loadSoulFile(source.soulPath).catch(() => undefined)
|
|
1569
|
+
: undefined;
|
|
1570
|
+
if (fresh && fresh.agent.id !== agent.id) {
|
|
1571
|
+
// The file was given to another agent. `refreshAgent` refuses every
|
|
1572
|
+
// dispatch for this id until the roster reloads, so there is no
|
|
1573
|
+
// runtime being served under it — reporting the cached pins would
|
|
1574
|
+
// claim billing for turns that cannot run, and reporting no soul at
|
|
1575
|
+
// all would claim the daemon-wide default instead. It is omitted.
|
|
1576
|
+
return undefined;
|
|
1577
|
+
}
|
|
1578
|
+
// Unreadable is the one case the cache answers for: the gateway goes
|
|
1579
|
+
// on dispatching from the soul it loaded, so those pins are live.
|
|
1580
|
+
const soul = fresh ?? source?.soul;
|
|
1581
|
+
return { id: agent.id, ...(soul ? { soul } : {}) };
|
|
1582
|
+
})).then((entries) => entries.filter((entry) => entry !== undefined));
|
|
1583
|
+
},
|
|
1237
1584
|
resolveApproval,
|
|
1238
1585
|
};
|
|
1239
1586
|
return gateway;
|