@stratusagent/gateway 0.14.0 → 0.14.2
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/event-intake.d.ts +19 -1
- package/dist/event-intake.d.ts.map +1 -1
- package/dist/event-intake.js +88 -12
- package/dist/event-intake.js.map +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +1 -6
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +82 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +548 -48
- package/dist/index.js.map +1 -1
- package/dist/schedules.d.ts +43 -1
- package/dist/schedules.d.ts.map +1 -1
- package/dist/schedules.js +258 -24
- package/dist/schedules.js.map +1 -1
- package/dist/sessions.d.ts +15 -7
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +89 -19
- package/dist/sessions.js.map +1 -1
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -9,8 +9,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
10
10
|
import { mkdir, readFile, rename, rm } from 'node:fs/promises';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import { filePathsOf, isUnaddressedTurn, carriesKey, leastTrusted, senderTrustOf, SENDER_TRUST_METADATA_KEY, workItemState, leftForNextStart, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, sessionTrustOf, sessionWriteTrust, 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, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, imagesWithinReplayBudget, matchesToolAllowlist, } from '@stratusagent/core';
|
|
13
|
-
import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, DELEGATE_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, createChannelCreateTool, directMessageReadRefusal, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DEFAULT_MAX_DELEGATION_DEPTH, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, credentialVariableProblem, } from '@stratusagent/agents';
|
|
12
|
+
import { filePathsOf, isUnaddressedTurn, carriesKey, leastTrusted, senderTrustOf, SENDER_TRUST_METADATA_KEY, workItemState, turnMessageOf, turnKeyOf, leftForNextStart, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, sessionTrustOf, sessionWriteTrust, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, CONTEXT_SUMMARY_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, imagesWithinReplayBudget, matchesToolAllowlist, } from '@stratusagent/core';
|
|
13
|
+
import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, DELEGATE_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, createChannelArchiveTool, createChannelCreateTool, createChannelInviteTool, createChannelJoinTool, directMessageReadRefusal, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATED_SESSION_ID_MARKER, DEFAULT_MAX_DELEGATION_DEPTH, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, credentialVariableProblem, } from '@stratusagent/agents';
|
|
14
14
|
import { createFleetReader } from "./fleet.js";
|
|
15
15
|
import { createEventIntake, summarizeEvents, } from "./event-intake.js";
|
|
16
16
|
import { SqliteEventStore } from "./events.js";
|
|
@@ -57,6 +57,8 @@ export const STOPPING_TURN_ERROR = 'Run aborted: stratusd is stopping';
|
|
|
57
57
|
* two windows an operator can hit agree.
|
|
58
58
|
*/
|
|
59
59
|
export const DEFAULT_RESTART_DRAIN_TIMEOUT_MS = 30_000;
|
|
60
|
+
/** How long a roster reload waits on one channel's `reload()`. */
|
|
61
|
+
export const DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS = 30_000;
|
|
60
62
|
/**
|
|
61
63
|
* How long a stop lets in-flight turns finish before aborting them. The
|
|
62
64
|
* daemon's documented drain ("up to 30 seconds"), and shorter than the
|
|
@@ -200,10 +202,19 @@ const MERGED_SEPARATOR = '\n\n';
|
|
|
200
202
|
*/
|
|
201
203
|
const fitsReplayBudget = (images) => images.length === 0 || imagesWithinReplayBudget([{ id: '', createdAt: '', role: 'user', content: '', images: [...images] }]).size === images.length;
|
|
202
204
|
/** A signal that aborts once every one of `signals` has. */
|
|
203
|
-
const allAborted = (signals) =>
|
|
205
|
+
const allAborted = (signals) => joinableSignal(signals).signal;
|
|
206
|
+
/**
|
|
207
|
+
* `allAborted` for callers that keep joining: a turn that takes in messages
|
|
208
|
+
* at its tool steps (card 948) is cancelled only once every caller in it
|
|
209
|
+
* is, the ones who joined late included. A caller joining with no signal
|
|
210
|
+
* can never cancel, so from then on neither can the turn.
|
|
211
|
+
*/
|
|
212
|
+
const joinableSignal = (initial) => {
|
|
204
213
|
const controller = new AbortController();
|
|
214
|
+
const signals = [...initial];
|
|
215
|
+
let uncancellable = false;
|
|
205
216
|
const check = () => {
|
|
206
|
-
if (!controller.signal.aborted && signals.every((signal) => signal.aborted)) {
|
|
217
|
+
if (!uncancellable && !controller.signal.aborted && signals.every((signal) => signal.aborted)) {
|
|
207
218
|
controller.abort(signals[signals.length - 1]?.reason);
|
|
208
219
|
}
|
|
209
220
|
};
|
|
@@ -211,8 +222,36 @@ const allAborted = (signals) => {
|
|
|
211
222
|
signal.addEventListener('abort', check, { once: true });
|
|
212
223
|
}
|
|
213
224
|
check();
|
|
214
|
-
return
|
|
225
|
+
return {
|
|
226
|
+
signal: controller.signal,
|
|
227
|
+
join: (signal) => {
|
|
228
|
+
if (signal === undefined) {
|
|
229
|
+
uncancellable = true;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
signals.push(signal);
|
|
233
|
+
signal.addEventListener('abort', check, { once: true });
|
|
234
|
+
},
|
|
235
|
+
};
|
|
215
236
|
};
|
|
237
|
+
/**
|
|
238
|
+
* A value from a form, without the line breaks around it.
|
|
239
|
+
*
|
|
240
|
+
* Forms take multi-line values now, so a one-line key copied with the line
|
|
241
|
+
* break after it would otherwise be stored with it, and fail later inside
|
|
242
|
+
* whatever header or command it lands in. Only line breaks: a value's own
|
|
243
|
+
* spaces are its own, as on the CLI.
|
|
244
|
+
*/
|
|
245
|
+
const formCredentialValue = (value) => value.replace(/^[\r\n]+|[\r\n]+$/g, '');
|
|
246
|
+
/**
|
|
247
|
+
* A PEM block (a private key, a certificate) with no line breaks left, which
|
|
248
|
+
* is what a one-line field makes of a pasted key file. PEM readers need the
|
|
249
|
+
* line after the `-----BEGIN …-----` header, so storing it would only move
|
|
250
|
+
* the failure to the first call that reads the key. Only a value that *is*
|
|
251
|
+
* one: a PEM inside JSON (a service account file) escapes its line breaks,
|
|
252
|
+
* so it has none of its own and is fine as sent.
|
|
253
|
+
*/
|
|
254
|
+
const isFlattenedPem = (value) => /^\s*-----BEGIN [A-Z0-9 ]+-----/.test(value) && !value.includes('\n');
|
|
216
255
|
/**
|
|
217
256
|
* Recorded on a turn the previous process was still running when it died.
|
|
218
257
|
*
|
|
@@ -335,6 +374,10 @@ export const RESERVED_SESSION_METADATA_KEYS = [
|
|
|
335
374
|
// no messages and, once it grows past that index, silently stops sending
|
|
336
375
|
// its own earliest turns, with no overflow and no trim event to say so.
|
|
337
376
|
CONTEXT_FLOOR_METADATA_KEY,
|
|
377
|
+
// The summary that stands in below that floor, for both of the same
|
|
378
|
+
// reasons, and one more: the model reads it as something it wrote itself,
|
|
379
|
+
// so a caller who could seed it could put words in the agent's mouth.
|
|
380
|
+
CONTEXT_SUMMARY_METADATA_KEY,
|
|
338
381
|
SUB_AGENT_METADATA_KEY,
|
|
339
382
|
];
|
|
340
383
|
/**
|
|
@@ -398,26 +441,39 @@ export const createGateway = (options = {}) => {
|
|
|
398
441
|
// schedule store's own connection (see its docs for why).
|
|
399
442
|
const scheduleStore = new SqliteScheduleStore(fleetDbIn(stateDir), { stateHome: stateDir });
|
|
400
443
|
// ---- events (card 590) ---------------------------------------------------
|
|
401
|
-
// Opened
|
|
402
|
-
//
|
|
444
|
+
// Opened for the first source, so a daemon that never takes events never
|
|
445
|
+
// grows the tables: at construction for the config's sources, or when the
|
|
446
|
+
// first plugin feeding a source loads (phase 2), which is at start(),
|
|
447
|
+
// before the scheduler's start-up pass. Its own connection to fleet.db,
|
|
448
|
+
// like schedules'.
|
|
403
449
|
const eventSources = options.events?.sources ?? {};
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
450
|
+
/** Sources plugins feed: source id to the package that declared it, filled as each loads. */
|
|
451
|
+
const pluginEventSources = new Map();
|
|
452
|
+
const eventSourceIds = () => [...Object.keys(eventSources), ...pluginEventSources.keys()];
|
|
453
|
+
let eventStore;
|
|
454
|
+
let eventIntake;
|
|
455
|
+
const openEvents = () => {
|
|
456
|
+
if (!eventStore || !eventIntake) {
|
|
457
|
+
eventStore = new SqliteEventStore(fleetDbIn(stateDir), { stateHome: stateDir });
|
|
458
|
+
eventIntake = createEventIntake({
|
|
459
|
+
store: eventStore,
|
|
460
|
+
sources: () => eventSources,
|
|
461
|
+
pluginSources: () => [...pluginEventSources.keys()],
|
|
462
|
+
// The daemon's own credential, read per delivery: a stored or
|
|
463
|
+
// rotated secret works on the next one, with no restart.
|
|
464
|
+
secret: (name) => resolveDaemonCredential(env, name),
|
|
465
|
+
// The triggers are schedules: `schedule.on` rows on this source.
|
|
466
|
+
// Late-bound, because the scheduler is built further down.
|
|
467
|
+
triggers: (sourceId) => scheduler.eventTriggers(sourceId),
|
|
468
|
+
...(options.events?.maxBodyBytes !== undefined ? { maxBodyBytes: options.events.maxBodyBytes } : {}),
|
|
469
|
+
warn,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return { store: eventStore, intake: eventIntake };
|
|
473
|
+
};
|
|
474
|
+
if (Object.keys(eventSources).length > 0) {
|
|
475
|
+
openEvents();
|
|
476
|
+
}
|
|
421
477
|
/**
|
|
422
478
|
* The started adapter of one kind that speaks for an agent and has the
|
|
423
479
|
* capability asked for. `carriesOthers` says there were capable adapters
|
|
@@ -481,13 +537,28 @@ export const createGateway = (options = {}) => {
|
|
|
481
537
|
await outboundFor(agentId, destination);
|
|
482
538
|
},
|
|
483
539
|
sessionStatus: async (sessionId) => (await store.get(sessionId))?.status,
|
|
540
|
+
// Card 858: the previous firing, for the next one's checkpoint. The
|
|
541
|
+
// reply and its label, never the transcript.
|
|
542
|
+
previousRun: async (sessionId) => {
|
|
543
|
+
const session = await store.get(sessionId);
|
|
544
|
+
if (!session) {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
const reply = latestTurnReply(session);
|
|
548
|
+
return {
|
|
549
|
+
status: session.status,
|
|
550
|
+
...(session.stoppedEarly !== undefined ? { stoppedEarly: session.stoppedEarly } : {}),
|
|
551
|
+
...(reply !== undefined ? { reply } : {}),
|
|
552
|
+
trust: sessionWriteTrust(session),
|
|
553
|
+
};
|
|
554
|
+
},
|
|
484
555
|
hasAgent: (agentId) => registry.get(agentId) !== undefined,
|
|
485
556
|
...(options.schedules ? { limits: options.schedules } : {}),
|
|
486
557
|
// The same stamp check every turn runs, before a slot is claimed — see
|
|
487
558
|
// `SchedulerRuntimeOptions.ready`.
|
|
488
559
|
ready: () => assertStateCompatible(env),
|
|
489
560
|
paused: () => intakeState.paused,
|
|
490
|
-
...(eventStore ? { events: { store: eventStore, sources:
|
|
561
|
+
...(eventStore ? { events: { store: eventStore, sources: eventSourceIds } } : {}),
|
|
491
562
|
log,
|
|
492
563
|
warn,
|
|
493
564
|
});
|
|
@@ -1168,6 +1239,59 @@ export const createGateway = (options = {}) => {
|
|
|
1168
1239
|
reloads = next.catch(() => undefined);
|
|
1169
1240
|
await next;
|
|
1170
1241
|
};
|
|
1242
|
+
const channelReloadTimeoutMs = options.channelReloadTimeoutMs ?? DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS;
|
|
1243
|
+
/**
|
|
1244
|
+
* After a roster reload, each started channel that can picks up the
|
|
1245
|
+
* agents it carries now (a new agent's stored app, one whose soul or
|
|
1246
|
+
* secrets are gone). One at a time, in start order, so the log reads in
|
|
1247
|
+
* the order an operator configured them. A channel that fails is
|
|
1248
|
+
* logged and the rest still reload: one bad app must not hold back the
|
|
1249
|
+
* others, the rule `startChannels` follows. A channel still reloading
|
|
1250
|
+
* after the bound is left to finish on its own, and what it ends with is
|
|
1251
|
+
* logged then, so a hung Slack call cannot hold every later reload.
|
|
1252
|
+
*/
|
|
1253
|
+
const reloadChannels = async () => {
|
|
1254
|
+
const outcomes = [];
|
|
1255
|
+
for (const adapter of [...startedChannels]) {
|
|
1256
|
+
if (stopping || channelsClosed) {
|
|
1257
|
+
return outcomes;
|
|
1258
|
+
}
|
|
1259
|
+
if (typeof adapter.reload !== 'function') {
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
const reload = adapter.reload.bind(adapter);
|
|
1263
|
+
// Settles with the failure, never rejects: one that comes after the
|
|
1264
|
+
// bound has nobody awaiting it, and would take the daemon down.
|
|
1265
|
+
const settled = (async () => reload())().then(() => undefined, (error) => (error instanceof Error ? error.message : String(error)));
|
|
1266
|
+
let timer;
|
|
1267
|
+
const bound = new Promise((resolve) => {
|
|
1268
|
+
timer = setTimeout(() => resolve('timeout'), channelReloadTimeoutMs);
|
|
1269
|
+
timer.unref?.();
|
|
1270
|
+
});
|
|
1271
|
+
const outcome = await Promise.race([settled.then((failure) => ({ failure })), bound]);
|
|
1272
|
+
clearTimeout(timer);
|
|
1273
|
+
if (outcome === 'timeout') {
|
|
1274
|
+
warn(`channel ${adapter.name} is still reloading after ${channelReloadTimeoutMs} ms; the roster reload goes on without it`);
|
|
1275
|
+
void settled.then((failure) => {
|
|
1276
|
+
if (failure !== undefined) {
|
|
1277
|
+
warn(`channel ${adapter.name} could not reload: ${failure}`);
|
|
1278
|
+
}
|
|
1279
|
+
else {
|
|
1280
|
+
log(`channel ${adapter.name} finished reloading`);
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
outcomes.push({ name: adapter.name, outcome: 'still-reloading' });
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (outcome.failure !== undefined) {
|
|
1287
|
+
warn(`channel ${adapter.name} could not reload: ${outcome.failure}`);
|
|
1288
|
+
outcomes.push({ name: adapter.name, outcome: 'failed', reason: outcome.failure });
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
outcomes.push({ name: adapter.name, outcome: 'reloaded' });
|
|
1292
|
+
}
|
|
1293
|
+
return outcomes;
|
|
1294
|
+
};
|
|
1171
1295
|
/**
|
|
1172
1296
|
* A session pins its agent id, never a snapshot of the agent: the soul
|
|
1173
1297
|
* file is re-read on every dispatch, so an edited persona (or tool
|
|
@@ -1320,6 +1444,128 @@ export const createGateway = (options = {}) => {
|
|
|
1320
1444
|
}
|
|
1321
1445
|
return adapter.createConversation({ agentId, ...request });
|
|
1322
1446
|
}));
|
|
1447
|
+
// Joining and inviting follow the same rule: the asking agent's own app
|
|
1448
|
+
// does it, so membership stays per app and its token stays in the adapter.
|
|
1449
|
+
tools.register(createChannelJoinTool(async ({ agentId, channel, conversation }) => {
|
|
1450
|
+
const { adapter, carriesOthers } = channelCarrying(channel, agentId, (candidate) => candidate.joinConversation !== undefined);
|
|
1451
|
+
if (!adapter?.joinConversation) {
|
|
1452
|
+
throw new Error(carriesOthers
|
|
1453
|
+
? `No running '${channel}' channel carries agent ${agentId}, so it has no app to join a channel with.`
|
|
1454
|
+
: `No running channel can join '${channel}' conversations.`);
|
|
1455
|
+
}
|
|
1456
|
+
return adapter.joinConversation({ agentId, conversation });
|
|
1457
|
+
}));
|
|
1458
|
+
tools.register(createChannelInviteTool(async ({ agentId, channel, ...request }) => {
|
|
1459
|
+
const { adapter, carriesOthers } = channelCarrying(channel, agentId, (candidate) => candidate.inviteToConversation !== undefined);
|
|
1460
|
+
if (!adapter?.inviteToConversation) {
|
|
1461
|
+
throw new Error(carriesOthers
|
|
1462
|
+
? `No running '${channel}' channel carries agent ${agentId}, so it has no app to invite anyone with.`
|
|
1463
|
+
: `No running channel can invite people to '${channel}' conversations.`);
|
|
1464
|
+
}
|
|
1465
|
+
return adapter.inviteToConversation({ agentId, ...request });
|
|
1466
|
+
}));
|
|
1467
|
+
/**
|
|
1468
|
+
* Why a `channel.archive` call would archive the conversation its work was
|
|
1469
|
+
* asked from, or can't tell, as a sentence; undefined when it may go on.
|
|
1470
|
+
* That conversation is never archived: the answer to it would have
|
|
1471
|
+
* nowhere to go, and the agent can't unarchive.
|
|
1472
|
+
*
|
|
1473
|
+
* An ordinary session is its own origin. A sub-agent's or a delegated
|
|
1474
|
+
* run's is the root turn that started the chain, read off the id the
|
|
1475
|
+
* host minted for it (each child id is its parent's plus a marker, so the
|
|
1476
|
+
* root is the text before the first one), never off metadata a tool in
|
|
1477
|
+
* the slice could rewrite. The id is the one the session had at its first
|
|
1478
|
+
* tool call, for the same reason. The origin's conversation is asked of
|
|
1479
|
+
* the adapter of this kind that carries the origin's agent. When it can't
|
|
1480
|
+
* be told (the root is gone; no adapter, no `sessionConversation`, a
|
|
1481
|
+
* lookup that fails, or no answer for a session whose id is this
|
|
1482
|
+
* channel's), the call is refused: an archive the agent can't undo
|
|
1483
|
+
* doesn't run on a guess. A session that isn't this channel's at all (an
|
|
1484
|
+
* API call, a schedule, a turn on another transport) has no conversation
|
|
1485
|
+
* here to protect.
|
|
1486
|
+
*/
|
|
1487
|
+
const archiveRefusal = async (kind, conversation, session, archiving) => {
|
|
1488
|
+
const unknown = (why) => `${kind} ${conversation} was not archived: ${why}, so Stratus can't tell which conversation this work was asked from, `
|
|
1489
|
+
+ 'and that one is never archived. Ask for the archive from a conversation.';
|
|
1490
|
+
const id = hostIdOf(session);
|
|
1491
|
+
const marks = [SUB_AGENT_SESSION_ID_MARKER, DELEGATED_SESSION_ID_MARKER]
|
|
1492
|
+
.map((marker) => id.indexOf(marker))
|
|
1493
|
+
.filter((at) => at >= 0);
|
|
1494
|
+
let origin = { agentId: session.agent.id, sessionId: id };
|
|
1495
|
+
let adapter = archiving;
|
|
1496
|
+
if (marks.length > 0) {
|
|
1497
|
+
const rootId = id.slice(0, Math.min(...marks));
|
|
1498
|
+
let root;
|
|
1499
|
+
try {
|
|
1500
|
+
root = rootId ? await store.get(rootId) : undefined;
|
|
1501
|
+
}
|
|
1502
|
+
catch (error) {
|
|
1503
|
+
warn(`${id}: could not read the root session ${rootId} to check a channel.archive (${error instanceof Error ? error.message : String(error)})`);
|
|
1504
|
+
root = undefined;
|
|
1505
|
+
}
|
|
1506
|
+
if (!root) {
|
|
1507
|
+
return unknown('this run was started by another session that no longer exists');
|
|
1508
|
+
}
|
|
1509
|
+
origin = { agentId: root.agent.id, sessionId: root.id };
|
|
1510
|
+
adapter = channelCarrying(kind, origin.agentId, () => true).adapter;
|
|
1511
|
+
}
|
|
1512
|
+
// Channel session ids lead with their kind (`channelSessionKey`).
|
|
1513
|
+
const onThisChannel = origin.sessionId.startsWith(`${kind}:`);
|
|
1514
|
+
if (!adapter) {
|
|
1515
|
+
return onThisChannel ? unknown(`no running '${kind}' channel carries the agent this work was asked of`) : undefined;
|
|
1516
|
+
}
|
|
1517
|
+
if (!adapter.sessionConversation) {
|
|
1518
|
+
return unknown(`the running '${kind}' channel can't say which conversation a session is in`);
|
|
1519
|
+
}
|
|
1520
|
+
let origins;
|
|
1521
|
+
try {
|
|
1522
|
+
origins = adapter.sessionConversation(origin.agentId, origin.sessionId);
|
|
1523
|
+
}
|
|
1524
|
+
catch (error) {
|
|
1525
|
+
warn(`${id}: could not tell which conversation ${origin.sessionId} is in to check a channel.archive (${error instanceof Error ? error.message : String(error)})`);
|
|
1526
|
+
return unknown(`the running '${kind}' channel couldn't say which conversation this work was asked from`);
|
|
1527
|
+
}
|
|
1528
|
+
if (origins === undefined) {
|
|
1529
|
+
return onThisChannel ? unknown(`the running '${kind}' channel can't say which of its conversations this work was asked from`) : undefined;
|
|
1530
|
+
}
|
|
1531
|
+
return origins === conversation
|
|
1532
|
+
? `${kind} ${conversation} is the conversation this work was asked from, so it was not archived: `
|
|
1533
|
+
+ 'the reply would have nowhere to go. Ask for the archive from another conversation.'
|
|
1534
|
+
: undefined;
|
|
1535
|
+
};
|
|
1536
|
+
const archivingAdapter = (kind, agentId) => channelCarrying(kind, agentId, (candidate) => candidate.archiveConversation !== undefined);
|
|
1537
|
+
const noArchivingAdapter = (kind, agentId, carriesOthers) => carriesOthers
|
|
1538
|
+
? `No running '${kind}' channel carries agent ${agentId}, so it has no app to archive a channel with.`
|
|
1539
|
+
: `No running channel can archive '${kind}' conversations.`;
|
|
1540
|
+
tools.register(createChannelArchiveTool(async ({ agentId, channel, conversation }, session) => {
|
|
1541
|
+
const { adapter, carriesOthers } = archivingAdapter(channel, agentId);
|
|
1542
|
+
if (!adapter?.archiveConversation) {
|
|
1543
|
+
throw new Error(noArchivingAdapter(channel, agentId, carriesOthers));
|
|
1544
|
+
}
|
|
1545
|
+
const refusal = await archiveRefusal(channel, conversation, session, adapter);
|
|
1546
|
+
if (refusal !== undefined) {
|
|
1547
|
+
throw new Error(refusal);
|
|
1548
|
+
}
|
|
1549
|
+
return adapter.archiveConversation({ agentId, conversation });
|
|
1550
|
+
}, {
|
|
1551
|
+
// The same check before anyone is asked: an approval, Always allow
|
|
1552
|
+
// above all, for an archive that is bound to be refused is a question
|
|
1553
|
+
// with no useful answer. Held as an approval wait, like message.read's,
|
|
1554
|
+
// since it runs before any tool event and may read the store.
|
|
1555
|
+
refusalFor: async ({ agentId, channel, conversation }, session) => {
|
|
1556
|
+
const { adapter, carriesOthers } = archivingAdapter(channel, agentId);
|
|
1557
|
+
if (!adapter?.archiveConversation) {
|
|
1558
|
+
return noArchivingAdapter(channel, agentId, carriesOthers);
|
|
1559
|
+
}
|
|
1560
|
+
const release = holdApprovalWait(session.id);
|
|
1561
|
+
try {
|
|
1562
|
+
return await archiveRefusal(channel, conversation, session, adapter);
|
|
1563
|
+
}
|
|
1564
|
+
finally {
|
|
1565
|
+
release();
|
|
1566
|
+
}
|
|
1567
|
+
},
|
|
1568
|
+
}));
|
|
1323
1569
|
tools.register(createMessageReadTool(async ({ agentId, source, ...window }) => {
|
|
1324
1570
|
// The same carrying rule as outbound: a read goes through the app of
|
|
1325
1571
|
// the agent asking, never whichever adapter of that kind is running,
|
|
@@ -1654,6 +1900,18 @@ export const createGateway = (options = {}) => {
|
|
|
1654
1900
|
message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
|
|
1655
1901
|
};
|
|
1656
1902
|
}
|
|
1903
|
+
const value = formCredentialValue(input.value);
|
|
1904
|
+
if (isFlattenedPem(value)) {
|
|
1905
|
+
// Refused, not retired: the same form can still take the key whole.
|
|
1906
|
+
const command = request.scope === 'agent'
|
|
1907
|
+
? `stratus credential set ${request.name} --agent ${quoteShellArg(request.agentId)} < key.pem`
|
|
1908
|
+
: `stratus credential set ${request.name} < key.pem`;
|
|
1909
|
+
return {
|
|
1910
|
+
ok: false,
|
|
1911
|
+
message: 'That looks like a PEM key whose line breaks were lost, so nothing was stored: whatever reads it would fail. '
|
|
1912
|
+
+ `Paste it again from the original file with its line breaks, or store it on the machine: ${command}.`,
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1657
1915
|
const store = {
|
|
1658
1916
|
...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
|
|
1659
1917
|
// The binding the form promised, written with the value, always for
|
|
@@ -1670,7 +1928,7 @@ export const createGateway = (options = {}) => {
|
|
|
1670
1928
|
if (soul.agent.id !== request.agentId || soul.agent.credentials?.includes(request.name) !== true) {
|
|
1671
1929
|
return false;
|
|
1672
1930
|
}
|
|
1673
|
-
await replaceNamedCredential(env, { name: request.name, value
|
|
1931
|
+
await replaceNamedCredential(env, { name: request.name, value, ...store });
|
|
1674
1932
|
return true;
|
|
1675
1933
|
});
|
|
1676
1934
|
}
|
|
@@ -1709,7 +1967,7 @@ export const createGateway = (options = {}) => {
|
|
|
1709
1967
|
try {
|
|
1710
1968
|
await addNamedCredential(env, {
|
|
1711
1969
|
name: request.name,
|
|
1712
|
-
value
|
|
1970
|
+
value,
|
|
1713
1971
|
...store,
|
|
1714
1972
|
});
|
|
1715
1973
|
}
|
|
@@ -2330,6 +2588,23 @@ export const createGateway = (options = {}) => {
|
|
|
2330
2588
|
// behind the in-flight turn instead of interleaving with it. Sessions
|
|
2331
2589
|
// are independent — different conversations run concurrently.
|
|
2332
2590
|
const sessionChains = new Map();
|
|
2591
|
+
/**
|
|
2592
|
+
* Work queued on each session's chain that has not started, of every
|
|
2593
|
+
* kind (a dispatch, an overheard message, a recovery). What says whether
|
|
2594
|
+
* a merged-turn batch is first in line behind the running turn, which
|
|
2595
|
+
* steering needs (card 948): a batch with anything ahead of it would be
|
|
2596
|
+
* answered before what was said first.
|
|
2597
|
+
*/
|
|
2598
|
+
const queuedWork = new Map();
|
|
2599
|
+
const releaseQueued = (sessionId) => {
|
|
2600
|
+
const left = (queuedWork.get(sessionId) ?? 1) - 1;
|
|
2601
|
+
if (left > 0) {
|
|
2602
|
+
queuedWork.set(sessionId, left);
|
|
2603
|
+
}
|
|
2604
|
+
else {
|
|
2605
|
+
queuedWork.delete(sessionId);
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2333
2608
|
/**
|
|
2334
2609
|
* Dispatches queued behind a session's running turn, per session: what
|
|
2335
2610
|
* tells an auto-continuing turn that somebody wrote meanwhile, so it
|
|
@@ -2567,9 +2842,13 @@ export const createGateway = (options = {}) => {
|
|
|
2567
2842
|
return await turn;
|
|
2568
2843
|
}
|
|
2569
2844
|
catch (error) {
|
|
2570
|
-
if (
|
|
2845
|
+
if (error instanceof RunAbortedError && error.resumable) {
|
|
2571
2846
|
const left = await store.get(input.sessionId).catch(() => undefined);
|
|
2572
|
-
|
|
2847
|
+
// The dispatch's own key, or, for an unkeyed one whose turn took
|
|
2848
|
+
// keyed messages in at a tool step (card 948), the key the turn has
|
|
2849
|
+
// from them: its steered callers are told what a keyed caller is.
|
|
2850
|
+
const key = input.idempotencyKey ?? (left === undefined ? undefined : turnKeyOf(turnMessageOf(left)));
|
|
2851
|
+
if (left !== undefined && key !== undefined && workItemState(left, key) === 'unfinished' && leftForNextStart(left, error)) {
|
|
2573
2852
|
throw leftForNext();
|
|
2574
2853
|
}
|
|
2575
2854
|
}
|
|
@@ -2581,7 +2860,9 @@ export const createGateway = (options = {}) => {
|
|
|
2581
2860
|
* runs as that, on the parent's resolved provider, with no memory and no
|
|
2582
2861
|
* soul. Only that path passes it.
|
|
2583
2862
|
*/
|
|
2584
|
-
const dispatchInternal = async (input, absorbedKeys = [], subAgent
|
|
2863
|
+
const dispatchInternal = async (input, absorbedKeys = [], subAgent,
|
|
2864
|
+
/** See `RunInput.steer`; passed by `dispatch` for an opted-in caller. */
|
|
2865
|
+
steer) => {
|
|
2585
2866
|
// Re-read on every turn, not once at start: a newer build that stamps
|
|
2586
2867
|
// the home while this daemon is serving has formats this build does not
|
|
2587
2868
|
// understand, and a daemon that kept writing into them would be the
|
|
@@ -2706,6 +2987,7 @@ export const createGateway = (options = {}) => {
|
|
|
2706
2987
|
...(hostedLoop ? { hostedLoop } : {}),
|
|
2707
2988
|
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
2708
2989
|
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
|
|
2990
|
+
...(steer !== undefined ? { steer } : {}),
|
|
2709
2991
|
signal,
|
|
2710
2992
|
});
|
|
2711
2993
|
}
|
|
@@ -2721,6 +3003,7 @@ export const createGateway = (options = {}) => {
|
|
|
2721
3003
|
...(input.earlier !== undefined && input.earlier.length > 0 ? { earlier: input.earlier } : {}),
|
|
2722
3004
|
metadata,
|
|
2723
3005
|
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
|
|
3006
|
+
...(steer !== undefined ? { steer } : {}),
|
|
2724
3007
|
signal,
|
|
2725
3008
|
});
|
|
2726
3009
|
}));
|
|
@@ -2920,7 +3203,7 @@ export const createGateway = (options = {}) => {
|
|
|
2920
3203
|
// otherwise stay parked for every later start and redelivery to try
|
|
2921
3204
|
// again. An unkeyed one keeps waiting for a sweep that can, as before.
|
|
2922
3205
|
const after = await store.get(sessionId).catch(() => undefined);
|
|
2923
|
-
const key = after
|
|
3206
|
+
const key = after === undefined ? undefined : turnKeyOf(turnMessageOf(after));
|
|
2924
3207
|
if (after !== undefined && key !== undefined && after.status === 'pending_approval' && workItemState(after, key) === 'unfinished') {
|
|
2925
3208
|
await failParked(after, ABANDONED_TURN_ERROR).catch(() => { });
|
|
2926
3209
|
}
|
|
@@ -3031,8 +3314,8 @@ export const createGateway = (options = {}) => {
|
|
|
3031
3314
|
* whose caller was never promised anything and is told to send again.
|
|
3032
3315
|
*/
|
|
3033
3316
|
const settleAbandonedTurn = async (session) => {
|
|
3034
|
-
const message = session
|
|
3035
|
-
if (message
|
|
3317
|
+
const message = turnMessageOf(session);
|
|
3318
|
+
if (message === undefined || turnKeyOf(message) === undefined) {
|
|
3036
3319
|
return failAbandoned(session);
|
|
3037
3320
|
}
|
|
3038
3321
|
// An answer already in the transcript is completed whatever came before
|
|
@@ -3184,6 +3467,100 @@ export const createGateway = (options = {}) => {
|
|
|
3184
3467
|
}
|
|
3185
3468
|
return settleAbandonedTurn(session);
|
|
3186
3469
|
};
|
|
3470
|
+
/**
|
|
3471
|
+
* What a running turn takes in at its next tool step (card 948): the
|
|
3472
|
+
* merged-turn batch waiting behind it, whole — leader and absorbed alike
|
|
3473
|
+
* — when it is for the same agent. The dispatches it takes join `into`,
|
|
3474
|
+
* which the running turn settles with its own outcome (`runSteerable`).
|
|
3475
|
+
*
|
|
3476
|
+
* A part cancelled while it waited is refused, as `runMerged` refuses it.
|
|
3477
|
+
* A part whose key this session (or the archive it rolled over from)
|
|
3478
|
+
* already holds is a repeat, and judging a repeat can mean settling a
|
|
3479
|
+
* turn — never something to do from inside one — so such a batch is not
|
|
3480
|
+
* taken: it waits and runs next, merged, where that is judged. Nor while
|
|
3481
|
+
* the gateway is stopping, when the batch is what the drain refuses.
|
|
3482
|
+
*/
|
|
3483
|
+
const takeSteered = async (sessionId, agentId, into,
|
|
3484
|
+
/**
|
|
3485
|
+
* Asked last, with nothing awaited after it: whether the running turn
|
|
3486
|
+
* still takes these callers, joining them to its cancellation if so. A
|
|
3487
|
+
* turn cancelled while the store was read takes nothing, and the batch
|
|
3488
|
+
* waits and runs next, as it would have.
|
|
3489
|
+
*/
|
|
3490
|
+
admit) => {
|
|
3491
|
+
const batch = openBatches.get(sessionId);
|
|
3492
|
+
if (stopping || batch === undefined || !batch.firstInLine || batch.agentId !== agentId) {
|
|
3493
|
+
return undefined;
|
|
3494
|
+
}
|
|
3495
|
+
// Until every key in it is checked with nothing awaited since: a message
|
|
3496
|
+
// can join while the store is read, and is checked on the next pass; a
|
|
3497
|
+
// batch closed meanwhile is no longer this turn's to take.
|
|
3498
|
+
const checked = new Set();
|
|
3499
|
+
let session;
|
|
3500
|
+
for (;;) {
|
|
3501
|
+
if (stopping || openBatches.get(sessionId) !== batch) {
|
|
3502
|
+
return undefined;
|
|
3503
|
+
}
|
|
3504
|
+
const unchecked = [batch.leader, ...batch.absorbed].flatMap((part) => (part.input.idempotencyKey !== undefined && !checked.has(part.input.idempotencyKey) ? [part.input.idempotencyKey] : []));
|
|
3505
|
+
if (unchecked.length === 0) {
|
|
3506
|
+
break;
|
|
3507
|
+
}
|
|
3508
|
+
session ??= await store.get(sessionId);
|
|
3509
|
+
for (const key of unchecked) {
|
|
3510
|
+
checked.add(key);
|
|
3511
|
+
if (session !== undefined && (workItemState(session, key) !== undefined || await archivedRepeat(session, key) !== undefined)) {
|
|
3512
|
+
return undefined;
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
const all = [batch.leader, ...batch.absorbed];
|
|
3517
|
+
if (!admit(all.filter((part) => part.input.signal?.aborted !== true))) {
|
|
3518
|
+
return undefined;
|
|
3519
|
+
}
|
|
3520
|
+
openBatches.delete(sessionId);
|
|
3521
|
+
batch.steered = true;
|
|
3522
|
+
// Its place on the chain runs nothing now, so it is not ahead of a batch
|
|
3523
|
+
// opening behind it, which can then be steered at a later step.
|
|
3524
|
+
releaseQueued(sessionId);
|
|
3525
|
+
// Not waiting any more; the leader's place on the chain does not count it again.
|
|
3526
|
+
const left = (waitingDispatches.get(sessionId) ?? all.length) - all.length;
|
|
3527
|
+
if (left > 0) {
|
|
3528
|
+
waitingDispatches.set(sessionId, left);
|
|
3529
|
+
}
|
|
3530
|
+
else {
|
|
3531
|
+
waitingDispatches.delete(sessionId);
|
|
3532
|
+
}
|
|
3533
|
+
const runnable = [];
|
|
3534
|
+
for (const part of all) {
|
|
3535
|
+
if (part.input.signal?.aborted) {
|
|
3536
|
+
part.reject(abortErrorFor(part.input.signal));
|
|
3537
|
+
continue;
|
|
3538
|
+
}
|
|
3539
|
+
runnable.push(part);
|
|
3540
|
+
}
|
|
3541
|
+
if (runnable.length === 0) {
|
|
3542
|
+
return undefined;
|
|
3543
|
+
}
|
|
3544
|
+
const keys = runnable.flatMap((part) => (part.input.idempotencyKey !== undefined ? [part.input.idempotencyKey] : []));
|
|
3545
|
+
const items = liveWorkItems.get(sessionId);
|
|
3546
|
+
for (const key of keys) {
|
|
3547
|
+
const item = items?.get(key);
|
|
3548
|
+
if (item !== undefined) {
|
|
3549
|
+
item.confirmed = true;
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
into.push(...runnable);
|
|
3553
|
+
const labelled = runnable.some((part) => part.input.metadata !== undefined && SENDER_TRUST_METADATA_KEY in part.input.metadata);
|
|
3554
|
+
const lowest = leastTrusted(...runnable.map((part) => senderTrustOf(part.input.metadata)));
|
|
3555
|
+
const images = runnable.flatMap((part) => part.input.images ?? []);
|
|
3556
|
+
log(`${sessionId}: ${runnable.length} message(s) sent during the running turn reached it at its next step`);
|
|
3557
|
+
return {
|
|
3558
|
+
userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
|
|
3559
|
+
...(images.length > 0 ? { images } : {}),
|
|
3560
|
+
...(labelled ? { metadata: { [SENDER_TRUST_METADATA_KEY]: lowest } } : {}),
|
|
3561
|
+
...(keys.length > 0 ? { keys } : {}),
|
|
3562
|
+
};
|
|
3563
|
+
};
|
|
3187
3564
|
/**
|
|
3188
3565
|
* Settles a keyed item the last process left unfinished before any other
|
|
3189
3566
|
* message joins its session. Channels are up before the sweeps reach it,
|
|
@@ -3196,7 +3573,7 @@ export const createGateway = (options = {}) => {
|
|
|
3196
3573
|
*/
|
|
3197
3574
|
const settleOrphanedItem = async (sessionId) => {
|
|
3198
3575
|
const session = await store.get(sessionId);
|
|
3199
|
-
const key = session
|
|
3576
|
+
const key = session === undefined ? undefined : turnKeyOf(turnMessageOf(session));
|
|
3200
3577
|
if (session !== undefined && key !== undefined && workItemState(session, key) === 'unfinished') {
|
|
3201
3578
|
// A continuation that fails has failed its session, which is settled
|
|
3202
3579
|
// too; the message waiting behind it runs either way.
|
|
@@ -3227,9 +3604,18 @@ export const createGateway = (options = {}) => {
|
|
|
3227
3604
|
* stops that, and it only holds if everything that writes a session goes
|
|
3228
3605
|
* through it.
|
|
3229
3606
|
*/
|
|
3230
|
-
const onSessionChain = (sessionId, work
|
|
3607
|
+
const onSessionChain = (sessionId, work,
|
|
3608
|
+
/** True once this work no longer counts as queued: a batch steered into the turn ahead (`takeSteered` released it). */
|
|
3609
|
+
released) => {
|
|
3231
3610
|
const previous = sessionChains.get(sessionId) ?? Promise.resolve();
|
|
3232
|
-
|
|
3611
|
+
queuedWork.set(sessionId, (queuedWork.get(sessionId) ?? 0) + 1);
|
|
3612
|
+
const start = () => {
|
|
3613
|
+
if (released?.() !== true) {
|
|
3614
|
+
releaseQueued(sessionId);
|
|
3615
|
+
}
|
|
3616
|
+
return work();
|
|
3617
|
+
};
|
|
3618
|
+
const turn = previous.then(start, start);
|
|
3233
3619
|
const settled = turn.catch(() => { });
|
|
3234
3620
|
sessionChains.set(sessionId, settled);
|
|
3235
3621
|
inflight.add(settled);
|
|
@@ -3438,7 +3824,16 @@ export const createGateway = (options = {}) => {
|
|
|
3438
3824
|
// Otherwise it leads a new batch, if it is waiting behind a turn at all:
|
|
3439
3825
|
// a message to an idle session runs at once, alone.
|
|
3440
3826
|
const batch = mergeable && sessionChains.has(input.sessionId)
|
|
3441
|
-
? {
|
|
3827
|
+
? {
|
|
3828
|
+
agentId: input.agentId,
|
|
3829
|
+
// Settled through `settleLeader`, read when called: it is assigned below.
|
|
3830
|
+
leader: { input, resolve: (session) => settleLeader({ session }), reject: (error) => settleLeader({ error }) },
|
|
3831
|
+
firstInLine: (queuedWork.get(input.sessionId) ?? 0) === 0,
|
|
3832
|
+
absorbed: [],
|
|
3833
|
+
steered: false,
|
|
3834
|
+
images: [...(input.images ?? [])],
|
|
3835
|
+
chars: input.userMessage.length,
|
|
3836
|
+
}
|
|
3442
3837
|
: undefined;
|
|
3443
3838
|
if (batch !== undefined) {
|
|
3444
3839
|
openBatches.set(input.sessionId, batch);
|
|
@@ -3540,7 +3935,7 @@ export const createGateway = (options = {}) => {
|
|
|
3540
3935
|
activeTurns.set(input.sessionId, merged.turnId);
|
|
3541
3936
|
}
|
|
3542
3937
|
try {
|
|
3543
|
-
outcome = { session: await
|
|
3938
|
+
outcome = { session: await runSteerable(merged, absorbedKeys) };
|
|
3544
3939
|
}
|
|
3545
3940
|
catch (error) {
|
|
3546
3941
|
outcome = { error };
|
|
@@ -3574,7 +3969,59 @@ export const createGateway = (options = {}) => {
|
|
|
3574
3969
|
settleLeader = (outcome) => ('error' in outcome ? reject(outcome.error) : resolve(outcome.session));
|
|
3575
3970
|
});
|
|
3576
3971
|
waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
|
|
3972
|
+
/**
|
|
3973
|
+
* Runs this dispatch's turn, handing it whatever queues behind it at
|
|
3974
|
+
* each tool step (`takeSteered`), then settles every dispatch it took
|
|
3975
|
+
* with the turn's outcome — the outcome they joined. Only for a caller
|
|
3976
|
+
* that opted into merged turns: it is the one that renders an answer
|
|
3977
|
+
* covering messages other than its own.
|
|
3978
|
+
*/
|
|
3979
|
+
const runSteerable = async (turnInput, absorbedKeys = []) => {
|
|
3980
|
+
const steered = [];
|
|
3981
|
+
// Cancelled only when every caller in it is, as a merged turn is, the
|
|
3982
|
+
// ones steered in later included: one caller giving up must not take
|
|
3983
|
+
// the others' answer with it. A turn with a caller who cannot cancel
|
|
3984
|
+
// has no signal to join.
|
|
3985
|
+
const joint = turnInput.signal !== undefined ? joinableSignal([turnInput.signal]) : undefined;
|
|
3986
|
+
const steer = mergeable
|
|
3987
|
+
? () => takeSteered(input.sessionId, input.agentId, steered, (parts) => {
|
|
3988
|
+
if (joint?.signal.aborted === true) {
|
|
3989
|
+
return false;
|
|
3990
|
+
}
|
|
3991
|
+
for (const part of parts) {
|
|
3992
|
+
joint?.join(part.input.signal);
|
|
3993
|
+
}
|
|
3994
|
+
return true;
|
|
3995
|
+
})
|
|
3996
|
+
: undefined;
|
|
3997
|
+
const running = joint !== undefined ? { ...turnInput, signal: joint.signal } : turnInput;
|
|
3998
|
+
let outcome;
|
|
3999
|
+
try {
|
|
4000
|
+
outcome = { session: await dispatchInternal(running, absorbedKeys, undefined, steer) };
|
|
4001
|
+
}
|
|
4002
|
+
catch (error) {
|
|
4003
|
+
outcome = { error };
|
|
4004
|
+
}
|
|
4005
|
+
for (const part of steered) {
|
|
4006
|
+
part.input.onAbsorbed?.();
|
|
4007
|
+
if ('session' in outcome) {
|
|
4008
|
+
part.resolve(outcome.session);
|
|
4009
|
+
}
|
|
4010
|
+
else {
|
|
4011
|
+
part.reject(outcome.error);
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
4014
|
+
if ('error' in outcome) {
|
|
4015
|
+
throw outcome.error;
|
|
4016
|
+
}
|
|
4017
|
+
return outcome.session;
|
|
4018
|
+
};
|
|
3577
4019
|
const chained = onSessionChain(input.sessionId, async () => {
|
|
4020
|
+
// Steered into the turn that ran ahead of it, which has answered and
|
|
4021
|
+
// settled everyone in the batch already — this caller included.
|
|
4022
|
+
if (batch?.steered === true) {
|
|
4023
|
+
return turn;
|
|
4024
|
+
}
|
|
3578
4025
|
// The batch closes here: anything arriving from now on waits behind
|
|
3579
4026
|
// this turn and leads the next one.
|
|
3580
4027
|
if (batch !== undefined && openBatches.get(input.sessionId) === batch) {
|
|
@@ -3609,11 +4056,11 @@ export const createGateway = (options = {}) => {
|
|
|
3609
4056
|
}
|
|
3610
4057
|
await settleOrphanedItem(input.sessionId);
|
|
3611
4058
|
if (turnId === undefined) {
|
|
3612
|
-
return
|
|
4059
|
+
return runSteerable(input);
|
|
3613
4060
|
}
|
|
3614
4061
|
activeTurns.set(input.sessionId, turnId);
|
|
3615
4062
|
try {
|
|
3616
|
-
return await
|
|
4063
|
+
return await runSteerable(input);
|
|
3617
4064
|
}
|
|
3618
4065
|
finally {
|
|
3619
4066
|
// Unconditional, and safe to be: the chain guarantees no other turn
|
|
@@ -3621,7 +4068,7 @@ export const createGateway = (options = {}) => {
|
|
|
3621
4068
|
// only ever be clearing its own entry.
|
|
3622
4069
|
activeTurns.delete(input.sessionId);
|
|
3623
4070
|
}
|
|
3624
|
-
});
|
|
4071
|
+
}, () => batch?.steered === true);
|
|
3625
4072
|
// A second settle (a leader already settled early) is a no-op.
|
|
3626
4073
|
chained.then((session) => settleLeader({ session }), (error) => settleLeader({ error }));
|
|
3627
4074
|
if (idempotencyKey !== undefined) {
|
|
@@ -3754,6 +4201,41 @@ export const createGateway = (options = {}) => {
|
|
|
3754
4201
|
onRead: (method) => log(`fleet read: ${method} by plugin ${packageName}`),
|
|
3755
4202
|
}),
|
|
3756
4203
|
providerCallGuards,
|
|
4204
|
+
eventSources: {
|
|
4205
|
+
reserved: Object.keys(eventSources),
|
|
4206
|
+
register: (packageName, sourceIds) => {
|
|
4207
|
+
if (!eventStore) {
|
|
4208
|
+
// The first source on a daemon whose config has none: the
|
|
4209
|
+
// scheduler was built without events, so it takes them now,
|
|
4210
|
+
// before its start-up pass reads what is waiting. First, so a
|
|
4211
|
+
// store that won't open refuses the plugin with no source left
|
|
4212
|
+
// behind.
|
|
4213
|
+
scheduler.attachEvents({ store: openEvents().store, sources: eventSourceIds });
|
|
4214
|
+
}
|
|
4215
|
+
for (const sourceId of sourceIds) {
|
|
4216
|
+
pluginEventSources.set(sourceId, packageName);
|
|
4217
|
+
}
|
|
4218
|
+
},
|
|
4219
|
+
ingest: async (packageName, sourceId, event) => {
|
|
4220
|
+
if (pluginEventSources.get(sourceId) !== packageName) {
|
|
4221
|
+
throw new Error(`Plugin ${packageName} does not feed event source "${sourceId}" on this daemon.`);
|
|
4222
|
+
}
|
|
4223
|
+
// The store closes last in a stop. Until then an event is kept
|
|
4224
|
+
// and goes out from the next start; after, refused, so a poller
|
|
4225
|
+
// keeps its cursor where it was.
|
|
4226
|
+
if (storesClosed) {
|
|
4227
|
+
throw new Error('stratusd has stopped, so this event was not stored. Feed it again once the daemon is back.');
|
|
4228
|
+
}
|
|
4229
|
+
const answer = openEvents().intake.ingest(sourceId, event);
|
|
4230
|
+
// Sent now rather than at the next tick; the scheduler decides
|
|
4231
|
+
// hold or fire, and does nothing while it is stopped or not yet
|
|
4232
|
+
// started.
|
|
4233
|
+
if (answer.status === 'matched') {
|
|
4234
|
+
scheduler.kickEvents();
|
|
4235
|
+
}
|
|
4236
|
+
return answer;
|
|
4237
|
+
},
|
|
4238
|
+
},
|
|
3757
4239
|
});
|
|
3758
4240
|
loadedPlugins = result.loaded;
|
|
3759
4241
|
pluginFailures = result.failures;
|
|
@@ -3766,6 +4248,7 @@ export const createGateway = (options = {}) => {
|
|
|
3766
4248
|
...plugin.contributions.memory.map((name) => `memory store ${name}`),
|
|
3767
4249
|
...plugin.contributions.executors.map((name) => `executor ${name}`),
|
|
3768
4250
|
...(plugin.providerCallGuards > 0 ? ['guards model calls'] : []),
|
|
4251
|
+
...plugin.eventSources.map((sourceId) => `event source ${sourceId}`),
|
|
3769
4252
|
];
|
|
3770
4253
|
log(`plugin ${plugin.package} loaded — ${contributed.join(', ') || 'no tools'}`);
|
|
3771
4254
|
}
|
|
@@ -4448,12 +4931,23 @@ export const createGateway = (options = {}) => {
|
|
|
4448
4931
|
return registry.list();
|
|
4449
4932
|
},
|
|
4450
4933
|
async reloadRoster() {
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4934
|
+
return (await gateway.reloadRosterAndChannels()).agents;
|
|
4935
|
+
},
|
|
4936
|
+
async reloadRosterAndChannels() {
|
|
4937
|
+
let roster = [];
|
|
4938
|
+
let channels = [];
|
|
4939
|
+
await reloadsInOrder(async () => {
|
|
4940
|
+
// Under the soul lock, so a repoint cannot land between a credential
|
|
4941
|
+
// grant's check of the served path and its write (`grantSoulCredential`).
|
|
4942
|
+
await withSoulFileLock(loadRoster);
|
|
4943
|
+
roster = registry.list();
|
|
4944
|
+
log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
|
|
4945
|
+
// Outside the soul lock, which guards files and must not wait on a
|
|
4946
|
+
// network; inside the reload chain, so two reloads never interleave
|
|
4947
|
+
// their channel work (the bound keeps one from holding it).
|
|
4948
|
+
channels = await reloadChannels();
|
|
4949
|
+
});
|
|
4950
|
+
return { agents: roster, channels };
|
|
4457
4951
|
},
|
|
4458
4952
|
async reloadSkills() {
|
|
4459
4953
|
try {
|
|
@@ -4559,7 +5053,13 @@ export const createGateway = (options = {}) => {
|
|
|
4559
5053
|
if (!eventStore || !eventIntake) {
|
|
4560
5054
|
return { since, sources: {}, unknownSource: 0 };
|
|
4561
5055
|
}
|
|
4562
|
-
return summarizeEvents({
|
|
5056
|
+
return summarizeEvents({
|
|
5057
|
+
sourceIds: eventSourceIds(),
|
|
5058
|
+
plugins: Object.fromEntries(pluginEventSources),
|
|
5059
|
+
since,
|
|
5060
|
+
activity: eventStore.activity(since),
|
|
5061
|
+
counters: eventIntake.counters(),
|
|
5062
|
+
});
|
|
4563
5063
|
},
|
|
4564
5064
|
eventDeliveries(options = {}) {
|
|
4565
5065
|
return eventStore?.deliveries(options) ?? [];
|