@stratusagent/gateway 0.14.1 → 0.14.3

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
@@ -9,9 +9,10 @@ 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, 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';
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';
14
14
  import { createFleetReader } from "./fleet.js";
15
+ import { createMemoryPassHost } from "./memory-passes.js";
15
16
  import { createEventIntake, summarizeEvents, } from "./event-intake.js";
16
17
  import { SqliteEventStore } from "./events.js";
17
18
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
@@ -23,7 +24,7 @@ export { SqliteEventStore, EVENT_BLOCK_MAX_BYTES, EVENT_DEDUP_WINDOW_MS, EVENT_D
23
24
  export { createEventIntake, mapGithubDelivery, parseStandardWebhookEvent, verifyGithubSignature, verifyStandardWebhookSignature, DEFAULT_EVENT_MAX_BODY_BYTES, EVENT_SOURCE_REQUESTS_PER_MINUTE, } from "./event-intake.js";
24
25
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
25
26
  import { loadPlugins, } from '@stratusagent/plugins';
26
- 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, stratusHomePath, intakeStatePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, syncDirectory, writeFileSynced, } from '@stratusagent/state';
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';
27
28
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
28
29
  /**
29
30
  * How long a parked call waits for a person. Long enough to survive a
@@ -202,10 +203,19 @@ const MERGED_SEPARATOR = '\n\n';
202
203
  */
203
204
  const fitsReplayBudget = (images) => images.length === 0 || imagesWithinReplayBudget([{ id: '', createdAt: '', role: 'user', content: '', images: [...images] }]).size === images.length;
204
205
  /** A signal that aborts once every one of `signals` has. */
205
- const allAborted = (signals) => {
206
+ const allAborted = (signals) => joinableSignal(signals).signal;
207
+ /**
208
+ * `allAborted` for callers that keep joining: a turn that takes in messages
209
+ * at its tool steps (card 948) is cancelled only once every caller in it
210
+ * is, the ones who joined late included. A caller joining with no signal
211
+ * can never cancel, so from then on neither can the turn.
212
+ */
213
+ const joinableSignal = (initial) => {
206
214
  const controller = new AbortController();
215
+ const signals = [...initial];
216
+ let uncancellable = false;
207
217
  const check = () => {
208
- if (!controller.signal.aborted && signals.every((signal) => signal.aborted)) {
218
+ if (!uncancellable && !controller.signal.aborted && signals.every((signal) => signal.aborted)) {
209
219
  controller.abort(signals[signals.length - 1]?.reason);
210
220
  }
211
221
  };
@@ -213,7 +223,17 @@ const allAborted = (signals) => {
213
223
  signal.addEventListener('abort', check, { once: true });
214
224
  }
215
225
  check();
216
- return controller.signal;
226
+ return {
227
+ signal: controller.signal,
228
+ join: (signal) => {
229
+ if (signal === undefined) {
230
+ uncancellable = true;
231
+ return;
232
+ }
233
+ signals.push(signal);
234
+ signal.addEventListener('abort', check, { once: true });
235
+ },
236
+ };
217
237
  };
218
238
  /**
219
239
  * A value from a form, without the line breaks around it.
@@ -355,6 +375,10 @@ export const RESERVED_SESSION_METADATA_KEYS = [
355
375
  // no messages and, once it grows past that index, silently stops sending
356
376
  // its own earliest turns, with no overflow and no trim event to say so.
357
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,
358
382
  SUB_AGENT_METADATA_KEY,
359
383
  ];
360
384
  /**
@@ -396,6 +420,8 @@ export const createGateway = (options = {}) => {
396
420
  // process-global.
397
421
  const fileMemory = createHomeMemoryStore(env);
398
422
  let selectedMemory = fileMemory;
423
+ // The built-in store takes a list in `supersedes`; a plugin's says so.
424
+ let selectedMemorySupersedesMany = true;
399
425
  const memory = createRoutedMemoryStore(() => selectedMemory);
400
426
  let selectedExecutor;
401
427
  // What loaded plugins contributed besides tools and skills. The loader
@@ -962,15 +988,27 @@ export const createGateway = (options = {}) => {
962
988
  // the file on disk is temporarily broken (an operator mid-edit).
963
989
  let lastGoodConfigSnapshot;
964
990
  /**
965
- * Said at load, because the allowlist fails open: `tools` omitted is
966
- * every registered tool, the opposite of what `skills` and `credentials`
967
- * do when omitted, and a soul that never wrote the key holds `shell.run`
968
- * the moment that plugin is enabled. The built-in agent is exempt — it
969
- * has no file to add the key to.
991
+ * Read from the home once, on first use, which is after the migrations
992
+ * `serve` runs before it starts the gateway. It changes only when 0005
993
+ * first records it, and that happens before a daemon of this build runs.
994
+ */
995
+ let unlistedToolsRead;
996
+ const unlistedTools = () => options.unlistedTools ?? (unlistedToolsRead ??= unlistedToolsFor(env));
997
+ /**
998
+ * Said at load, because an omitted `tools` list is a quiet downgrade on a
999
+ * home this build set up: it is only the tools registered as safe, so an
1000
+ * agent whose soul never wrote the key cannot run a shell, write a file
1001
+ * or send a message, and nothing else says why. On a home an older build
1002
+ * set up it is every tool, as it always was there, and the line says so
1003
+ * the way it always did. The built-in agent is exempt: it has no file,
1004
+ * and says `tools: ["*"]` in code.
970
1005
  */
971
1006
  const warnNoToolsList = (agentId, soulPath) => {
972
- warn(`agent ${agentId} has no tools: list, so it may call every tool this daemon loads — `
973
- + `add tools: [...] to ${soulPath} to say which`);
1007
+ warn(unlistedTools() === 'every'
1008
+ ? `agent ${agentId} has no tools: list, so it may call every tool this daemon loads — `
1009
+ + `add tools: [...] to ${soulPath} to say which`
1010
+ : `agent ${agentId} has no tools: list, so it gets only safe tools (reads and memory) — `
1011
+ + `add tools: [...] to ${soulPath} to give it more, or tools: ["*"] for every tool`);
974
1012
  };
975
1013
  const defaultAgentId = async () => {
976
1014
  // Identity only — never full runtime resolution: credential checks do
@@ -1409,6 +1447,23 @@ export const createGateway = (options = {}) => {
1409
1447
  const ts = typeof posted === 'object' && posted !== null ? posted.ts : undefined;
1410
1448
  return typeof ts === 'string' && ts.length > 0 ? { id: ts } : {};
1411
1449
  }, { defaultThread: (session, destination) => scheduler.reportThreadFor(session, destination) }));
1450
+ // A mid-task message goes where the conversation is, through the channel
1451
+ // carrying this agent there, and nowhere else (card 1009). A session no
1452
+ // channel started (a terminal run, a schedule nobody watches) has nobody
1453
+ // to say it to before the reply.
1454
+ tools.register(createMessageSayTool(async ({ session, text }) => {
1455
+ const agentId = session.agent.id;
1456
+ const kind = session.metadata?.channel;
1457
+ const { adapter } = typeof kind === 'string'
1458
+ ? channelCarrying(kind, agentId, (candidate) => candidate.say !== undefined)
1459
+ : { adapter: undefined };
1460
+ if (!adapter?.say) {
1461
+ throw new Error(typeof kind === 'string'
1462
+ ? `This conversation's channel (${kind}) cannot take a mid-task message. Say it in your reply.`
1463
+ : 'This conversation is not in a channel that can take a mid-task message. Say it in your reply.');
1464
+ }
1465
+ await adapter.say({ sessionId: session.id, agentId, text });
1466
+ }));
1412
1467
  // The same carrying rule again: a channel is created by the asking
1413
1468
  // agent's own app, whose token never leaves the adapter, and so becomes
1414
1469
  // that app's to post in and read.
@@ -1699,7 +1754,13 @@ export const createGateway = (options = {}) => {
1699
1754
  // bearer credential the model is handed, and whoever it reaches can
1700
1755
  // store a key and amend the soul. That is the operator's to allow.
1701
1756
  const allowlist = session.agent.tools ?? registry.get(agentId)?.tools;
1702
- const linkAllowed = allowlist === undefined || matchesToolAllowlist(CREDENTIAL_REQUEST_TOOL_NAME, allowlist);
1757
+ // An omitted list grants the safe tools, this one among them, but a
1758
+ // link is the operator's to allow by naming it, so only a list says yes.
1759
+ // On a home an older build set up an omitted list is every tool, and
1760
+ // has always allowed a link there.
1761
+ const linkAllowed = allowlist === undefined
1762
+ ? unlistedTools() === 'every'
1763
+ : matchesToolAllowlist(CREDENTIAL_REQUEST_TOOL_NAME, allowlist);
1703
1764
  // Why no form went up, said to the agent beside the link that replaces
1704
1765
  // it, so it can tell the person why they are getting a link instead.
1705
1766
  let formUnavailable;
@@ -2102,7 +2163,9 @@ export const createGateway = (options = {}) => {
2102
2163
  if (!Array.isArray(requested) || requested.some((name) => typeof name !== 'string' || name.length === 0)) {
2103
2164
  return 'A sub-agent\'s tools are a list of tool names.';
2104
2165
  }
2105
- const allowlist = parent.agent.tools;
2166
+ // Read the way the runner reads it for the parent's own calls: the
2167
+ // session's copy, else the registered agent's.
2168
+ const allowlist = parent.agent.tools ?? registry.get(parent.agent.id)?.tools;
2106
2169
  for (const name of requested) {
2107
2170
  if (/[*?]/.test(name)) {
2108
2171
  return `A sub-agent's tools are exact names, and ${name} is a pattern.`;
@@ -2110,12 +2173,21 @@ export const createGateway = (options = {}) => {
2110
2173
  if (subAgentNeverGets(name)) {
2111
2174
  return `${name} is never given to a sub-agent, even by an agent that holds it.`;
2112
2175
  }
2113
- if (!tools.get(name)) {
2176
+ const tool = tools.get(name);
2177
+ if (!tool) {
2114
2178
  return `There is no tool named ${name}.`;
2115
2179
  }
2116
2180
  // The parent's allowlist as its running turn holds it: a slice can
2117
- // only narrow what that turn may already do.
2118
- if (allowlist !== undefined && !matchesToolAllowlist(name, allowlist)) {
2181
+ // only narrow what that turn may already do. With no list, that is
2182
+ // the tools registered as safe (every tool on a home an older build
2183
+ // set up), and the slice is held to the same rule: naming a gated
2184
+ // tool must not hand it to the sub-agent.
2185
+ if (allowlist === undefined) {
2186
+ if (unlistedTools() !== 'every' && resolveToolRisk(tool) !== 'safe') {
2187
+ return `${name} is not a safe tool, and ${parent.agent.id}'s soul has no tools: list, so it gets only safe tools and a sub-agent of it cannot have ${name}.`;
2188
+ }
2189
+ }
2190
+ else if (!matchesToolAllowlist(name, allowlist)) {
2119
2191
  return `${name} is not one of ${parent.agent.id}'s tools, so a sub-agent of it cannot have it.`;
2120
2192
  }
2121
2193
  }
@@ -2296,6 +2368,7 @@ export const createGateway = (options = {}) => {
2296
2368
  // Asking a person for a key is harmless to grant: they decide
2297
2369
  // everything after the question, so no soul has to list it.
2298
2370
  grantedToEveryAgent: subAgent ? [] : [CREDENTIAL_REQUEST_TOOL_NAME],
2371
+ unlistedTools: unlistedTools(),
2299
2372
  ...(subAgent ? {} : { skills: skillCatalog, memory }),
2300
2373
  streaming: true,
2301
2374
  ...(maxTurns !== undefined ? { maxTurns } : {}),
@@ -2565,6 +2638,23 @@ export const createGateway = (options = {}) => {
2565
2638
  // behind the in-flight turn instead of interleaving with it. Sessions
2566
2639
  // are independent — different conversations run concurrently.
2567
2640
  const sessionChains = new Map();
2641
+ /**
2642
+ * Work queued on each session's chain that has not started, of every
2643
+ * kind (a dispatch, an overheard message, a recovery). What says whether
2644
+ * a merged-turn batch is first in line behind the running turn, which
2645
+ * steering needs (card 948): a batch with anything ahead of it would be
2646
+ * answered before what was said first.
2647
+ */
2648
+ const queuedWork = new Map();
2649
+ const releaseQueued = (sessionId) => {
2650
+ const left = (queuedWork.get(sessionId) ?? 1) - 1;
2651
+ if (left > 0) {
2652
+ queuedWork.set(sessionId, left);
2653
+ }
2654
+ else {
2655
+ queuedWork.delete(sessionId);
2656
+ }
2657
+ };
2568
2658
  /**
2569
2659
  * Dispatches queued behind a session's running turn, per session: what
2570
2660
  * tells an auto-continuing turn that somebody wrote meanwhile, so it
@@ -2802,9 +2892,13 @@ export const createGateway = (options = {}) => {
2802
2892
  return await turn;
2803
2893
  }
2804
2894
  catch (error) {
2805
- if (input.idempotencyKey !== undefined && error instanceof RunAbortedError && error.resumable) {
2895
+ if (error instanceof RunAbortedError && error.resumable) {
2806
2896
  const left = await store.get(input.sessionId).catch(() => undefined);
2807
- if (left !== undefined && workItemState(left, input.idempotencyKey) === 'unfinished' && leftForNextStart(left, error)) {
2897
+ // The dispatch's own key, or, for an unkeyed one whose turn took
2898
+ // keyed messages in at a tool step (card 948), the key the turn has
2899
+ // from them: its steered callers are told what a keyed caller is.
2900
+ const key = input.idempotencyKey ?? (left === undefined ? undefined : turnKeyOf(turnMessageOf(left)));
2901
+ if (left !== undefined && key !== undefined && workItemState(left, key) === 'unfinished' && leftForNextStart(left, error)) {
2808
2902
  throw leftForNext();
2809
2903
  }
2810
2904
  }
@@ -2816,7 +2910,15 @@ export const createGateway = (options = {}) => {
2816
2910
  * runs as that, on the parent's resolved provider, with no memory and no
2817
2911
  * soul. Only that path passes it.
2818
2912
  */
2819
- const dispatchInternal = async (input, absorbedKeys = [], subAgent) => {
2913
+ const dispatchInternal = async (input, absorbedKeys = [], subAgent,
2914
+ /**
2915
+ * See `RunInput.steer`; passed by `dispatch` for an opted-in caller.
2916
+ * Handed the turn's own signal — the one the runner was given, which
2917
+ * the watchdog, `abortTurns` and a restart's window abort as well as
2918
+ * the callers — so it takes nothing into a turn that is already
2919
+ * stopping (card 959).
2920
+ */
2921
+ steer) => {
2820
2922
  // Re-read on every turn, not once at start: a newer build that stamps
2821
2923
  // the home while this daemon is serving has formats this build does not
2822
2924
  // understand, and a daemon that kept writing into them would be the
@@ -2936,11 +3038,13 @@ export const createGateway = (options = {}) => {
2936
3038
  userMessage: input.userMessage,
2937
3039
  ...(input.images !== undefined ? { images: input.images } : {}),
2938
3040
  ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
3041
+ ...(input.replyOptional === true ? { replyOptional: true } : {}),
2939
3042
  ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
2940
3043
  ...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
2941
3044
  ...(hostedLoop ? { hostedLoop } : {}),
2942
3045
  ...(input.metadata ? { metadata: input.metadata } : {}),
2943
3046
  runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
3047
+ ...(steer !== undefined ? { steer: () => steer(signal) } : {}),
2944
3048
  signal,
2945
3049
  });
2946
3050
  }
@@ -2950,12 +3054,14 @@ export const createGateway = (options = {}) => {
2950
3054
  userMessage: input.userMessage,
2951
3055
  ...(input.images !== undefined ? { images: input.images } : {}),
2952
3056
  ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
3057
+ ...(input.replyOptional === true ? { replyOptional: true } : {}),
2953
3058
  ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
2954
3059
  ...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
2955
3060
  ...(hostedLoop ? { hostedLoop } : {}),
2956
3061
  ...(input.earlier !== undefined && input.earlier.length > 0 ? { earlier: input.earlier } : {}),
2957
3062
  metadata,
2958
3063
  runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
3064
+ ...(steer !== undefined ? { steer: () => steer(signal) } : {}),
2959
3065
  signal,
2960
3066
  });
2961
3067
  }));
@@ -3155,7 +3261,7 @@ export const createGateway = (options = {}) => {
3155
3261
  // otherwise stay parked for every later start and redelivery to try
3156
3262
  // again. An unkeyed one keeps waiting for a sweep that can, as before.
3157
3263
  const after = await store.get(sessionId).catch(() => undefined);
3158
- const key = after?.messages.findLast((candidate) => candidate.role === 'user')?.idempotencyKey;
3264
+ const key = after === undefined ? undefined : turnKeyOf(turnMessageOf(after));
3159
3265
  if (after !== undefined && key !== undefined && after.status === 'pending_approval' && workItemState(after, key) === 'unfinished') {
3160
3266
  await failParked(after, ABANDONED_TURN_ERROR).catch(() => { });
3161
3267
  }
@@ -3266,8 +3372,8 @@ export const createGateway = (options = {}) => {
3266
3372
  * whose caller was never promised anything and is told to send again.
3267
3373
  */
3268
3374
  const settleAbandonedTurn = async (session) => {
3269
- const message = session.messages.findLast((candidate) => candidate.role === 'user');
3270
- if (message?.idempotencyKey === undefined) {
3375
+ const message = turnMessageOf(session);
3376
+ if (message === undefined || turnKeyOf(message) === undefined) {
3271
3377
  return failAbandoned(session);
3272
3378
  }
3273
3379
  // An answer already in the transcript is completed whatever came before
@@ -3419,6 +3525,100 @@ export const createGateway = (options = {}) => {
3419
3525
  }
3420
3526
  return settleAbandonedTurn(session);
3421
3527
  };
3528
+ /**
3529
+ * What a running turn takes in at its next tool step (card 948): the
3530
+ * merged-turn batch waiting behind it, whole — leader and absorbed alike
3531
+ * — when it is for the same agent. The dispatches it takes join `into`,
3532
+ * which the running turn settles with its own outcome (`runSteerable`).
3533
+ *
3534
+ * A part cancelled while it waited is refused, as `runMerged` refuses it.
3535
+ * A part whose key this session (or the archive it rolled over from)
3536
+ * already holds is a repeat, and judging a repeat can mean settling a
3537
+ * turn — never something to do from inside one — so such a batch is not
3538
+ * taken: it waits and runs next, merged, where that is judged. Nor while
3539
+ * the gateway is stopping, when the batch is what the drain refuses.
3540
+ */
3541
+ const takeSteered = async (sessionId, agentId, into,
3542
+ /**
3543
+ * Asked last, with nothing awaited after it: whether the running turn
3544
+ * still takes these callers, joining them to its cancellation if so. A
3545
+ * turn cancelled while the store was read takes nothing, and the batch
3546
+ * waits and runs next, as it would have.
3547
+ */
3548
+ admit) => {
3549
+ const batch = openBatches.get(sessionId);
3550
+ if (stopping || batch === undefined || !batch.firstInLine || batch.agentId !== agentId) {
3551
+ return undefined;
3552
+ }
3553
+ // Until every key in it is checked with nothing awaited since: a message
3554
+ // can join while the store is read, and is checked on the next pass; a
3555
+ // batch closed meanwhile is no longer this turn's to take.
3556
+ const checked = new Set();
3557
+ let session;
3558
+ for (;;) {
3559
+ if (stopping || openBatches.get(sessionId) !== batch) {
3560
+ return undefined;
3561
+ }
3562
+ const unchecked = [batch.leader, ...batch.absorbed].flatMap((part) => (part.input.idempotencyKey !== undefined && !checked.has(part.input.idempotencyKey) ? [part.input.idempotencyKey] : []));
3563
+ if (unchecked.length === 0) {
3564
+ break;
3565
+ }
3566
+ session ??= await store.get(sessionId);
3567
+ for (const key of unchecked) {
3568
+ checked.add(key);
3569
+ if (session !== undefined && (workItemState(session, key) !== undefined || await archivedRepeat(session, key) !== undefined)) {
3570
+ return undefined;
3571
+ }
3572
+ }
3573
+ }
3574
+ const all = [batch.leader, ...batch.absorbed];
3575
+ if (!admit(all.filter((part) => part.input.signal?.aborted !== true))) {
3576
+ return undefined;
3577
+ }
3578
+ openBatches.delete(sessionId);
3579
+ batch.steered = true;
3580
+ // Its place on the chain runs nothing now, so it is not ahead of a batch
3581
+ // opening behind it, which can then be steered at a later step.
3582
+ releaseQueued(sessionId);
3583
+ // Not waiting any more; the leader's place on the chain does not count it again.
3584
+ const left = (waitingDispatches.get(sessionId) ?? all.length) - all.length;
3585
+ if (left > 0) {
3586
+ waitingDispatches.set(sessionId, left);
3587
+ }
3588
+ else {
3589
+ waitingDispatches.delete(sessionId);
3590
+ }
3591
+ const runnable = [];
3592
+ for (const part of all) {
3593
+ if (part.input.signal?.aborted) {
3594
+ part.reject(abortErrorFor(part.input.signal));
3595
+ continue;
3596
+ }
3597
+ runnable.push(part);
3598
+ }
3599
+ if (runnable.length === 0) {
3600
+ return undefined;
3601
+ }
3602
+ const keys = runnable.flatMap((part) => (part.input.idempotencyKey !== undefined ? [part.input.idempotencyKey] : []));
3603
+ const items = liveWorkItems.get(sessionId);
3604
+ for (const key of keys) {
3605
+ const item = items?.get(key);
3606
+ if (item !== undefined) {
3607
+ item.confirmed = true;
3608
+ }
3609
+ }
3610
+ into.push(...runnable);
3611
+ const labelled = runnable.some((part) => part.input.metadata !== undefined && SENDER_TRUST_METADATA_KEY in part.input.metadata);
3612
+ const lowest = leastTrusted(...runnable.map((part) => senderTrustOf(part.input.metadata)));
3613
+ const images = runnable.flatMap((part) => part.input.images ?? []);
3614
+ log(`${sessionId}: ${runnable.length} message(s) sent during the running turn reached it at its next step`);
3615
+ return {
3616
+ userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
3617
+ ...(images.length > 0 ? { images } : {}),
3618
+ ...(labelled ? { metadata: { [SENDER_TRUST_METADATA_KEY]: lowest } } : {}),
3619
+ ...(keys.length > 0 ? { keys } : {}),
3620
+ };
3621
+ };
3422
3622
  /**
3423
3623
  * Settles a keyed item the last process left unfinished before any other
3424
3624
  * message joins its session. Channels are up before the sweeps reach it,
@@ -3431,7 +3631,7 @@ export const createGateway = (options = {}) => {
3431
3631
  */
3432
3632
  const settleOrphanedItem = async (sessionId) => {
3433
3633
  const session = await store.get(sessionId);
3434
- const key = session?.messages.findLast((candidate) => candidate.role === 'user')?.idempotencyKey;
3634
+ const key = session === undefined ? undefined : turnKeyOf(turnMessageOf(session));
3435
3635
  if (session !== undefined && key !== undefined && workItemState(session, key) === 'unfinished') {
3436
3636
  // A continuation that fails has failed its session, which is settled
3437
3637
  // too; the message waiting behind it runs either way.
@@ -3462,9 +3662,18 @@ export const createGateway = (options = {}) => {
3462
3662
  * stops that, and it only holds if everything that writes a session goes
3463
3663
  * through it.
3464
3664
  */
3465
- const onSessionChain = (sessionId, work) => {
3665
+ const onSessionChain = (sessionId, work,
3666
+ /** True once this work no longer counts as queued: a batch steered into the turn ahead (`takeSteered` released it). */
3667
+ released) => {
3466
3668
  const previous = sessionChains.get(sessionId) ?? Promise.resolve();
3467
- const turn = previous.then(work, work);
3669
+ queuedWork.set(sessionId, (queuedWork.get(sessionId) ?? 0) + 1);
3670
+ const start = () => {
3671
+ if (released?.() !== true) {
3672
+ releaseQueued(sessionId);
3673
+ }
3674
+ return work();
3675
+ };
3676
+ const turn = previous.then(start, start);
3468
3677
  const settled = turn.catch(() => { });
3469
3678
  sessionChains.set(sessionId, settled);
3470
3679
  inflight.add(settled);
@@ -3476,6 +3685,88 @@ export const createGateway = (options = {}) => {
3476
3685
  });
3477
3686
  return turn;
3478
3687
  };
3688
+ // ---- memory passes (spec 962) -------------------------------------------
3689
+ /**
3690
+ * A pass's one model turn: the agent's provider and credentials, exactly
3691
+ * the model asked for and never the fallback (cost stays what the
3692
+ * operator chose), only the pass's two tools, no persona, skills, memory
3693
+ * injection or delegates, and a session that is never stored. Its calls
3694
+ * go through the same provider-call guards as every turn, and its events
3695
+ * through the same bus, so a budget sees and counts them.
3696
+ */
3697
+ const runMemoryPassModel = async (run) => {
3698
+ const source = await refreshAgent(run.agentId);
3699
+ const resolved = await runtimeForAgent(source);
3700
+ const { fallback: _fallback, ...primary } = resolved;
3701
+ const config = (run.model !== undefined ? { ...primary, model: run.model } : primary);
3702
+ const tools = new ToolRegistry();
3703
+ for (const tool of run.tools) {
3704
+ tools.register(tool);
3705
+ }
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;
3709
+ let passRunner;
3710
+ const provider = createRuntimeProvider(config, () => { }, async (session, call, context) => {
3711
+ if (!passRunner) {
3712
+ throw new Error('The Stratus runtime is not ready to execute tools yet.');
3713
+ }
3714
+ return passRunner.executeHostedToolCall(session, call, context);
3715
+ }, maxTurns, async () => { }, providerContributions);
3716
+ passRunner = new AgentRunner({
3717
+ provider,
3718
+ tools,
3719
+ store: new InMemorySessionStore(),
3720
+ bus,
3721
+ maxTurns,
3722
+ providerCallGuards,
3723
+ });
3724
+ const definition = {
3725
+ id: source.definition.id,
3726
+ name: source.definition.name,
3727
+ instructions: MEMORY_PASS_RULES,
3728
+ tools: run.tools.map((tool) => tool.name),
3729
+ skills: [],
3730
+ delegates: [],
3731
+ ...(source.definition.credentials !== undefined ? { credentials: [...source.definition.credentials] } : {}),
3732
+ };
3733
+ await passRunner.run({
3734
+ 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
+ },
3743
+ signal: run.signal,
3744
+ });
3745
+ };
3746
+ const memoryPassHost = createMemoryPassHost({
3747
+ // Beside each agent's memory.jsonl, under the home the memory store uses.
3748
+ stateDir: stratusHomePath(env),
3749
+ stateHome: stratusHomePath(env),
3750
+ run: randomUUID(),
3751
+ memory,
3752
+ supersedesMany: () => selectedMemorySupersedesMany,
3753
+ sessions: store,
3754
+ chain: {
3755
+ idle: (sessionId) => !sessionChains.has(sessionId),
3756
+ queue: (sessionId, work) => onSessionChain(sessionId, work),
3757
+ },
3758
+ hasAgent: (agentId) => sources.has(agentId),
3759
+ isSubAgentSession: (session) => isSubAgentSession(session),
3760
+ defaultModel: async (agentId) => {
3761
+ const config = await runtimeForAgent(await refreshAgent(agentId));
3762
+ return config.provider !== 'demo' ? config.model : undefined;
3763
+ },
3764
+ runModel: runMemoryPassModel,
3765
+ emit: (event) => {
3766
+ void bus.emit(event);
3767
+ },
3768
+ log,
3769
+ });
3479
3770
  /**
3480
3771
  * A scheduled firing's own dispatch — single-flight and stopping-aware
3481
3772
  * like the public one, but permitted into the reserved `schedule:`
@@ -3673,7 +3964,16 @@ export const createGateway = (options = {}) => {
3673
3964
  // Otherwise it leads a new batch, if it is waiting behind a turn at all:
3674
3965
  // a message to an idle session runs at once, alone.
3675
3966
  const batch = mergeable && sessionChains.has(input.sessionId)
3676
- ? { agentId: input.agentId, absorbed: [], images: [...(input.images ?? [])], chars: input.userMessage.length }
3967
+ ? {
3968
+ agentId: input.agentId,
3969
+ // Settled through `settleLeader`, read when called: it is assigned below.
3970
+ leader: { input, resolve: (session) => settleLeader({ session }), reject: (error) => settleLeader({ error }) },
3971
+ firstInLine: (queuedWork.get(input.sessionId) ?? 0) === 0,
3972
+ absorbed: [],
3973
+ steered: false,
3974
+ images: [...(input.images ?? [])],
3975
+ chars: input.userMessage.length,
3976
+ }
3677
3977
  : undefined;
3678
3978
  if (batch !== undefined) {
3679
3979
  openBatches.set(input.sessionId, batch);
@@ -3763,6 +4063,7 @@ export const createGateway = (options = {}) => {
3763
4063
  ...(head.input.agentId !== undefined ? { agentId: head.input.agentId } : {}),
3764
4064
  userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
3765
4065
  ...(images.length > 0 ? { images } : {}),
4066
+ ...(runnable.every((part) => part.input.replyOptional === true) ? { replyOptional: true } : {}),
3766
4067
  ...(head.input.earlier !== undefined ? { earlier: head.input.earlier } : {}),
3767
4068
  ...(metadata !== undefined ? { metadata } : {}),
3768
4069
  ...(signal !== undefined ? { signal } : {}),
@@ -3775,7 +4076,7 @@ export const createGateway = (options = {}) => {
3775
4076
  activeTurns.set(input.sessionId, merged.turnId);
3776
4077
  }
3777
4078
  try {
3778
- outcome = { session: await dispatchInternal(merged, absorbedKeys) };
4079
+ outcome = { session: await runSteerable(merged, absorbedKeys) };
3779
4080
  }
3780
4081
  catch (error) {
3781
4082
  outcome = { error };
@@ -3809,7 +4110,65 @@ export const createGateway = (options = {}) => {
3809
4110
  settleLeader = (outcome) => ('error' in outcome ? reject(outcome.error) : resolve(outcome.session));
3810
4111
  });
3811
4112
  waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
4113
+ /**
4114
+ * Runs this dispatch's turn, handing it whatever queues behind it at
4115
+ * each tool step (`takeSteered`), then settles every dispatch it took
4116
+ * with the turn's outcome — the outcome they joined. Only for a caller
4117
+ * that opted into merged turns: it is the one that renders an answer
4118
+ * covering messages other than its own.
4119
+ */
4120
+ const runSteerable = async (turnInput, absorbedKeys = []) => {
4121
+ const steered = [];
4122
+ // Cancelled only when every caller in it is, as a merged turn is, the
4123
+ // ones steered in later included: one caller giving up must not take
4124
+ // the others' answer with it. A turn with a caller who cannot cancel
4125
+ // has no signal to join.
4126
+ const joint = turnInput.signal !== undefined ? joinableSignal([turnInput.signal]) : undefined;
4127
+ // Admitted on the turn's own signal, not only the callers' joint one:
4128
+ // the watchdog, `abortTurns` and a restart's window stop the turn
4129
+ // without touching its callers, and a batch taken into a turn stopped
4130
+ // while the store was read would share its failure instead of running
4131
+ // next (card 959). The turn's signal follows the joint one, so this
4132
+ // covers every caller giving up too.
4133
+ const steer = mergeable
4134
+ ? (turnSignal) => takeSteered(input.sessionId, input.agentId, steered, (parts) => {
4135
+ if (turnSignal.aborted) {
4136
+ return false;
4137
+ }
4138
+ for (const part of parts) {
4139
+ joint?.join(part.input.signal);
4140
+ }
4141
+ return true;
4142
+ })
4143
+ : undefined;
4144
+ const running = joint !== undefined ? { ...turnInput, signal: joint.signal } : turnInput;
4145
+ let outcome;
4146
+ try {
4147
+ outcome = { session: await dispatchInternal(running, absorbedKeys, undefined, steer) };
4148
+ }
4149
+ catch (error) {
4150
+ outcome = { error };
4151
+ }
4152
+ for (const part of steered) {
4153
+ part.input.onAbsorbed?.();
4154
+ if ('session' in outcome) {
4155
+ part.resolve(outcome.session);
4156
+ }
4157
+ else {
4158
+ part.reject(outcome.error);
4159
+ }
4160
+ }
4161
+ if ('error' in outcome) {
4162
+ throw outcome.error;
4163
+ }
4164
+ return outcome.session;
4165
+ };
3812
4166
  const chained = onSessionChain(input.sessionId, async () => {
4167
+ // Steered into the turn that ran ahead of it, which has answered and
4168
+ // settled everyone in the batch already — this caller included.
4169
+ if (batch?.steered === true) {
4170
+ return turn;
4171
+ }
3813
4172
  // The batch closes here: anything arriving from now on waits behind
3814
4173
  // this turn and leads the next one.
3815
4174
  if (batch !== undefined && openBatches.get(input.sessionId) === batch) {
@@ -3844,11 +4203,11 @@ export const createGateway = (options = {}) => {
3844
4203
  }
3845
4204
  await settleOrphanedItem(input.sessionId);
3846
4205
  if (turnId === undefined) {
3847
- return dispatchInternal(input);
4206
+ return runSteerable(input);
3848
4207
  }
3849
4208
  activeTurns.set(input.sessionId, turnId);
3850
4209
  try {
3851
- return await dispatchInternal(input);
4210
+ return await runSteerable(input);
3852
4211
  }
3853
4212
  finally {
3854
4213
  // Unconditional, and safe to be: the chain guarantees no other turn
@@ -3856,7 +4215,7 @@ export const createGateway = (options = {}) => {
3856
4215
  // only ever be clearing its own entry.
3857
4216
  activeTurns.delete(input.sessionId);
3858
4217
  }
3859
- });
4218
+ }, () => batch?.steered === true);
3860
4219
  // A second settle (a leader already settled early) is a no-op.
3861
4220
  chained.then((session) => settleLeader({ session }), (error) => settleLeader({ error }));
3862
4221
  if (idempotencyKey !== undefined) {
@@ -3979,6 +4338,7 @@ export const createGateway = (options = {}) => {
3979
4338
  log,
3980
4339
  warn,
3981
4340
  subAgents,
4341
+ memoryPasses: memoryPassHost.handle,
3982
4342
  // Metadata only, from the reads the control API makes, with a log
3983
4343
  // line per read naming the plugin. See `createFleetReader`.
3984
4344
  fleet: (packageName) => createFleetReader({
@@ -4078,6 +4438,7 @@ export const createGateway = (options = {}) => {
4078
4438
  + `. Enable the plugin that contributes it, or set memoryStore to ${BUILTIN_MEMORY_STORE_NAME}.`);
4079
4439
  }
4080
4440
  selectedMemory = contribution.store;
4441
+ selectedMemorySupersedesMany = contribution.supersedesMany === true;
4081
4442
  log(`memory store ${memoryName} selected, from ${contributorOf('memory', memoryName) ?? 'a plugin'}`);
4082
4443
  }
4083
4444
  };
@@ -4149,6 +4510,10 @@ export const createGateway = (options = {}) => {
4149
4510
  + `${reconciled.released.length} claim(s) released`);
4150
4511
  }
4151
4512
  await loadRoster();
4513
+ // Agents loaded and the memory store chosen: a plugin that turned memory
4514
+ // passes on in its setup had neither, so the claims a crash left open
4515
+ // are settled now, before any turn can end and ask for a pass.
4516
+ await memoryPassHost.ready();
4152
4517
  intakeState = await readIntakeState(env, warn);
4153
4518
  if (intakeState.paused) {
4154
4519
  log(`intake is paused (since ${intakeState.since ?? 'unknown'}); new work will be refused until it resumes`);
@@ -4392,6 +4757,12 @@ export const createGateway = (options = {}) => {
4392
4757
  // already past its `stopping` check loses to `dispatch`'s own
4393
4758
  // refusal — the second gate is why this needs no handshake.
4394
4759
  scheduler.stop();
4760
+ // Memory passes are low priority: aborted at once rather than drained,
4761
+ // settled from what they wrote, then the stop records. Awaited before
4762
+ // the stores close, since a settle reads the memory store.
4763
+ const passesClosed = memoryPassHost.close().catch((error) => {
4764
+ warn(`memory passes did not close cleanly: ${error instanceof Error ? error.message : String(error)}`);
4765
+ });
4395
4766
  // Deny what is parked before draining, or the drain waits out every
4396
4767
  // outstanding approval timeout — a shutdown would hang for as long as
4397
4768
  // the longest request had left. Each denial is a real decision the
@@ -4430,6 +4801,7 @@ export const createGateway = (options = {}) => {
4430
4801
  return channelsStopping;
4431
4802
  };
4432
4803
  const drain = (async () => {
4804
+ await passesClosed;
4433
4805
  // A promotion still connecting channels finishes first, so every
4434
4806
  // channel it started is in the list stopped below; seeing `stopping`,
4435
4807
  // it starts nothing after that.
@@ -4852,6 +5224,12 @@ export const createGateway = (options = {}) => {
4852
5224
  eventDeliveries(options = {}) {
4853
5225
  return eventStore?.deliveries(options) ?? [];
4854
5226
  },
5227
+ memoryPasses(agentId) {
5228
+ return memoryPassHost.passes(agentId);
5229
+ },
5230
+ memoryPass(agentId, passId) {
5231
+ return memoryPassHost.pass(agentId, passId);
5232
+ },
4855
5233
  tools() {
4856
5234
  // From the registry, not from the load records: the risk reported is
4857
5235
  // the one a call will actually be judged at, floor applied, and a