@stratusagent/gateway 0.11.3 → 0.11.5
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 +5 -1
- package/dist/index.d.ts +80 -82
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +302 -208
- package/dist/index.js.map +1 -1
- package/dist/sessions.d.ts +211 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +592 -0
- package/dist/sessions.js.map +1 -0
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -7,25 +7,16 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
13
|
-
import { filePathsOf, isUnaddressedTurn, abortErrorFor, AgentRegistry, AgentRunner, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, } from '@stratusagent/core';
|
|
14
|
-
import { createDelegateTool, createForgetTool, createMessageSendTool, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, } from '@stratusagent/agents';
|
|
10
|
+
import { filePathsOf, isUnaddressedTurn, abortErrorFor, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, } from '@stratusagent/core';
|
|
11
|
+
import { createDelegateTool, createForgetTool, createPinTool, createMessageSendTool, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, } from '@stratusagent/agents';
|
|
15
12
|
import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
|
|
13
|
+
import { ShardedSessionStore } from "./sessions.js";
|
|
16
14
|
export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
|
|
15
|
+
export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTakenError, tightenSqliteFile, } from "./sessions.js";
|
|
17
16
|
export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
|
|
18
17
|
import { createLocalCommandExecutor } from '@stratusagent/executor-local';
|
|
19
18
|
import { loadPlugins, } from '@stratusagent/plugins';
|
|
20
|
-
import { createDemoTool, createFileCredentialResolver,
|
|
21
|
-
const SESSIONS_DB_FILENAME = 'sessions.db';
|
|
22
|
-
/**
|
|
23
|
-
* Where a daemon with no explicit `sessionDbPath` keeps its database — the
|
|
24
|
-
* one rule for it, exported because `stratus schedules` opens the same file
|
|
25
|
-
* from another process and a re-derived copy of this join is how the CLI
|
|
26
|
-
* ends up auditing a database no daemon writes.
|
|
27
|
-
*/
|
|
28
|
-
export const defaultSessionDbPath = (env = {}) => path.join(stratusHomePath(env), SESSIONS_DB_FILENAME);
|
|
19
|
+
import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, applySoulPins, assertStateCompatible, stratusHomePath, fleetDbIn, createAgentWorkspaces, foldedAgentId, } from '@stratusagent/state';
|
|
29
20
|
const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
|
30
21
|
/**
|
|
31
22
|
* How long a parked call waits for a person. Long enough to survive a
|
|
@@ -39,179 +30,6 @@ const DEFAULT_APPROVAL_TIMEOUT_MS = 900_000;
|
|
|
39
30
|
* 30-day approval window would expire every request almost immediately.
|
|
40
31
|
*/
|
|
41
32
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
42
|
-
export class SqliteSessionStore {
|
|
43
|
-
db;
|
|
44
|
-
constructor(filePath, options = {}) {
|
|
45
|
-
// Sessions hold complete conversations (prompts, replies, tool output,
|
|
46
|
-
// provider replay state) — owner-only, like the credentials file. The
|
|
47
|
-
// file chmods below cover databases created earlier under a looser
|
|
48
|
-
// umask too; directories the store creates are born 0700.
|
|
49
|
-
const dir = path.dirname(filePath);
|
|
50
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
51
|
-
if (options.ownedDirectory) {
|
|
52
|
-
chmodSync(dir, 0o700);
|
|
53
|
-
}
|
|
54
|
-
this.db = new DatabaseSync(filePath);
|
|
55
|
-
// The database file is tightened FIRST: SQLite derives sidecar
|
|
56
|
-
// permissions from the main file's mode, so everything created later
|
|
57
|
-
// inherits owner-only.
|
|
58
|
-
try {
|
|
59
|
-
chmodSync(filePath, 0o600);
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
// A database that cannot be tightened must not be used: conversation
|
|
63
|
-
// bodies would stay readable by other local users for the daemon's
|
|
64
|
-
// whole lifetime, silently.
|
|
65
|
-
if (error.code !== 'ENOENT') {
|
|
66
|
-
throw error;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
// WAL, not the default DELETE journal: DELETE mode recreates a
|
|
70
|
-
// rollback journal on EVERY write, which a one-time chmod could never
|
|
71
|
-
// cover in a traversable caller-supplied directory. WAL keeps one
|
|
72
|
-
// persistent sidecar pair per connection — forced into existence here
|
|
73
|
-
// (the user_version pragma is a real page-one write) so the loop
|
|
74
|
-
// below covers them for the connection's whole lifetime.
|
|
75
|
-
this.db.exec('PRAGMA journal_mode = WAL');
|
|
76
|
-
// A busy timeout, because this file has a second writer: `stratus
|
|
77
|
-
// schedules cancel` opens its own connection to the schedules table
|
|
78
|
-
// living here, and WAL serializes writers file-wide — a session save
|
|
79
|
-
// racing that delete would otherwise throw `database is locked` rather
|
|
80
|
-
// than wait its brief turn.
|
|
81
|
-
this.db.exec('PRAGMA busy_timeout = 5000');
|
|
82
|
-
this.db.exec(`
|
|
83
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
84
|
-
id TEXT PRIMARY KEY,
|
|
85
|
-
agent_id TEXT NOT NULL,
|
|
86
|
-
status TEXT NOT NULL,
|
|
87
|
-
body TEXT NOT NULL,
|
|
88
|
-
created_at TEXT NOT NULL,
|
|
89
|
-
updated_at TEXT NOT NULL
|
|
90
|
-
)
|
|
91
|
-
`);
|
|
92
|
-
this.db.exec('PRAGMA user_version = 0');
|
|
93
|
-
for (const sensitive of [filePath, `${filePath}-wal`, `${filePath}-shm`, `${filePath}-journal`]) {
|
|
94
|
-
try {
|
|
95
|
-
chmodSync(sensitive, 0o600);
|
|
96
|
-
}
|
|
97
|
-
catch (error) {
|
|
98
|
-
// A sidecar that does not exist has nothing to tighten; anything
|
|
99
|
-
// else means session data stays readable — refuse to run over it.
|
|
100
|
-
if (error.code !== 'ENOENT') {
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
async create(input) {
|
|
107
|
-
const now = new Date().toISOString();
|
|
108
|
-
const session = { ...input, createdAt: now, updatedAt: now };
|
|
109
|
-
this.db
|
|
110
|
-
.prepare('INSERT OR REPLACE INTO sessions (id, agent_id, status, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
111
|
-
.run(session.id, session.agent.id, session.status, JSON.stringify(session), session.createdAt, session.updatedAt);
|
|
112
|
-
return session;
|
|
113
|
-
}
|
|
114
|
-
async get(id) {
|
|
115
|
-
const row = this.db.prepare('SELECT body FROM sessions WHERE id = ?').get(id);
|
|
116
|
-
return row ? JSON.parse(row.body) : undefined;
|
|
117
|
-
}
|
|
118
|
-
async save(session) {
|
|
119
|
-
const updated = { ...session, updatedAt: new Date().toISOString() };
|
|
120
|
-
this.db
|
|
121
|
-
.prepare('INSERT OR REPLACE INTO sessions (id, agent_id, status, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
122
|
-
.run(updated.id, updated.agent.id, updated.status, JSON.stringify(updated), updated.createdAt, updated.updatedAt);
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Session ids in a state, oldest first — the index a restarting daemon
|
|
126
|
-
* sweeps for turns parked on a human. The `status` column carries it, so
|
|
127
|
-
* no conversation body is deserialized to answer the question.
|
|
128
|
-
*/
|
|
129
|
-
async listIdsByStatus(status) {
|
|
130
|
-
const rows = this.db
|
|
131
|
-
.prepare('SELECT id FROM sessions WHERE status = ? ORDER BY updated_at ASC')
|
|
132
|
-
.all(status);
|
|
133
|
-
return rows.map((row) => row.id);
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* When each agent last did anything, and how many of its sessions are live
|
|
137
|
-
* right now.
|
|
138
|
-
*
|
|
139
|
-
* One aggregate over the indexed columns — no conversation body is
|
|
140
|
-
* deserialized, for the same reason `listIdsByStatus` returns ids: a
|
|
141
|
-
* roster view asks this on every load, and it must not cost a JSON parse
|
|
142
|
-
* per session to answer.
|
|
143
|
-
*
|
|
144
|
-
* Both halves are needed and neither is sufficient. `lastActiveAt` alone
|
|
145
|
-
* reads a turn parked on a human as idle, because the save that recorded
|
|
146
|
-
* the park is the last thing that touched the row — and a turn waiting
|
|
147
|
-
* twenty minutes on an approval is exactly when someone wants to see the
|
|
148
|
-
* agent lit. `activeSessions` alone loses an agent that finished a moment
|
|
149
|
-
* ago. The caller decides what window counts as "recent"; a daemon that
|
|
150
|
-
* baked one in would need upgrading to change it.
|
|
151
|
-
*/
|
|
152
|
-
/**
|
|
153
|
-
* How many sessions are in each state.
|
|
154
|
-
*
|
|
155
|
-
* A grouped count, not a listing that gets counted. The table grows for the
|
|
156
|
-
* life of an install and a health endpoint is polled, so materialising one
|
|
157
|
-
* object per historical session to produce five numbers gets steadily
|
|
158
|
-
* slower at exactly the thing meant to report that the daemon is fine.
|
|
159
|
-
*/
|
|
160
|
-
countByStatus() {
|
|
161
|
-
const rows = this.db
|
|
162
|
-
.prepare('SELECT status, COUNT(*) AS total FROM sessions GROUP BY status')
|
|
163
|
-
.all();
|
|
164
|
-
const counts = {};
|
|
165
|
-
for (const row of rows) {
|
|
166
|
-
counts[row.status] = Number(row.total);
|
|
167
|
-
}
|
|
168
|
-
return counts;
|
|
169
|
-
}
|
|
170
|
-
lastActivityByAgent() {
|
|
171
|
-
const rows = this.db
|
|
172
|
-
.prepare(`
|
|
173
|
-
SELECT agent_id,
|
|
174
|
-
MAX(updated_at) AS last_active_at,
|
|
175
|
-
SUM(CASE WHEN status IN ('running', 'pending_approval') THEN 1 ELSE 0 END) AS active_sessions
|
|
176
|
-
FROM sessions
|
|
177
|
-
GROUP BY agent_id
|
|
178
|
-
`)
|
|
179
|
-
.all();
|
|
180
|
-
const activity = {};
|
|
181
|
-
for (const row of rows) {
|
|
182
|
-
activity[row.agent_id] = {
|
|
183
|
-
lastActiveAt: row.last_active_at,
|
|
184
|
-
activeSessions: Number(row.active_sessions),
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
return activity;
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Newest-first session listing for one agent (or all agents).
|
|
191
|
-
*
|
|
192
|
-
* `limit` is not decoration: this table grows for the life of an install,
|
|
193
|
-
* and a surface that renders "recent conversations" would otherwise pull
|
|
194
|
-
* every session anyone has ever had to show ten of them.
|
|
195
|
-
*/
|
|
196
|
-
list(agentId, limit) {
|
|
197
|
-
// -1 is SQLite's "no limit", so one prepared statement serves both cases
|
|
198
|
-
// rather than four.
|
|
199
|
-
const bound = limit !== undefined && Number.isInteger(limit) && limit >= 0 ? limit : -1;
|
|
200
|
-
const rows = (agentId
|
|
201
|
-
? 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)
|
|
202
|
-
: this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?').all(bound));
|
|
203
|
-
return rows.map((row) => ({
|
|
204
|
-
id: row.id,
|
|
205
|
-
agentId: row.agent_id,
|
|
206
|
-
status: row.status,
|
|
207
|
-
createdAt: row.created_at,
|
|
208
|
-
updatedAt: row.updated_at,
|
|
209
|
-
}));
|
|
210
|
-
}
|
|
211
|
-
close() {
|
|
212
|
-
this.db.close();
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
33
|
/**
|
|
216
34
|
* A soul's provider/model pins, and the daemon-wide defaults they demote.
|
|
217
35
|
*
|
|
@@ -288,6 +106,8 @@ export const ROLLED_OVER_FROM_METADATA_KEY = 'rolledOverFrom';
|
|
|
288
106
|
export const ROLLED_OVER_TO_METADATA_KEY = 'rolledOverTo';
|
|
289
107
|
/** The segment a rollover mints into the archived transcript's id. */
|
|
290
108
|
export const ROLLED_OVER_SESSION_ID_MARKER = ':rolledover:';
|
|
109
|
+
/** Where the session's tool calls ran: `local-command`, or a contributed executor's registered name. */
|
|
110
|
+
export const EXECUTOR_METADATA_KEY = 'executor';
|
|
291
111
|
export const RESERVED_SESSION_METADATA_KEYS = [
|
|
292
112
|
PENDING_APPROVAL_METADATA_KEY,
|
|
293
113
|
FALLBACK_ACTIVE_METADATA_KEY,
|
|
@@ -302,6 +122,17 @@ export const RESERVED_SESSION_METADATA_KEYS = [
|
|
|
302
122
|
SESSION_TAINTED_BY_METADATA_KEY,
|
|
303
123
|
ROLLED_OVER_FROM_METADATA_KEY,
|
|
304
124
|
ROLLED_OVER_TO_METADATA_KEY,
|
|
125
|
+
// Which executor ran the turn's tool calls is the daemon's record of
|
|
126
|
+
// where a command ran, and the one audit question a caller must not be
|
|
127
|
+
// able to answer for it.
|
|
128
|
+
EXECUTOR_METADATA_KEY,
|
|
129
|
+
// The context floor is an index into a specific transcript, so it means
|
|
130
|
+
// nothing next to a different one. Reserved for both halves of that: a
|
|
131
|
+
// caller cannot seed it, and a rollover drops it with the transcript it
|
|
132
|
+
// indexed — otherwise the fresh session inherits an absolute floor over
|
|
133
|
+
// no messages and, once it grows past that index, silently stops sending
|
|
134
|
+
// its own earliest turns, with no overflow and no trim event to say so.
|
|
135
|
+
CONTEXT_FLOOR_METADATA_KEY,
|
|
305
136
|
];
|
|
306
137
|
/**
|
|
307
138
|
* The first reserved key `metadata` carries, or undefined when it carries
|
|
@@ -326,18 +157,39 @@ export const createGateway = (options = {}) => {
|
|
|
326
157
|
const bus = new EventBus({
|
|
327
158
|
onError: (error) => warn(`event handler failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
328
159
|
});
|
|
329
|
-
// The default
|
|
330
|
-
// tightened to owner-only; a caller-supplied
|
|
160
|
+
// The default state directory is the gateway-owned ~/.stratus, which is
|
|
161
|
+
// tightened to owner-only; a caller-supplied one may sit in a shared
|
|
331
162
|
// directory that must not be chmodded from under other processes.
|
|
332
|
-
const
|
|
333
|
-
const store =
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
163
|
+
const stateDir = options.stateDir ?? stratusHomePath(env);
|
|
164
|
+
const store = new ShardedSessionStore({
|
|
165
|
+
stateDir,
|
|
166
|
+
...(options.stateDir ? {} : { ownedDirectory: true }),
|
|
167
|
+
});
|
|
168
|
+
// The built-in store, behind a route that can change its mind: the memory
|
|
169
|
+
// tools and every runner are built before a plugin has loaded, so the
|
|
170
|
+
// store they hold is the one that answers *per call* with whichever was
|
|
171
|
+
// selected once the plugins are up. Per call, not per start, is also
|
|
172
|
+
// what keeps a contributed store per-agent-resolvable rather than
|
|
173
|
+
// process-global.
|
|
174
|
+
const fileMemory = createHomeMemoryStore(env);
|
|
175
|
+
let selectedMemory = fileMemory;
|
|
176
|
+
const memory = createRoutedMemoryStore(() => selectedMemory);
|
|
177
|
+
let selectedExecutor;
|
|
178
|
+
// What loaded plugins contributed besides tools and skills. The loader
|
|
179
|
+
// commits into these; the gateway reads them by name — a provider when a
|
|
180
|
+
// runner is built, the executor and memory store once at start, the
|
|
181
|
+
// channels when serving begins.
|
|
182
|
+
const providerContributions = new ContributionRegistry();
|
|
183
|
+
const channelContributions = new ChannelRegistry();
|
|
184
|
+
for (const claim of options.hostChannelClaims ?? []) {
|
|
185
|
+
channelContributions.claim(claim.kind, claim.agents);
|
|
186
|
+
}
|
|
187
|
+
const memoryContributions = new ContributionRegistry();
|
|
188
|
+
const executorContributions = new ContributionRegistry();
|
|
337
189
|
// ---- schedules ----------------------------------------------------------
|
|
338
190
|
// The same database sessions live in — one file to back up — through the
|
|
339
191
|
// schedule store's own connection (see its docs for why).
|
|
340
|
-
const scheduleStore = new SqliteScheduleStore(
|
|
192
|
+
const scheduleStore = new SqliteScheduleStore(fleetDbIn(stateDir));
|
|
341
193
|
/**
|
|
342
194
|
* The write side of an addressable destination, through whichever
|
|
343
195
|
* started channel serves its kind. Resolution happens per call against
|
|
@@ -346,7 +198,31 @@ export const createGateway = (options = {}) => {
|
|
|
346
198
|
* both callers (schedule creation, `message.send`) hear why.
|
|
347
199
|
*/
|
|
348
200
|
const outboundFor = async (agentId, destination) => {
|
|
349
|
-
const
|
|
201
|
+
const candidates = startedChannels.filter((candidate) => candidate.name === destination.channel && candidate.resolveOutbound);
|
|
202
|
+
// One kind may be served by several adapters carrying disjoint
|
|
203
|
+
// agents — two plugins, or the host's adapter beside a plugin's — and
|
|
204
|
+
// the one that speaks for this agent is the one whose claims include
|
|
205
|
+
// it: a contribution's agent list, or the host's declared claims for
|
|
206
|
+
// an adapter no plugin contributed. A host that declared no claims for
|
|
207
|
+
// the kind left its adapter unclaimed, and an unclaimed adapter speaks
|
|
208
|
+
// for anyone, as the single host adapter always did. A claim miss is
|
|
209
|
+
// a refusal, never the first adapter that happens to be running: that
|
|
210
|
+
// adapter posts under another agent's transport identity, and it is
|
|
211
|
+
// exactly what a failed start of the right adapter would leave behind.
|
|
212
|
+
const contributed = channelContributions.list();
|
|
213
|
+
const hostClaims = (options.hostChannelClaims ?? []).filter((claim) => claim.kind === destination.channel);
|
|
214
|
+
const carries = (candidate) => {
|
|
215
|
+
const entry = contributed.find((contribution) => contribution.adapter === candidate);
|
|
216
|
+
if (entry) {
|
|
217
|
+
return entry.agents.includes(agentId);
|
|
218
|
+
}
|
|
219
|
+
return hostClaims.length === 0 || hostClaims.some((claim) => claim.agents.includes(agentId));
|
|
220
|
+
};
|
|
221
|
+
const adapter = candidates.find(carries);
|
|
222
|
+
if (!adapter?.resolveOutbound && candidates.length > 0) {
|
|
223
|
+
throw new Error(`No running '${destination.channel}' channel carries agent ${agentId} — `
|
|
224
|
+
+ `the running ${destination.channel} adapters carry other agents, so a message from ${agentId} has no transport identity to post under.`);
|
|
225
|
+
}
|
|
350
226
|
if (!adapter?.resolveOutbound) {
|
|
351
227
|
throw new Error(`No running channel can deliver to '${destination.channel}' destinations — `
|
|
352
228
|
+ (startedChannels.length === 0
|
|
@@ -656,6 +532,24 @@ export const createGateway = (options = {}) => {
|
|
|
656
532
|
registry.register(source.definition);
|
|
657
533
|
sources.set(source.definition.id, source);
|
|
658
534
|
};
|
|
535
|
+
/**
|
|
536
|
+
* An id already registered that would open `agentId`'s state directory
|
|
537
|
+
* while being a different id — `ava` when asked about `Ava`, on the
|
|
538
|
+
* filesystems that fold them together.
|
|
539
|
+
*
|
|
540
|
+
* `foldedAgentId` owns which pairs those are. What this adds is the
|
|
541
|
+
* "already registered" half, for the one registration that does not come
|
|
542
|
+
* through `loadRosterSouls` and so was never folded against anything.
|
|
543
|
+
*/
|
|
544
|
+
const stateDirectoryPeer = (agentId) => {
|
|
545
|
+
const folded = foldedAgentId(agentId);
|
|
546
|
+
for (const other of sources.keys()) {
|
|
547
|
+
if (other !== agentId && foldedAgentId(other) === folded) {
|
|
548
|
+
return other;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return undefined;
|
|
552
|
+
};
|
|
659
553
|
/**
|
|
660
554
|
* The agent an agentId-less dispatch routes to: the configured default
|
|
661
555
|
* soul when one is set (what `stratus setup` writes to config.json),
|
|
@@ -666,9 +560,27 @@ export const createGateway = (options = {}) => {
|
|
|
666
560
|
* built-in definition.
|
|
667
561
|
*/
|
|
668
562
|
let lastDefaultAgentId;
|
|
563
|
+
/**
|
|
564
|
+
* What the last default resolution read, for the roster load to judge
|
|
565
|
+
* — the soul is re-read on every resolution, so a `tools:` key removed
|
|
566
|
+
* after startup is seen here, where the registered source would still
|
|
567
|
+
* carry the old definition until the next dispatch refreshes it.
|
|
568
|
+
*/
|
|
569
|
+
let lastDefaultResolved;
|
|
669
570
|
// The last successfully loaded config snapshot, serving dispatches while
|
|
670
571
|
// the file on disk is temporarily broken (an operator mid-edit).
|
|
671
572
|
let lastGoodConfigSnapshot;
|
|
573
|
+
/**
|
|
574
|
+
* Said at load, because the allowlist fails open: `tools` omitted is
|
|
575
|
+
* every registered tool, the opposite of what `skills` and `credentials`
|
|
576
|
+
* do when omitted, and a soul that never wrote the key holds `shell.run`
|
|
577
|
+
* the moment that plugin is enabled. The built-in agent is exempt — it
|
|
578
|
+
* has no file to add the key to.
|
|
579
|
+
*/
|
|
580
|
+
const warnNoToolsList = (agentId, soulPath) => {
|
|
581
|
+
warn(`agent ${agentId} has no tools: list, so it may call every tool this daemon loads — `
|
|
582
|
+
+ `add tools: [...] to ${soulPath} to say which`);
|
|
583
|
+
};
|
|
672
584
|
const defaultAgentId = async () => {
|
|
673
585
|
// Identity only — never full runtime resolution: credential checks do
|
|
674
586
|
// not belong here, or a daemon default provider without installed keys
|
|
@@ -691,9 +603,33 @@ export const createGateway = (options = {}) => {
|
|
|
691
603
|
// now the cached answer too, so a later transient read failure
|
|
692
604
|
// cannot resurrect the retired soul's identity.
|
|
693
605
|
lastDefaultAgentId = DEFAULT_STRATUS_AGENT.id;
|
|
606
|
+
lastDefaultResolved = undefined;
|
|
694
607
|
return DEFAULT_STRATUS_AGENT.id;
|
|
695
608
|
}
|
|
696
609
|
const id = resolved.soul.agent.id;
|
|
610
|
+
// The roster refuses two *souls* whose ids name one state directory,
|
|
611
|
+
// but this soul never went through it: a config-only default is
|
|
612
|
+
// resolved here, by path, and registered by exact id. So `Ava` in
|
|
613
|
+
// config beside `ava` on the roster — or `Stratus` beside the built-in
|
|
614
|
+
// — passed as two agents sharing one `agents/<id>/`, which is one
|
|
615
|
+
// `whitelist.json` and therefore one agent acting on the other's
|
|
616
|
+
// unattended grants.
|
|
617
|
+
//
|
|
618
|
+
// Warned and dropped rather than thrown: this runs on every
|
|
619
|
+
// agentId-less dispatch, and a configuration mistake must not turn
|
|
620
|
+
// every such turn into an error. Dropping routes to the built-in,
|
|
621
|
+
// which is the documented answer when there is no usable default, and
|
|
622
|
+
// leaves the colliding agent serving from its own directory alone.
|
|
623
|
+
const peer = stateDirectoryPeer(id);
|
|
624
|
+
if (peer !== undefined) {
|
|
625
|
+
warn(`the configured default soul at ${resolved.path} declares the agent id ${id}, which names the same `
|
|
626
|
+
+ `state directory as ${peer} — sessions, memories, and the grants that say what may run unattended. `
|
|
627
|
+
+ `Ignoring it and routing to ${DEFAULT_STRATUS_AGENT.id}; rename one of the two ids.`);
|
|
628
|
+
lastDefaultAgentId = DEFAULT_STRATUS_AGENT.id;
|
|
629
|
+
lastDefaultResolved = undefined;
|
|
630
|
+
return DEFAULT_STRATUS_AGENT.id;
|
|
631
|
+
}
|
|
632
|
+
lastDefaultResolved = resolved;
|
|
697
633
|
// The normal setup layout has the default soul in ~/.stratus/agents
|
|
698
634
|
// too: keep the roster registration — its soulPath drives per-dispatch
|
|
699
635
|
// refresh. A config-only soul registers with the path it resolved
|
|
@@ -739,6 +675,9 @@ export const createGateway = (options = {}) => {
|
|
|
739
675
|
// no correct winner, and refusing to serve beats letting sort order
|
|
740
676
|
// decide whose sessions, memory, and credentials an agent inherits.
|
|
741
677
|
const entries = await loadRosterSouls(env, warn);
|
|
678
|
+
// Which souls this pass named for having no tools: list, so the
|
|
679
|
+
// configured default is not named twice when it is one of them.
|
|
680
|
+
const namedThisPass = new Set();
|
|
742
681
|
for (const entry of entries) {
|
|
743
682
|
// Against ids claimed by THIS pass, not by any earlier one: a reload
|
|
744
683
|
// must be free to re-register the agent it just re-read, and only the
|
|
@@ -752,12 +691,28 @@ export const createGateway = (options = {}) => {
|
|
|
752
691
|
continue;
|
|
753
692
|
}
|
|
754
693
|
registerFresh({ definition: entry.soul.agent, soulPath: entry.path, soul: entry.soul });
|
|
694
|
+
if (entry.soul.agent.tools === undefined) {
|
|
695
|
+
warnNoToolsList(entry.soul.agent.id, entry.path);
|
|
696
|
+
namedThisPass.add(entry.path);
|
|
697
|
+
}
|
|
755
698
|
}
|
|
756
699
|
// The configured default soul is part of the roster too — it is what
|
|
757
700
|
// an agentId-less dispatch answers as. It may live outside the agents
|
|
758
701
|
// directory, and it survives a read failure by name, so what it
|
|
759
702
|
// resolves to is kept whether or not this pass re-registered it.
|
|
760
|
-
|
|
703
|
+
const defaultId = await defaultAgentId();
|
|
704
|
+
seen.add(defaultId);
|
|
705
|
+
// The same notice for a default soul that lives outside the agents
|
|
706
|
+
// directory — the one path into the roster that skips the loop above
|
|
707
|
+
// — on every load, judged on what the resolution just read rather
|
|
708
|
+
// than on the registered source, so a key removed after startup is
|
|
709
|
+
// reported at the next reload. A roster-backed default was named by
|
|
710
|
+
// the loop, and is not named twice.
|
|
711
|
+
if (lastDefaultResolved !== undefined
|
|
712
|
+
&& lastDefaultResolved.soul.agent.tools === undefined
|
|
713
|
+
&& !namedThisPass.has(lastDefaultResolved.path)) {
|
|
714
|
+
warnNoToolsList(defaultId, lastDefaultResolved.path);
|
|
715
|
+
}
|
|
761
716
|
for (const id of [...sources.keys()]) {
|
|
762
717
|
if (!seen.has(id)) {
|
|
763
718
|
sources.delete(id);
|
|
@@ -932,6 +887,7 @@ export const createGateway = (options = {}) => {
|
|
|
932
887
|
tools.register(createRememberTool(memory));
|
|
933
888
|
tools.register(createRecallTool(memory));
|
|
934
889
|
tools.register(createForgetTool(memory));
|
|
890
|
+
tools.register(createPinTool(memory));
|
|
935
891
|
// Registered here rather than left to the first runner, so `tools()`
|
|
936
892
|
// lists the reader before anything dispatches. The allowlist resolver
|
|
937
893
|
// mirrors the runner's own sourcing — the definition travelling with the
|
|
@@ -995,11 +951,14 @@ export const createGateway = (options = {}) => {
|
|
|
995
951
|
// The sticky-fallback switch is durable the moment it happens, not
|
|
996
952
|
// when the turn's next save lands — a daemon killed mid-fallback
|
|
997
953
|
// must not retry the primary on restart.
|
|
998
|
-
(session) => store.save(session));
|
|
954
|
+
(session) => store.save(session), providerContributions);
|
|
999
955
|
const runner = new AgentRunner({
|
|
1000
956
|
provider,
|
|
1001
957
|
tools,
|
|
1002
|
-
|
|
958
|
+
// Runners are built on dispatch, after the plugins loaded and the
|
|
959
|
+
// selection below ran, so a contributed executor is in place for
|
|
960
|
+
// the first turn.
|
|
961
|
+
executor: selectedExecutor ?? createLocalCommandExecutor(),
|
|
1003
962
|
...(approvals ? { approvals } : {}),
|
|
1004
963
|
store,
|
|
1005
964
|
bus,
|
|
@@ -1055,13 +1014,23 @@ export const createGateway = (options = {}) => {
|
|
|
1055
1014
|
// ten-minute timer instead of the idle timeout the operator set.
|
|
1056
1015
|
(fallback.provider === 'anthropic' && Boolean(fallback.apiKey || fallback.authToken))
|
|
1057
1016
|
// The codex harness streams item snapshots in every auth mode.
|
|
1058
|
-
|| fallback.provider === 'codex'
|
|
1017
|
+
|| fallback.provider === 'codex'
|
|
1018
|
+
// A contributed fallback says for itself, exactly as a contributed
|
|
1019
|
+
// primary does below; a session that switched to one must not lose
|
|
1020
|
+
// the watchdog the plugin declared it could have.
|
|
1021
|
+
|| (isRegisteredProviderName(fallback.provider)
|
|
1022
|
+
&& providerContributions.get(registeredProviderNameOf(fallback.provider))?.streams === true);
|
|
1059
1023
|
const streamsDeltas = (config) =>
|
|
1060
1024
|
// Both Anthropic modes stream now: an API key through the Messages
|
|
1061
1025
|
// API, a subscription token through the Agent SDK's partial messages —
|
|
1062
|
-
// and the codex harness streams item snapshots in every auth mode.
|
|
1026
|
+
// and the codex harness streams item snapshots in every auth mode. A
|
|
1027
|
+
// contributed provider says for itself, and one that did not say is
|
|
1028
|
+
// read as not streaming: a watchdog armed over a silent-until-done
|
|
1029
|
+
// call would abort every long turn.
|
|
1063
1030
|
(config.provider === 'anthropic' && Boolean(config.apiKey || config.authToken))
|
|
1064
|
-
|| config.provider === 'codex'
|
|
1031
|
+
|| config.provider === 'codex'
|
|
1032
|
+
|| (isRegisteredProviderName(config.provider)
|
|
1033
|
+
&& providerContributions.get(registeredProviderNameOf(config.provider))?.streams === true);
|
|
1065
1034
|
/**
|
|
1066
1035
|
* Progress-based abort, armed only while the turn is actually awaiting a
|
|
1067
1036
|
* streaming provider — the one phase where silence means a stall. Tool
|
|
@@ -1388,11 +1357,21 @@ export const createGateway = (options = {}) => {
|
|
|
1388
1357
|
}
|
|
1389
1358
|
const config = await runtimeForAgent(source);
|
|
1390
1359
|
const runner = runnerFor(config);
|
|
1360
|
+
// Which executor the turn's tool calls run through — the built-in
|
|
1361
|
+
// under the name `stratus run` has always recorded, a contributed one
|
|
1362
|
+
// under its registered name — so a transcript says where a command
|
|
1363
|
+
// ran, not only which model asked for it.
|
|
1364
|
+
const executorRecord = selectedExecutor && options.executor !== undefined ? options.executor : 'local-command';
|
|
1391
1365
|
const metadata = {
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1366
|
+
provider: config.provider,
|
|
1367
|
+
// A contributed provider may leave the model to its own default, in
|
|
1368
|
+
// which case there is none to record.
|
|
1369
|
+
...(config.provider !== 'demo' && config.model !== undefined ? { model: config.model } : {}),
|
|
1395
1370
|
...input.metadata,
|
|
1371
|
+
// After the caller's metadata, and reserved besides: a dispatch that
|
|
1372
|
+
// could write it could make a transcript claim a sandbox ran what
|
|
1373
|
+
// the host ran.
|
|
1374
|
+
[EXECUTOR_METADATA_KEY]: executorRecord,
|
|
1396
1375
|
};
|
|
1397
1376
|
if (existing && existing.agent.id !== agent.id) {
|
|
1398
1377
|
throw new Error(`Session ${input.sessionId} belongs to agent ${existing.agent.id}, not ${agent.id} — sessions never cross agent identities.`);
|
|
@@ -1424,6 +1403,13 @@ export const createGateway = (options = {}) => {
|
|
|
1424
1403
|
if (isDelegatedSession(existing) && existing.metadata) {
|
|
1425
1404
|
existing.metadata = withoutDelegation(existing.metadata);
|
|
1426
1405
|
}
|
|
1406
|
+
// The executor record follows the daemon, not the session's first
|
|
1407
|
+
// turn: a restart that selected another executor runs this turn's
|
|
1408
|
+
// commands there, and a session from before the record existed
|
|
1409
|
+
// gets one now rather than never.
|
|
1410
|
+
if (existing.metadata?.[EXECUTOR_METADATA_KEY] !== executorRecord) {
|
|
1411
|
+
existing.metadata = { ...existing.metadata, [EXECUTOR_METADATA_KEY]: executorRecord };
|
|
1412
|
+
}
|
|
1427
1413
|
await store.save(existing);
|
|
1428
1414
|
// The turn's metadata rides along for the sender's trust: without
|
|
1429
1415
|
// it a resumed turn has no way to say who sent it, and a stranger
|
|
@@ -1853,11 +1839,20 @@ export const createGateway = (options = {}) => {
|
|
|
1853
1839
|
tools,
|
|
1854
1840
|
skills: skillCatalog,
|
|
1855
1841
|
bus,
|
|
1842
|
+
providers: providerContributions,
|
|
1843
|
+
channels: channelContributions,
|
|
1844
|
+
memory: memoryContributions,
|
|
1845
|
+
executors: executorContributions,
|
|
1846
|
+
// The host-owned path for a channel's transport secrets: the same
|
|
1847
|
+
// `channels.<kind>.<agentId>` namespace Slack's tokens live in, read
|
|
1848
|
+
// for the kind the plugin declared, and never through the
|
|
1849
|
+
// agent-scoped resolver below.
|
|
1850
|
+
channelSecrets: (kind) => loadChannelTransportSecrets(env, kind),
|
|
1856
1851
|
// The file-backed resolver, so a plugin needing a key reaches the
|
|
1857
1852
|
// *calling* agent's — its allowlist checked, its own entry before the
|
|
1858
1853
|
// fleet's shared one.
|
|
1859
1854
|
credentials: createFileCredentialResolver(env),
|
|
1860
|
-
|
|
1855
|
+
workspaces: createAgentWorkspaces(env),
|
|
1861
1856
|
// The structured log, so a plugin's lifecycle lines — an MCP server
|
|
1862
1857
|
// that dropped, a reconnect that failed — are in `stratus logs` and
|
|
1863
1858
|
// not only on a stderr the service manager owns.
|
|
@@ -1870,6 +1865,10 @@ export const createGateway = (options = {}) => {
|
|
|
1870
1865
|
const contributed = [
|
|
1871
1866
|
...plugin.tools.map((tool) => `${tool.name} (${tool.risk})`),
|
|
1872
1867
|
...plugin.skills.map((skill) => `skill ${skill.id}`),
|
|
1868
|
+
...plugin.contributions.providers.map((name) => `provider ${name}`),
|
|
1869
|
+
...plugin.contributions.channels.map((channel) => `channel ${channel.kind} for ${channel.agents.join(', ') || 'no agent'}`),
|
|
1870
|
+
...plugin.contributions.memory.map((name) => `memory store ${name}`),
|
|
1871
|
+
...plugin.contributions.executors.map((name) => `executor ${name}`),
|
|
1873
1872
|
];
|
|
1874
1873
|
log(`plugin ${plugin.package} loaded — ${contributed.join(', ') || 'no tools'}`);
|
|
1875
1874
|
}
|
|
@@ -1877,6 +1876,43 @@ export const createGateway = (options = {}) => {
|
|
|
1877
1876
|
warn(`plugin ${failure.package} did not load: ${failure.reason}`);
|
|
1878
1877
|
}
|
|
1879
1878
|
};
|
|
1879
|
+
/**
|
|
1880
|
+
* Which package registered a name, for the log line and the refusal.
|
|
1881
|
+
* From the load records rather than a second map: they are the record.
|
|
1882
|
+
*/
|
|
1883
|
+
const contributorOf = (kind, name) => loadedPlugins.find((plugin) => plugin.contributions[kind].includes(name))?.package;
|
|
1884
|
+
/**
|
|
1885
|
+
* Put the operator's executor and memory-store selections into effect,
|
|
1886
|
+
* or refuse to start. Refuse, not warn: the built-in executor runs
|
|
1887
|
+
* commands on this host, and an operator who selected a sandbox that
|
|
1888
|
+
* failed to load must not find their agents on the host anyway; a
|
|
1889
|
+
* memory store the same, one write later. A name no plugin registered
|
|
1890
|
+
* is named with what is, so a typo reads as one.
|
|
1891
|
+
*/
|
|
1892
|
+
const applyContributionSelections = () => {
|
|
1893
|
+
const executorName = options.executor;
|
|
1894
|
+
if (executorName !== undefined && executorName !== BUILTIN_EXECUTOR_NAME) {
|
|
1895
|
+
const contribution = executorContributions.get(executorName);
|
|
1896
|
+
if (!contribution) {
|
|
1897
|
+
throw new Error(`The config selects executor ${executorName}, which no loaded plugin registers`
|
|
1898
|
+
+ (executorContributions.names().length > 0 ? ` (registered: ${executorContributions.names().join(', ')})` : '')
|
|
1899
|
+
+ `. Enable the plugin that contributes it, or set executor to ${BUILTIN_EXECUTOR_NAME}.`);
|
|
1900
|
+
}
|
|
1901
|
+
selectedExecutor = contribution.executor;
|
|
1902
|
+
log(`executor ${executorName} selected, from ${contributorOf('executors', executorName) ?? 'a plugin'}`);
|
|
1903
|
+
}
|
|
1904
|
+
const memoryName = options.memoryStore;
|
|
1905
|
+
if (memoryName !== undefined && memoryName !== BUILTIN_MEMORY_STORE_NAME) {
|
|
1906
|
+
const contribution = memoryContributions.get(memoryName);
|
|
1907
|
+
if (!contribution) {
|
|
1908
|
+
throw new Error(`The config selects memoryStore ${memoryName}, which no loaded plugin registers`
|
|
1909
|
+
+ (memoryContributions.names().length > 0 ? ` (registered: ${memoryContributions.names().join(', ')})` : '')
|
|
1910
|
+
+ `. Enable the plugin that contributes it, or set memoryStore to ${BUILTIN_MEMORY_STORE_NAME}.`);
|
|
1911
|
+
}
|
|
1912
|
+
selectedMemory = contribution.store;
|
|
1913
|
+
log(`memory store ${memoryName} selected, from ${contributorOf('memory', memoryName) ?? 'a plugin'}`);
|
|
1914
|
+
}
|
|
1915
|
+
};
|
|
1880
1916
|
/**
|
|
1881
1917
|
* Load the operator's `~/.stratus/skills/` into the catalog. Before the
|
|
1882
1918
|
* plugins, deliberately: an operator's bare id outranks a plugin's bare
|
|
@@ -1907,6 +1943,42 @@ export const createGateway = (options = {}) => {
|
|
|
1907
1943
|
// it is synchronous for the same reason: the check is a file read, and
|
|
1908
1944
|
// an async one in that stretch failed CI's restart tests twice.
|
|
1909
1945
|
assertStateCompatible(env);
|
|
1946
|
+
// A home whose pre-15a state has not been moved yet is refused rather
|
|
1947
|
+
// than served, because serving it is the quiet failure: the stores open
|
|
1948
|
+
// on the new paths, find nothing, and the fleet starts writing a second
|
|
1949
|
+
// population beside every session, schedule, and grant it already had.
|
|
1950
|
+
//
|
|
1951
|
+
// Refused rather than migrated, even though this process is the daemon:
|
|
1952
|
+
// the move needs the home to *itself*, and holding the claim is
|
|
1953
|
+
// something the host does (`claimHome`) — a gateway that migrated on
|
|
1954
|
+
// its own would be asserting an exclusivity nobody gave it. `stratus
|
|
1955
|
+
// serve` runs the migration before it ever gets here, so this only
|
|
1956
|
+
// fires for a host wiring the gateway up itself.
|
|
1957
|
+
// Against `stateDir` — the directory the stores were actually opened
|
|
1958
|
+
// on — rather than the one `env` names. A host that pointed them
|
|
1959
|
+
// somewhere else would otherwise be told its home is fine while the
|
|
1960
|
+
// sessions it is stranding sit in the directory it chose.
|
|
1961
|
+
if (await hasBracketedLegacyStateIn(stateDir)) {
|
|
1962
|
+
throw new Error(`${stateDir} still holds pre-per-agent state (a shared sessions.db, or an <id>.whitelist.json beside the souls), `
|
|
1963
|
+
+ 'and serving it now would strand every session, schedule, and grant in it. '
|
|
1964
|
+
+ (stateDir === stratusHomePath(env)
|
|
1965
|
+
? 'Run `stratus update`, or — for a host starting the gateway itself — claim the home with `claimHome` and '
|
|
1966
|
+
+ 'await `runStateMigrations(env, { exclusive: true })` from @stratusagent/state before calling start().'
|
|
1967
|
+
: 'This is a state directory of your own choosing, which `stratus update` does not migrate: move that state '
|
|
1968
|
+
+ 'into the layout yourself, or point `stateDir` at a directory that has been through it.'));
|
|
1969
|
+
}
|
|
1970
|
+
// Before the sweeps below read a single session id: the index and the
|
|
1971
|
+
// per-agent stores are two durable writes where the shared database had
|
|
1972
|
+
// one primary key, and a crash between them left them disagreeing.
|
|
1973
|
+
// Reconciling here is what makes that disagreement resolve one way —
|
|
1974
|
+
// a claim with no conversation released, a conversation the index lost
|
|
1975
|
+
// re-indexed — rather than leaving an abandoned turn unreachable by the
|
|
1976
|
+
// sweep that was supposed to fail it honestly.
|
|
1977
|
+
const reconciled = await store.reconcile();
|
|
1978
|
+
if (reconciled.released.length > 0 || reconciled.reindexed.length > 0) {
|
|
1979
|
+
log(`session index reconciled: ${reconciled.reindexed.length} re-indexed, `
|
|
1980
|
+
+ `${reconciled.released.length} claim(s) released`);
|
|
1981
|
+
}
|
|
1910
1982
|
await loadRoster();
|
|
1911
1983
|
const named = registry.list().map((agent) => agent.name).join(', ');
|
|
1912
1984
|
log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
|
|
@@ -1921,8 +1993,14 @@ export const createGateway = (options = {}) => {
|
|
|
1921
1993
|
const orphaned = await listOrphanedDelegations();
|
|
1922
1994
|
// Channels come up after the roster so their first inbound message
|
|
1923
1995
|
// already has agents to dispatch to. One failing adapter must not
|
|
1924
|
-
// keep the rest (or the gateway) down.
|
|
1925
|
-
|
|
1996
|
+
// keep the rest (or the gateway) down. The host's adapters first, then
|
|
1997
|
+
// the plugins' in load order — the `plugins` block's order, which is
|
|
1998
|
+
// the one an operator can read.
|
|
1999
|
+
const adapters = [
|
|
2000
|
+
...(options.channels ?? []),
|
|
2001
|
+
...channelContributions.list().map((contribution) => contribution.adapter),
|
|
2002
|
+
];
|
|
2003
|
+
for (const adapter of adapters) {
|
|
1926
2004
|
try {
|
|
1927
2005
|
await adapter.start(gateway);
|
|
1928
2006
|
startedChannels.push(adapter);
|
|
@@ -2138,6 +2216,13 @@ export const createGateway = (options = {}) => {
|
|
|
2138
2216
|
async start() {
|
|
2139
2217
|
try {
|
|
2140
2218
|
await migrateLegacyMemory(env);
|
|
2219
|
+
// And anything still in the shared `memory.jsonl` a pre-15a build
|
|
2220
|
+
// left (or a still-running one of those recreated). Not a
|
|
2221
|
+
// migration and never stamped — see `drainSharedMemory`.
|
|
2222
|
+
const drained = await drainSharedMemory(env);
|
|
2223
|
+
if (drained) {
|
|
2224
|
+
log(`shared memory drained: ${drained}`);
|
|
2225
|
+
}
|
|
2141
2226
|
// Before the roster and before channels: a turn must never arrive
|
|
2142
2227
|
// for an agent whose soul lists a tool the daemon has not
|
|
2143
2228
|
// registered yet, which would refuse the call as "not permitted"
|
|
@@ -2146,6 +2231,7 @@ export const createGateway = (options = {}) => {
|
|
|
2146
2231
|
// startSkills).
|
|
2147
2232
|
await startSkills();
|
|
2148
2233
|
await startPlugins();
|
|
2234
|
+
applyContributionSelections();
|
|
2149
2235
|
await startServing();
|
|
2150
2236
|
}
|
|
2151
2237
|
catch (error) {
|
|
@@ -2455,6 +2541,10 @@ export const createGateway = (options = {}) => {
|
|
|
2455
2541
|
path: skill.path,
|
|
2456
2542
|
};
|
|
2457
2543
|
}),
|
|
2544
|
+
providers: plugin.contributions.providers,
|
|
2545
|
+
channels: plugin.contributions.channels,
|
|
2546
|
+
memory: plugin.contributions.memory,
|
|
2547
|
+
executors: plugin.contributions.executors,
|
|
2458
2548
|
}));
|
|
2459
2549
|
const failed = pluginFailures.map((failure) => ({
|
|
2460
2550
|
package: failure.package,
|
|
@@ -2462,6 +2552,10 @@ export const createGateway = (options = {}) => {
|
|
|
2462
2552
|
}));
|
|
2463
2553
|
return [...loaded, ...failed];
|
|
2464
2554
|
},
|
|
2555
|
+
providers() {
|
|
2556
|
+
const contributed = loadedPlugins.flatMap((plugin) => plugin.contributions.providers.map((name) => ({ name, package: plugin.package })));
|
|
2557
|
+
return [...BUILTIN_PROVIDER_NAMES.map((name) => ({ name })), ...contributed];
|
|
2558
|
+
},
|
|
2465
2559
|
async servedSouls() {
|
|
2466
2560
|
// From the registry rather than `sources` directly, so the order and
|
|
2467
2561
|
// the membership are the roster's own — one entry per agent `agents()`
|