@stratusagent/gateway 0.14.2 → 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, 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';
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
@@ -419,6 +420,8 @@ export const createGateway = (options = {}) => {
419
420
  // process-global.
420
421
  const fileMemory = createHomeMemoryStore(env);
421
422
  let selectedMemory = fileMemory;
423
+ // The built-in store takes a list in `supersedes`; a plugin's says so.
424
+ let selectedMemorySupersedesMany = true;
422
425
  const memory = createRoutedMemoryStore(() => selectedMemory);
423
426
  let selectedExecutor;
424
427
  // What loaded plugins contributed besides tools and skills. The loader
@@ -985,15 +988,27 @@ export const createGateway = (options = {}) => {
985
988
  // the file on disk is temporarily broken (an operator mid-edit).
986
989
  let lastGoodConfigSnapshot;
987
990
  /**
988
- * Said at load, because the allowlist fails open: `tools` omitted is
989
- * every registered tool, the opposite of what `skills` and `credentials`
990
- * do when omitted, and a soul that never wrote the key holds `shell.run`
991
- * the moment that plugin is enabled. The built-in agent is exempt — it
992
- * 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.
993
1005
  */
994
1006
  const warnNoToolsList = (agentId, soulPath) => {
995
- warn(`agent ${agentId} has no tools: list, so it may call every tool this daemon loads — `
996
- + `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`);
997
1012
  };
998
1013
  const defaultAgentId = async () => {
999
1014
  // Identity only — never full runtime resolution: credential checks do
@@ -1432,6 +1447,23 @@ export const createGateway = (options = {}) => {
1432
1447
  const ts = typeof posted === 'object' && posted !== null ? posted.ts : undefined;
1433
1448
  return typeof ts === 'string' && ts.length > 0 ? { id: ts } : {};
1434
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
+ }));
1435
1467
  // The same carrying rule again: a channel is created by the asking
1436
1468
  // agent's own app, whose token never leaves the adapter, and so becomes
1437
1469
  // that app's to post in and read.
@@ -1722,7 +1754,13 @@ export const createGateway = (options = {}) => {
1722
1754
  // bearer credential the model is handed, and whoever it reaches can
1723
1755
  // store a key and amend the soul. That is the operator's to allow.
1724
1756
  const allowlist = session.agent.tools ?? registry.get(agentId)?.tools;
1725
- 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);
1726
1764
  // Why no form went up, said to the agent beside the link that replaces
1727
1765
  // it, so it can tell the person why they are getting a link instead.
1728
1766
  let formUnavailable;
@@ -2125,7 +2163,9 @@ export const createGateway = (options = {}) => {
2125
2163
  if (!Array.isArray(requested) || requested.some((name) => typeof name !== 'string' || name.length === 0)) {
2126
2164
  return 'A sub-agent\'s tools are a list of tool names.';
2127
2165
  }
2128
- 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;
2129
2169
  for (const name of requested) {
2130
2170
  if (/[*?]/.test(name)) {
2131
2171
  return `A sub-agent's tools are exact names, and ${name} is a pattern.`;
@@ -2133,12 +2173,21 @@ export const createGateway = (options = {}) => {
2133
2173
  if (subAgentNeverGets(name)) {
2134
2174
  return `${name} is never given to a sub-agent, even by an agent that holds it.`;
2135
2175
  }
2136
- if (!tools.get(name)) {
2176
+ const tool = tools.get(name);
2177
+ if (!tool) {
2137
2178
  return `There is no tool named ${name}.`;
2138
2179
  }
2139
2180
  // The parent's allowlist as its running turn holds it: a slice can
2140
- // only narrow what that turn may already do.
2141
- 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)) {
2142
2191
  return `${name} is not one of ${parent.agent.id}'s tools, so a sub-agent of it cannot have it.`;
2143
2192
  }
2144
2193
  }
@@ -2319,6 +2368,7 @@ export const createGateway = (options = {}) => {
2319
2368
  // Asking a person for a key is harmless to grant: they decide
2320
2369
  // everything after the question, so no soul has to list it.
2321
2370
  grantedToEveryAgent: subAgent ? [] : [CREDENTIAL_REQUEST_TOOL_NAME],
2371
+ unlistedTools: unlistedTools(),
2322
2372
  ...(subAgent ? {} : { skills: skillCatalog, memory }),
2323
2373
  streaming: true,
2324
2374
  ...(maxTurns !== undefined ? { maxTurns } : {}),
@@ -2861,7 +2911,13 @@ export const createGateway = (options = {}) => {
2861
2911
  * soul. Only that path passes it.
2862
2912
  */
2863
2913
  const dispatchInternal = async (input, absorbedKeys = [], subAgent,
2864
- /** See `RunInput.steer`; passed by `dispatch` for an opted-in caller. */
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
+ */
2865
2921
  steer) => {
2866
2922
  // Re-read on every turn, not once at start: a newer build that stamps
2867
2923
  // the home while this daemon is serving has formats this build does not
@@ -2982,12 +3038,13 @@ export const createGateway = (options = {}) => {
2982
3038
  userMessage: input.userMessage,
2983
3039
  ...(input.images !== undefined ? { images: input.images } : {}),
2984
3040
  ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
3041
+ ...(input.replyOptional === true ? { replyOptional: true } : {}),
2985
3042
  ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
2986
3043
  ...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
2987
3044
  ...(hostedLoop ? { hostedLoop } : {}),
2988
3045
  ...(input.metadata ? { metadata: input.metadata } : {}),
2989
3046
  runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
2990
- ...(steer !== undefined ? { steer } : {}),
3047
+ ...(steer !== undefined ? { steer: () => steer(signal) } : {}),
2991
3048
  signal,
2992
3049
  });
2993
3050
  }
@@ -2997,13 +3054,14 @@ export const createGateway = (options = {}) => {
2997
3054
  userMessage: input.userMessage,
2998
3055
  ...(input.images !== undefined ? { images: input.images } : {}),
2999
3056
  ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
3057
+ ...(input.replyOptional === true ? { replyOptional: true } : {}),
3000
3058
  ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
3001
3059
  ...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
3002
3060
  ...(hostedLoop ? { hostedLoop } : {}),
3003
3061
  ...(input.earlier !== undefined && input.earlier.length > 0 ? { earlier: input.earlier } : {}),
3004
3062
  metadata,
3005
3063
  runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
3006
- ...(steer !== undefined ? { steer } : {}),
3064
+ ...(steer !== undefined ? { steer: () => steer(signal) } : {}),
3007
3065
  signal,
3008
3066
  });
3009
3067
  }));
@@ -3627,6 +3685,88 @@ export const createGateway = (options = {}) => {
3627
3685
  });
3628
3686
  return turn;
3629
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
+ });
3630
3770
  /**
3631
3771
  * A scheduled firing's own dispatch — single-flight and stopping-aware
3632
3772
  * like the public one, but permitted into the reserved `schedule:`
@@ -3923,6 +4063,7 @@ export const createGateway = (options = {}) => {
3923
4063
  ...(head.input.agentId !== undefined ? { agentId: head.input.agentId } : {}),
3924
4064
  userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
3925
4065
  ...(images.length > 0 ? { images } : {}),
4066
+ ...(runnable.every((part) => part.input.replyOptional === true) ? { replyOptional: true } : {}),
3926
4067
  ...(head.input.earlier !== undefined ? { earlier: head.input.earlier } : {}),
3927
4068
  ...(metadata !== undefined ? { metadata } : {}),
3928
4069
  ...(signal !== undefined ? { signal } : {}),
@@ -3983,9 +4124,15 @@ export const createGateway = (options = {}) => {
3983
4124
  // the others' answer with it. A turn with a caller who cannot cancel
3984
4125
  // has no signal to join.
3985
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.
3986
4133
  const steer = mergeable
3987
- ? () => takeSteered(input.sessionId, input.agentId, steered, (parts) => {
3988
- if (joint?.signal.aborted === true) {
4134
+ ? (turnSignal) => takeSteered(input.sessionId, input.agentId, steered, (parts) => {
4135
+ if (turnSignal.aborted) {
3989
4136
  return false;
3990
4137
  }
3991
4138
  for (const part of parts) {
@@ -4191,6 +4338,7 @@ export const createGateway = (options = {}) => {
4191
4338
  log,
4192
4339
  warn,
4193
4340
  subAgents,
4341
+ memoryPasses: memoryPassHost.handle,
4194
4342
  // Metadata only, from the reads the control API makes, with a log
4195
4343
  // line per read naming the plugin. See `createFleetReader`.
4196
4344
  fleet: (packageName) => createFleetReader({
@@ -4290,6 +4438,7 @@ export const createGateway = (options = {}) => {
4290
4438
  + `. Enable the plugin that contributes it, or set memoryStore to ${BUILTIN_MEMORY_STORE_NAME}.`);
4291
4439
  }
4292
4440
  selectedMemory = contribution.store;
4441
+ selectedMemorySupersedesMany = contribution.supersedesMany === true;
4293
4442
  log(`memory store ${memoryName} selected, from ${contributorOf('memory', memoryName) ?? 'a plugin'}`);
4294
4443
  }
4295
4444
  };
@@ -4361,6 +4510,10 @@ export const createGateway = (options = {}) => {
4361
4510
  + `${reconciled.released.length} claim(s) released`);
4362
4511
  }
4363
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();
4364
4517
  intakeState = await readIntakeState(env, warn);
4365
4518
  if (intakeState.paused) {
4366
4519
  log(`intake is paused (since ${intakeState.since ?? 'unknown'}); new work will be refused until it resumes`);
@@ -4604,6 +4757,12 @@ export const createGateway = (options = {}) => {
4604
4757
  // already past its `stopping` check loses to `dispatch`'s own
4605
4758
  // refusal — the second gate is why this needs no handshake.
4606
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
+ });
4607
4766
  // Deny what is parked before draining, or the drain waits out every
4608
4767
  // outstanding approval timeout — a shutdown would hang for as long as
4609
4768
  // the longest request had left. Each denial is a real decision the
@@ -4642,6 +4801,7 @@ export const createGateway = (options = {}) => {
4642
4801
  return channelsStopping;
4643
4802
  };
4644
4803
  const drain = (async () => {
4804
+ await passesClosed;
4645
4805
  // A promotion still connecting channels finishes first, so every
4646
4806
  // channel it started is in the list stopped below; seeing `stopping`,
4647
4807
  // it starts nothing after that.
@@ -5064,6 +5224,12 @@ export const createGateway = (options = {}) => {
5064
5224
  eventDeliveries(options = {}) {
5065
5225
  return eventStore?.deliveries(options) ?? [];
5066
5226
  },
5227
+ memoryPasses(agentId) {
5228
+ return memoryPassHost.passes(agentId);
5229
+ },
5230
+ memoryPass(agentId, passId) {
5231
+ return memoryPassHost.pass(agentId, passId);
5232
+ },
5067
5233
  tools() {
5068
5234
  // From the registry, not from the load records: the risk reported is
5069
5235
  // the one a call will actually be judged at, floor applied, and a