@stratusagent/gateway 0.14.6 → 0.14.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,25 +6,30 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- import { createHash, randomBytes, randomUUID } from 'node:crypto';
10
- import { mkdir, readFile, rename, rm } from 'node:fs/promises';
11
- import path from 'node:path';
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, InMemorySessionStore, MEMORY_PASS_MAX_MODEL_CALLS, MEMORY_PASS_RULES, 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, resolveToolRisk, } from '@stratusagent/core';
13
- import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, DELEGATE_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageSayTool, 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';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ 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, InMemorySessionStore, MEMORY_PASS_MAX_MODEL_CALLS, MEMORY_PASS_RULES, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, PENDING_APPROVAL_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, imagesWithinReplayBudget, matchesToolAllowlist, resolveToolRisk, } from '@stratusagent/core';
11
+ import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, createForgetTool, createPinTool, createMessageSendTool, createMessageSayTool, 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, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, credentialVariableProblem, } from '@stratusagent/agents';
14
12
  import { createFleetReader } from "./fleet.js";
15
13
  import { createMemoryPassHost } from "./memory-passes.js";
14
+ import { callShownWhole, readReviewReply, REVIEW_RULES, reviewCaseText } from "./review.js";
16
15
  import { createEventIntake, summarizeEvents, } from "./event-intake.js";
17
16
  import { SqliteEventStore } from "./events.js";
18
17
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
19
18
  import { ShardedSessionStore } from "./sessions.js";
19
+ import { DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS, DEFAULT_PAUSED_MESSAGE, DEFAULT_RESTART_DRAIN_TIMEOUT_MS, DEFAULT_STOP_DRAIN_TIMEOUT_MS, GatewayStoppingError, HELD_MESSAGE, IntakePausedError, MAX_PAUSED_MESSAGE_LENGTH, PauseMessageError, readIntakeState, RESTARTING_TURN_ERROR, RestartUnsupportedError, STOPPING_TURN_ERROR, writeIntakeState, } from "./lifecycle.js";
20
+ import { ABANDONED_TURN_ERROR, crossIdentityError, CrossIdentityError, DEFAULT_SUB_AGENTS_PER_TURN, EXECUTOR_METADATA_KEY, isSubAgentSession, ORPHANED_DELEGATION_ERROR, RESERVED_SESSION_METADATA_KEYS, reservedSessionMetadataKey, ROLLED_OVER_FROM_METADATA_KEY, ROLLED_OVER_SESSION_ID_MARKER, ROLLED_OVER_TO_METADATA_KEY, ROLLOVER_REPEAT_WINDOW_MS, SUB_AGENT_METADATA_KEY, SUB_AGENT_SESSION_ID_MARKER, subAgentNeverGets, } from "./session-keys.js";
20
21
  export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
21
22
  export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTakenError, tightenSqliteFile, } from "./sessions.js";
22
23
  export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
23
24
  export { SqliteEventStore, EVENT_BLOCK_MAX_BYTES, EVENT_DEDUP_WINDOW_MS, EVENT_DELIVERY_ROW_CAP, eventCoalescingKey, eventDispatchKey, eventSessionId, renderEventBlock, } from "./events.js";
25
+ // The review posture's fixed prompt and case, for the review-step eval
26
+ // (`packages/cli/eval/review-step`), which measures the prompt production
27
+ // sends rather than a copy of it.
28
+ export { REVIEW_RULES, readReviewReply, reviewCaseText } from "./review.js";
24
29
  export { createEventIntake, mapGithubDelivery, parseStandardWebhookEvent, verifyGithubSignature, verifyStandardWebhookSignature, DEFAULT_EVENT_MAX_BODY_BYTES, EVENT_SOURCE_REQUESTS_PER_MINUTE, } from "./event-intake.js";
25
30
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
26
31
  import { loadPlugins, } from '@stratusagent/plugins';
27
- import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, resolveDaemonCredential, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, appendCredentialAudit, replaceNamedCredential, NamedCredentialMissingError, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, unlistedToolsFor, stratusHomePath, intakeStatePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, syncDirectory, writeFileSynced, } from '@stratusagent/state';
32
+ import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, credentialValuesFor, resolveDaemonCredential, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, appendCredentialAudit, replaceNamedCredential, NamedCredentialMissingError, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, unlistedToolsFor, stratusHomePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, } from '@stratusagent/state';
28
33
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
29
34
  /**
30
35
  * How long a parked call waits for a person. Long enough to survive a
@@ -48,142 +53,6 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647;
48
53
  * question even when the rule answering it is not gateway code.
49
54
  */
50
55
  export { applySoulPins } from '@stratusagent/state';
51
- /** What a turn cut short by an announced restart records as its failure. */
52
- export const RESTARTING_TURN_ERROR = 'Run aborted: stratusd is restarting';
53
- /** The error a turn still running when a stop's drain window closes is aborted with. */
54
- export const STOPPING_TURN_ERROR = 'Run aborted: stratusd is stopping';
55
- /**
56
- * How long an announced restart lets in-flight turns finish before aborting
57
- * them. The same as a stop's window (`DEFAULT_STOP_DRAIN_TIMEOUT_MS`), so the
58
- * two windows an operator can hit agree.
59
- */
60
- export const DEFAULT_RESTART_DRAIN_TIMEOUT_MS = 30_000;
61
- /** How long a roster reload waits on one channel's `reload()`. */
62
- export const DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS = 30_000;
63
- /**
64
- * How long a stop lets in-flight turns finish before aborting them. The
65
- * daemon's documented drain ("up to 30 seconds"), and shorter than the
66
- * service units' stop timeouts, so the abort happens in this process
67
- * rather than as the manager's SIGKILL. That matters beyond the turn's
68
- * record: a shell command runs in its own process group, which launchd's
69
- * kill of the job does not reach, so only the turn's abort stops it.
70
- */
71
- export const DEFAULT_STOP_DRAIN_TIMEOUT_MS = 30_000;
72
- /** What a paused daemon says when nobody gave it a sentence of its own. */
73
- export const DEFAULT_PAUSED_MESSAGE = "I'm paused right now and not taking new work. Please try again later.";
74
- /** What a held gateway answers a dispatch with, before it is promoted. */
75
- export const HELD_MESSAGE = "I'm starting up and not taking work yet. Please try again in a moment.";
76
- /** The longest pause message, so a reply to a refused message stays one. */
77
- export const MAX_PAUSED_MESSAGE_LENGTH = 500;
78
- /** A pause message the gateway will not take: empty, or too long. */
79
- export class PauseMessageError extends Error {
80
- constructor(message) {
81
- super(message);
82
- this.name = 'PauseMessageError';
83
- }
84
- }
85
- /**
86
- * A dispatch refused because intake is paused. Its message is the sentence
87
- * to show whoever sent the work, as it is: a channel posts it as the reply,
88
- * not as a failure.
89
- */
90
- export class IntakePausedError extends Error {
91
- constructor(message) {
92
- super(message);
93
- this.name = 'IntakePausedError';
94
- }
95
- }
96
- /**
97
- * A dispatch refused because the daemon is restarting or stopping. Channels
98
- * stay connected while a restart drains, so they hear people during it, and
99
- * this is what tells them the message reached a daemon on its way down: a
100
- * channel can say so and keep the message for the next one. Matched by name
101
- * there, as channel packages do not import the gateway.
102
- */
103
- export class GatewayStoppingError extends Error {
104
- /** True for a restart, which comes back on its own; false for a stop. */
105
- restarting;
106
- constructor(message, restarting) {
107
- super(message);
108
- this.name = 'GatewayStoppingError';
109
- this.restarting = restarting;
110
- }
111
- }
112
- /**
113
- * The intake state a previous process left, or open when there is none.
114
- *
115
- * Fails closed: a file that exists but cannot be read or parsed starts the
116
- * daemon paused, with a warning naming it. A pause exists to stop work (a
117
- * spending cap, a maintenance window), and a file nobody can read is no
118
- * evidence that it was lifted.
119
- */
120
- const readIntakeState = async (env, warn) => {
121
- const file = intakeStatePath(env);
122
- let raw;
123
- try {
124
- raw = await readFile(file, 'utf8');
125
- }
126
- catch (error) {
127
- if (error.code === 'ENOENT') {
128
- return { paused: false };
129
- }
130
- warn(`intake: ${file} could not be read (${error.message}); starting paused. Resume intake to clear it.`);
131
- return { paused: true, since: new Date().toISOString(), unreadable: true };
132
- }
133
- try {
134
- const parsed = JSON.parse(raw);
135
- // Only a pause is ever written here (resuming removes the file), so
136
- // anything else in it is not a record this build wrote, and the same
137
- // rule as an unreadable file applies.
138
- if (typeof parsed !== 'object' || parsed === null || parsed.paused !== true) {
139
- warn(`intake: ${file} does not hold a pause record; starting paused. Resume intake to clear it.`);
140
- return { paused: true, since: new Date().toISOString(), unreadable: true };
141
- }
142
- return {
143
- paused: true,
144
- ...(typeof parsed.message === 'string' && parsed.message.trim().length > 0 ? { message: parsed.message } : {}),
145
- since: typeof parsed.since === 'string' ? parsed.since : new Date().toISOString(),
146
- };
147
- }
148
- catch {
149
- warn(`intake: ${file} is not valid JSON; starting paused. Resume intake to clear it.`);
150
- return { paused: true, since: new Date().toISOString(), unreadable: true };
151
- }
152
- };
153
- /**
154
- * Record a pause durably (0600, replaced in one atomic step), or remove the
155
- * record when intake reopens: open is the default, so nothing on disk
156
- * means open.
157
- */
158
- const writeIntakeState = async (env, state) => {
159
- const file = intakeStatePath(env);
160
- if (!state.paused) {
161
- await rm(file, { force: true });
162
- return;
163
- }
164
- await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
165
- const staging = `${file}.${randomBytes(8).toString('hex')}`;
166
- try {
167
- await writeFileSynced(staging, `${JSON.stringify(state)}\n`, { flag: 'wx', mode: 0o600 });
168
- await rename(staging, file);
169
- await syncDirectory(path.dirname(file));
170
- }
171
- finally {
172
- await rm(staging, { force: true });
173
- }
174
- };
175
- /**
176
- * `restart()` on a gateway whose host gave it no way back. Exported so the
177
- * control API can answer it as "not supported here" rather than as a
178
- * failure of the daemon.
179
- */
180
- export class RestartUnsupportedError extends Error {
181
- constructor() {
182
- super('This daemon cannot restart itself: the host that started it did not say how. '
183
- + 'Restart it from the service manager (stratus service stop, then start) or from the terminal it runs in.');
184
- this.name = 'RestartUnsupportedError';
185
- }
186
- }
187
56
  /**
188
57
  * The most text one merged user message may hold (card 423). The runner's
189
58
  * overflow recovery can drop earlier transcript but never part of the
@@ -253,143 +122,6 @@ const formCredentialValue = (value) => value.replace(/^[\r\n]+|[\r\n]+$/g, '');
253
122
  * so it has none of its own and is fine as sent.
254
123
  */
255
124
  const isFlattenedPem = (value) => /^\s*-----BEGIN [A-Z0-9 ]+-----/.test(value) && !value.includes('\n');
256
- /**
257
- * Recorded on a turn the previous process was still running when it died.
258
- *
259
- * Exported because "distinguishable" is the whole point: a surface, a
260
- * test, or an operator reading `lastError` has to be able to tell a turn
261
- * nobody finished apart from one a provider or a tool actually failed, and
262
- * matching on prose that lives in one place beats each caller inventing
263
- * its own guess at the wording.
264
- */
265
- export const ABANDONED_TURN_ERROR = 'stratusd stopped while this turn was still running; it was not resumed. Send the message again.';
266
- /**
267
- * The longest `DispatchInput.idempotencyKey` accepted. A platform message id
268
- * is far shorter; the bound is so a key cannot be used to grow every row it
269
- * is stored on.
270
- */
271
- /**
272
- * Recorded on a delegated sub-session that was parked on a human when the
273
- * previous process died.
274
- *
275
- * Its parent — the turn whose `agent.delegate` call was awaiting the reply
276
- * — was running, so the restart settles it as abandoned (above). Recovering
277
- * the child anyway would re-ask a person to approve a call whose result no
278
- * turn will ever read, and then run it: a command executed, tokens spent,
279
- * and a reply delivered to nobody. Distinguishable from the parent's error
280
- * because the remedy differs — there is no message to send again here;
281
- * the parent's is the one to repeat.
282
- */
283
- /**
284
- * Session metadata keys the daemon writes for itself, which no external
285
- * dispatch may supply.
286
- *
287
- * Metadata is one bag: the keys a channel attaches to say where a turn is
288
- * happening sit beside the ones the kernel, the scheduler, the fallback
289
- * wrapper, and `agent.delegate` write to remember what a session *is* —
290
- * and the control API forwards a caller's `metadata` into a new session as
291
- * given. A caller who can write `delegatedBy` can have the restart sweep
292
- * fail its own parked turn as an orphan; one who can write `fallbackActive`
293
- * bills its conversation to the fallback model from the first turn. The
294
- * scheduler already refused to trust its own keys for exactly this reason
295
- * (see `activeFirings`); the durable ones cannot be re-derived in memory,
296
- * so they are refused at the door instead.
297
- */
298
- /**
299
- * A rolled-over session: the fresh row records where its predecessor's
300
- * transcript went, and the archived row records which live id continues
301
- * the conversation. See `Gateway.rolloverSession`.
302
- */
303
- export const ROLLED_OVER_FROM_METADATA_KEY = 'rolledOverFrom';
304
- export const ROLLED_OVER_TO_METADATA_KEY = 'rolledOverTo';
305
- /** The segment a rollover mints into the archived transcript's id. */
306
- export const ROLLED_OVER_SESSION_ID_MARKER = ':rolledover:';
307
- /**
308
- * How long after a rollover a repeated key is still looked for in the
309
- * archived transcript. A rollover moves every keyed message out of the
310
- * live row, so a redelivery arriving after one would otherwise read as a
311
- * new message and run again. Platforms redeliver within minutes — Slack
312
- * gives up after about five — and past the window the archive is not
313
- * loaded at all, since every new message to the fresh row would pay for a
314
- * read that can no longer find anything.
315
- */
316
- const ROLLOVER_REPEAT_WINDOW_MS = 60 * 60 * 1000;
317
- /**
318
- * The one refusal for a dispatch naming an agent other than its session's.
319
- * Its own type so a repeat attached to a claimant can tell the claimant was
320
- * refused for who it named — the item then still has no owner — from the
321
- * claimant's turn failing. Internal: callers read the message.
322
- */
323
- class CrossIdentityError extends Error {
324
- constructor(sessionId, owner, claimed) {
325
- super(`Session ${sessionId} belongs to agent ${owner}, not ${claimed} — sessions never cross agent identities.`);
326
- this.name = 'CrossIdentityError';
327
- }
328
- }
329
- const crossIdentityError = (sessionId, owner, claimed) => new CrossIdentityError(sessionId, owner, claimed);
330
- /** Where the session's tool calls ran: `local-command`, or a contributed executor's registered name. */
331
- export const EXECUTOR_METADATA_KEY = 'executor';
332
- /**
333
- * Marks a sub-agent's session (roadmap 24). Reserved: it is what makes the
334
- * daemon deny the session's gated calls without asking anyone, so a caller
335
- * that could set it could also clear it.
336
- */
337
- export const SUB_AGENT_METADATA_KEY = 'subAgent';
338
- export const SUB_AGENT_SESSION_ID_MARKER = ':subagent:';
339
- /** How many sub-agents one parent turn runs at once unless the host says otherwise. */
340
- export const DEFAULT_SUB_AGENTS_PER_TURN = 4;
341
- export const isSubAgentSession = (session) => session.metadata?.[SUB_AGENT_METADATA_KEY] === true && session.id.includes(SUB_AGENT_SESSION_ID_MARKER);
342
- /**
343
- * Tools a sub-agent never gets, whatever its parent holds. `agent.spawn`
344
- * would fan out inside a fan-out; `agent.delegate` runs a roster agent
345
- * under that agent's own allowlist, which is the way out of a narrowed
346
- * slice; `memory.*` would let it persist what nobody reviewed; and
347
- * `credential.request` asks a person, which a sub-agent never does.
348
- */
349
- const subAgentNeverGets = (name) => name === 'agent.spawn'
350
- || name === DELEGATE_TOOL_NAME
351
- || name.startsWith('memory.')
352
- || name === CREDENTIAL_REQUEST_TOOL_NAME;
353
- export const RESERVED_SESSION_METADATA_KEYS = [
354
- PENDING_APPROVAL_METADATA_KEY,
355
- FALLBACK_ACTIVE_METADATA_KEY,
356
- DELEGATED_BY_METADATA_KEY,
357
- ROOT_SESSION_ID_METADATA_KEY,
358
- DELEGATION_DEPTH_METADATA_KEY,
359
- SCHEDULED_TURN_METADATA_KEY,
360
- SCHEDULE_ID_METADATA_KEY,
361
- // The trust label is the runner's to write and only ever goes down; a
362
- // caller who could seed it would be seeding it upward.
363
- SESSION_TRUST_METADATA_KEY,
364
- SESSION_TAINTED_BY_METADATA_KEY,
365
- ROLLED_OVER_FROM_METADATA_KEY,
366
- ROLLED_OVER_TO_METADATA_KEY,
367
- // Which executor ran the turn's tool calls is the daemon's record of
368
- // where a command ran, and the one audit question a caller must not be
369
- // able to answer for it.
370
- EXECUTOR_METADATA_KEY,
371
- // The context floor is an index into a specific transcript, so it means
372
- // nothing next to a different one. Reserved for both halves of that: a
373
- // caller cannot seed it, and a rollover drops it with the transcript it
374
- // indexed — otherwise the fresh session inherits an absolute floor over
375
- // no messages and, once it grows past that index, silently stops sending
376
- // its own earliest turns, with no overflow and no trim event to say so.
377
- CONTEXT_FLOOR_METADATA_KEY,
378
- // The summary that stands in below that floor, for both of the same
379
- // reasons, and one more: the model reads it as something it wrote itself,
380
- // so a caller who could seed it could put words in the agent's mouth.
381
- CONTEXT_SUMMARY_METADATA_KEY,
382
- SUB_AGENT_METADATA_KEY,
383
- ];
384
- /**
385
- * The first reserved key `metadata` carries, or undefined when it carries
386
- * none. Exported so the control API can refuse the request where the
387
- * caller can see it, with the same rule the gateway applies at dispatch.
388
- */
389
- export const reservedSessionMetadataKey = (metadata) => metadata === undefined
390
- ? undefined
391
- : RESERVED_SESSION_METADATA_KEYS.find((key) => Object.prototype.hasOwnProperty.call(metadata, key));
392
- export const ORPHANED_DELEGATION_ERROR = 'stratusd stopped while the turn that delegated this one was still running; the delegation was not resumed. Send the original message again.';
393
125
  /**
394
126
  * The always-on Stratus process. One gateway holds the roster, the durable
395
127
  * session store, the shared event bus, and a pool of runners keyed by
@@ -766,6 +498,7 @@ export const createGateway = (options = {}) => {
766
498
  ...(request.origin !== undefined ? { origin: request.origin } : {}),
767
499
  ...(oneShot ? { oneShot: true } : {}),
768
500
  ...(always !== undefined ? { always } : {}),
501
+ ...(request.reviewNote !== undefined ? { reviewNote: request.reviewNote } : {}),
769
502
  parkedAt: request.parkedAt ?? new Date().toISOString(),
770
503
  ...(expiresAt ? { expiresAt } : {}),
771
504
  ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
@@ -816,6 +549,7 @@ export const createGateway = (options = {}) => {
816
549
  ...(request.origin !== undefined ? { origin: request.origin } : {}),
817
550
  ...(oneShot ? { oneShot: true } : {}),
818
551
  ...(always !== undefined ? { always } : {}),
552
+ ...(request.reviewNote !== undefined ? { reviewNote: request.reviewNote } : {}),
819
553
  ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
820
554
  ...(delegatedFrom ? { delegatedFrom } : {}),
821
555
  ...(scheduleDestination ? { scheduleDestination } : {}),
@@ -886,6 +620,17 @@ export const createGateway = (options = {}) => {
886
620
  const configuredApprovals = typeof options.approvals === 'function'
887
621
  ? options.approvals({
888
622
  request: requestApproval,
623
+ // Late-bound: the side call is built further down this function.
624
+ review: (request) => reviewCall(request),
625
+ // Rejects when the soul or the credential store can't be read: the
626
+ // permission engine then asks a person, rather than checking the
627
+ // call against no secrets at all.
628
+ secretsFor: async (agentId) => {
629
+ const { definition } = await refreshAgent(agentId);
630
+ return credentialValuesFor(createFileCredentialResolver(env), definition);
631
+ },
632
+ // Late-bound like `review`: the set is declared with the side call.
633
+ offPath: (task) => keepUntilStopped(task),
889
634
  destinations: {
890
635
  isPreauthorized: (session, destination) => scheduler.isPreauthorized(session, destination),
891
636
  accountFor: (agentId, destination) => {
@@ -2760,6 +2505,25 @@ export const createGateway = (options = {}) => {
2760
2505
  * session sufficient.
2761
2506
  */
2762
2507
  const activeTurns = new Map();
2508
+ /**
2509
+ * The runtime each running turn resolved when it started, by session. A
2510
+ * reviewer call for one of its calls runs on it (`reviewCall`), so a soul
2511
+ * or config edited mid-turn can't send that turn's request to a provider
2512
+ * that wasn't serving it. Exact for the same reason `activeTurns` is: one
2513
+ * turn per session at a time, set and cleared inside the session chain.
2514
+ */
2515
+ const turnRuntimes = new Map();
2516
+ const onTurnRuntime = async (sessionId, runtime, work) => {
2517
+ turnRuntimes.set(sessionId, runtime);
2518
+ try {
2519
+ return await work();
2520
+ }
2521
+ finally {
2522
+ if (turnRuntimes.get(sessionId) === runtime) {
2523
+ turnRuntimes.delete(sessionId);
2524
+ }
2525
+ }
2526
+ };
2763
2527
  /**
2764
2528
  * Keyed dispatches not yet settled, by session and key — what a repeat
2765
2529
  * attaches to. In memory on purpose: it only has to cover the turns this
@@ -2981,7 +2745,7 @@ export const createGateway = (options = {}) => {
2981
2745
  // has switched for good, the primary is never called again, and only
2982
2746
  // the fallback counts.
2983
2747
  const hostedLoop = input.idempotencyKey !== undefined && reachesHarness(config, switchedToFallback);
2984
- return settleLeftForNextStart(input, withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
2748
+ return settleLeftForNextStart(input, onTurnRuntime(input.sessionId, { source, config }, () => withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
2985
2749
  // The preflight above (agent refresh, config resolution, session
2986
2750
  // load) awaited filesystem work; the entry check has long passed.
2987
2751
  // Recheck before the runner touches durable state, or a dispatch
@@ -3064,7 +2828,7 @@ export const createGateway = (options = {}) => {
3064
2828
  ...(steer !== undefined ? { steer: () => steer(signal) } : {}),
3065
2829
  signal,
3066
2830
  });
3067
- }));
2831
+ })));
3068
2832
  };
3069
2833
  /**
3070
2834
  * Finishes the turns that were parked on a human when this daemon last
@@ -3235,11 +2999,11 @@ export const createGateway = (options = {}) => {
3235
2999
  }
3236
3000
  }
3237
3001
  try {
3238
- await runner.recoverPendingApproval(sessionId, {
3002
+ await onTurnRuntime(sessionId, { source, config: recoveredConfig }, () => runner.recoverPendingApproval(sessionId, {
3239
3003
  denyPending: expired,
3240
3004
  runtime: runtimeContextFor(source, recoveredConfig, session.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true, session.metadata),
3241
3005
  signal: controller.signal,
3242
- });
3006
+ }));
3243
3007
  }
3244
3008
  finally {
3245
3009
  untrackTurn(sessionId, controller);
@@ -3449,10 +3213,10 @@ export const createGateway = (options = {}) => {
3449
3213
  session.agent = source.definition;
3450
3214
  await store.save(session);
3451
3215
  const runner = runnerFor(config, source.definition.id);
3452
- return withWatchdog(session.id, undefined, effectiveStreams, fallbackStreams, async (signal) => await runner.continueTurn(session.id, {
3216
+ return onTurnRuntime(session.id, { source, config }, () => withWatchdog(session.id, undefined, effectiveStreams, fallbackStreams, async (signal) => await runner.continueTurn(session.id, {
3453
3217
  runtime: runtimeContextFor(source, config, switchedToFallback, session.metadata),
3454
3218
  signal,
3455
- }) ?? session);
3219
+ }) ?? session));
3456
3220
  };
3457
3221
  /**
3458
3222
  * A repeated idempotency key that reached the session's chain, so nothing
@@ -3686,6 +3450,8 @@ export const createGateway = (options = {}) => {
3686
3450
  return turn;
3687
3451
  };
3688
3452
  // ---- memory passes (spec 962) -------------------------------------------
3453
+ /** The session ids of the side runs running now: reviewer calls and memory passes. */
3454
+ const sideRuns = new Set();
3689
3455
  /**
3690
3456
  * A pass's one model turn: the agent's provider and credentials, exactly
3691
3457
  * the model asked for and never the fallback (cost stays what the
@@ -3694,18 +3460,16 @@ export const createGateway = (options = {}) => {
3694
3460
  * go through the same provider-call guards as every turn, and its events
3695
3461
  * through the same bus, so a budget sees and counts them.
3696
3462
  */
3697
- const runMemoryPassModel = async (run) => {
3698
- const source = await refreshAgent(run.agentId);
3699
- const resolved = await runtimeForAgent(source);
3463
+ const runSideModel = async (run) => {
3464
+ const source = run.runtime?.source ?? await refreshAgent(run.agentId);
3465
+ const resolved = run.runtime?.config ?? await runtimeForAgent(source);
3700
3466
  const { fallback: _fallback, ...primary } = resolved;
3701
3467
  const config = (run.model !== undefined ? { ...primary, model: run.model } : primary);
3702
3468
  const tools = new ToolRegistry();
3703
3469
  for (const tool of run.tools) {
3704
3470
  tools.register(tool);
3705
3471
  }
3706
- // A step budget of n is n tool steps and one wrap-up, so this is the
3707
- // pass's model-call cap.
3708
- const maxTurns = MEMORY_PASS_MAX_MODEL_CALLS - 1;
3472
+ const { maxTurns } = run;
3709
3473
  let passRunner;
3710
3474
  const provider = createRuntimeProvider(config, () => { }, async (session, call, context) => {
3711
3475
  if (!passRunner) {
@@ -3724,25 +3488,126 @@ export const createGateway = (options = {}) => {
3724
3488
  const definition = {
3725
3489
  id: source.definition.id,
3726
3490
  name: source.definition.name,
3727
- instructions: MEMORY_PASS_RULES,
3491
+ instructions: run.instructions,
3728
3492
  tools: run.tools.map((tool) => tool.name),
3729
3493
  skills: [],
3730
3494
  delegates: [],
3731
3495
  ...(source.definition.credentials !== undefined ? { credentials: [...source.definition.credentials] } : {}),
3732
3496
  };
3733
- await passRunner.run({
3497
+ // Its events go on the shared bus like a turn's, for what counts provider
3498
+ // calls there (`session.usage`), and are known as a side run's for as
3499
+ // long as it runs (`isSideRun`), so a surface that shows conversations
3500
+ // can leave it out.
3501
+ sideRuns.add(run.sessionId);
3502
+ try {
3503
+ return await passRunner.run({
3504
+ sessionId: run.sessionId,
3505
+ agent: definition,
3506
+ userMessage: run.prompt,
3507
+ metadata: {
3508
+ provider: config.provider,
3509
+ ...(config.provider !== 'demo' && config.model !== undefined ? { model: config.model } : {}),
3510
+ [SENDER_TRUST_METADATA_KEY]: run.trust,
3511
+ },
3512
+ signal: run.signal,
3513
+ });
3514
+ }
3515
+ finally {
3516
+ sideRuns.delete(run.sessionId);
3517
+ }
3518
+ };
3519
+ const runMemoryPassModel = async (run) => {
3520
+ await runSideModel({
3521
+ agentId: run.agentId,
3734
3522
  sessionId: run.sessionId,
3735
- agent: definition,
3736
- userMessage: run.prompt,
3737
- metadata: {
3738
- provider: config.provider,
3739
- ...(config.provider !== 'demo' && config.model !== undefined ? { model: config.model } : {}),
3740
- // The slice's label: the pass reads at the conversation's trust.
3741
- [SENDER_TRUST_METADATA_KEY]: run.trust,
3742
- },
3523
+ model: run.model,
3524
+ instructions: MEMORY_PASS_RULES,
3525
+ prompt: run.prompt,
3526
+ // The slice's label: the pass reads at the conversation's trust.
3527
+ trust: run.trust,
3528
+ tools: run.tools,
3529
+ // A step budget of n is n tool steps and one wrap-up, so this is the
3530
+ // pass's model-call cap.
3531
+ maxTurns: MEMORY_PASS_MAX_MODEL_CALLS - 1,
3743
3532
  signal: run.signal,
3744
3533
  });
3745
3534
  };
3535
+ // ---- the review posture's reviewer (spec 964) ----------------------------
3536
+ /**
3537
+ * Reviewer calls still running, and the shadow reviews the policy hands
3538
+ * over whole (`ApprovalTransport.offPath`). A turn awaits its own, but a
3539
+ * shadow review runs off the call path and can outlast its turn, still
3540
+ * reading credentials before its reviewer call starts, and a provider
3541
+ * that ignores its abort after the review's timeout keeps one going with
3542
+ * nothing waiting on it. A shutdown aborts these once the turns have
3543
+ * drained and waits for them, so none is still using a provider when the
3544
+ * plugins are disposed, and no decision they report is lost.
3545
+ */
3546
+ const reviewsRunning = new Set();
3547
+ const stopReviews = new AbortController();
3548
+ const keepUntilStopped = (task) => {
3549
+ reviewsRunning.add(task);
3550
+ const untrack = () => {
3551
+ reviewsRunning.delete(task);
3552
+ };
3553
+ task.then(untrack, untrack);
3554
+ };
3555
+ const reviewCall = async (request) => {
3556
+ if (request.command !== undefined && !callShownWhole(request.call)) {
3557
+ // A command line the case can't show whole could hide its end, and
3558
+ // the reviewer would judge only the part it saw: a person decides.
3559
+ return { failed: 'command-too-long' };
3560
+ }
3561
+ const firing = scheduler.firingPromptFor(request.session);
3562
+ const prompt = reviewCaseText({
3563
+ session: request.session,
3564
+ call: request.call,
3565
+ tool: request.tool,
3566
+ risk: request.risk,
3567
+ ...(request.command !== undefined ? { command: request.command } : {}),
3568
+ ...(request.origin !== undefined ? { origin: request.origin } : {}),
3569
+ ...(request.destination !== undefined ? { destination: request.destination } : {}),
3570
+ ...(firing !== undefined ? { firing } : {}),
3571
+ delegated: isSubAgentSession(request.session),
3572
+ });
3573
+ if (stopReviews.signal.aborted) {
3574
+ return { failed: 'cancelled' };
3575
+ }
3576
+ const signal = AbortSignal.any([request.signal, stopReviews.signal]);
3577
+ // On the reviewed turn's own runtime while it runs. A review after it
3578
+ // has ended (a shadow review can outlast its turn) takes the agent's
3579
+ // current one, which is where the session's next turn sends the same
3580
+ // conversation anyway.
3581
+ const runtime = turnRuntimes.get(request.session.id);
3582
+ const running = runSideModel({
3583
+ agentId: request.session.agent.id,
3584
+ sessionId: `review:${randomUUID()}`,
3585
+ model: request.model,
3586
+ instructions: REVIEW_RULES,
3587
+ prompt,
3588
+ // The case quotes a call an outside page may have shaped.
3589
+ trust: 'external',
3590
+ tools: [],
3591
+ maxTurns: 1,
3592
+ signal,
3593
+ ...(runtime !== undefined ? { runtime } : {}),
3594
+ });
3595
+ keepUntilStopped(running);
3596
+ let answered;
3597
+ try {
3598
+ answered = await running;
3599
+ }
3600
+ catch (error) {
3601
+ const message = error instanceof Error ? error.message : String(error);
3602
+ return { failed: /budget/i.test(message) ? 'budget' : signal.aborted ? 'cancelled' : 'provider-error' };
3603
+ }
3604
+ if (answered.status === 'failed') {
3605
+ return { failed: signal.aborted ? 'cancelled' : 'provider-error' };
3606
+ }
3607
+ const reply = [...answered.messages].reverse().find((message) => message.role === 'assistant' && message.content.trim().length > 0);
3608
+ const parsed = reply === undefined ? undefined : readReviewReply(reply.content);
3609
+ return parsed === undefined ? { failed: 'unparseable' } : { reply: parsed };
3610
+ };
3746
3611
  const memoryPassHost = createMemoryPassHost({
3747
3612
  // Beside each agent's memory.jsonl, under the home the memory store uses.
3748
3613
  stateDir: stratusHomePath(env),
@@ -4817,6 +4682,12 @@ export const createGateway = (options = {}) => {
4817
4682
  while (inflight.size > 0) {
4818
4683
  await Promise.allSettled([...inflight]);
4819
4684
  }
4685
+ // Then the reviews no turn is waiting on: aborted rather than drained,
4686
+ // since what they report is about turns already over, and waited for.
4687
+ stopReviews.abort(new Error('The daemon is stopping.'));
4688
+ while (reviewsRunning.size > 0) {
4689
+ await Promise.allSettled([...reviewsRunning]);
4690
+ }
4820
4691
  // Then the channels, whose stop awaits the rendering they still have
4821
4692
  // in flight: the outcomes of the turns that just finished.
4822
4693
  await stopChannels();
@@ -4847,6 +4718,7 @@ export const createGateway = (options = {}) => {
4847
4718
  // Before the loop, so a turn queued behind one aborted here, which
4848
4719
  // starts as soon as that one settles, is aborted the same way.
4849
4720
  abortingWith = kind === 'stop' ? STOPPING_TURN_ERROR : RESTARTING_TURN_ERROR;
4721
+ stopReviews.abort(new Error('The daemon is stopping.'));
4850
4722
  for (const running of turnControllers.values()) {
4851
4723
  for (const controller of running) {
4852
4724
  controller.abort(shutdownAbort());
@@ -5178,6 +5050,9 @@ export const createGateway = (options = {}) => {
5178
5050
  activeTurnId(sessionId) {
5179
5051
  return activeTurns.get(sessionId);
5180
5052
  },
5053
+ isSideRun(sessionId) {
5054
+ return sideRuns.has(sessionId);
5055
+ },
5181
5056
  abortTurns(sessionId, reason) {
5182
5057
  const running = turnControllers.get(sessionId) ?? new Set();
5183
5058
  for (const controller of running) {
@@ -5239,11 +5114,18 @@ export const createGateway = (options = {}) => {
5239
5114
  const provenanceByName = toolProvenance();
5240
5115
  return tools.describe().map((descriptor) => {
5241
5116
  const provenance = provenanceByName.get(descriptor.name);
5117
+ const tool = tools.get(descriptor.name);
5118
+ const hooks = [
5119
+ ['command', tool?.commandFor],
5120
+ ['origin', tool?.originFor],
5121
+ ['destination', tool?.destinationFor],
5122
+ ].filter(([, hook]) => hook !== undefined).map(([name]) => name);
5242
5123
  return {
5243
5124
  name: descriptor.name,
5244
5125
  ...(descriptor.description ? { description: descriptor.description } : {}),
5245
5126
  risk: descriptor.risk ?? 'gated',
5246
5127
  ...(provenance ? { package: provenance.package, trusted: provenance.trusted } : {}),
5128
+ ...(hooks.length === 1 ? { scopedBy: hooks[0] } : {}),
5247
5129
  };
5248
5130
  });
5249
5131
  },
@@ -5389,4 +5271,8 @@ export const createGateway = (options = {}) => {
5389
5271
  };
5390
5272
  return gateway;
5391
5273
  };
5274
+ // Restart, stop and pause, in their own module (see the top of lifecycle.ts).
5275
+ export { DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS, DEFAULT_PAUSED_MESSAGE, DEFAULT_RESTART_DRAIN_TIMEOUT_MS, DEFAULT_STOP_DRAIN_TIMEOUT_MS, GatewayStoppingError, HELD_MESSAGE, IntakePausedError, MAX_PAUSED_MESSAGE_LENGTH, PauseMessageError, RESTARTING_TURN_ERROR, RestartUnsupportedError, STOPPING_TURN_ERROR, } from "./lifecycle.js";
5276
+ // Reserved session keys and markers, in their own module (see the top of session-keys.ts).
5277
+ export { ABANDONED_TURN_ERROR, DEFAULT_SUB_AGENTS_PER_TURN, EXECUTOR_METADATA_KEY, isSubAgentSession, ORPHANED_DELEGATION_ERROR, RESERVED_SESSION_METADATA_KEYS, reservedSessionMetadataKey, ROLLED_OVER_FROM_METADATA_KEY, ROLLED_OVER_SESSION_ID_MARKER, ROLLED_OVER_TO_METADATA_KEY, SUB_AGENT_METADATA_KEY, SUB_AGENT_SESSION_ID_MARKER, } from "./session-keys.js";
5392
5278
  //# sourceMappingURL=index.js.map