@stratusagent/gateway 0.14.8 → 0.14.10

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.
Files changed (40) hide show
  1. package/dist/agent-source.d.ts +10 -0
  2. package/dist/agent-source.d.ts.map +1 -0
  3. package/dist/agent-source.js +7 -0
  4. package/dist/agent-source.js.map +1 -0
  5. package/dist/approvals.d.ts +38 -0
  6. package/dist/approvals.d.ts.map +1 -0
  7. package/dist/approvals.js +330 -0
  8. package/dist/approvals.js.map +1 -0
  9. package/dist/credential-requests.d.ts +24 -0
  10. package/dist/credential-requests.d.ts.map +1 -0
  11. package/dist/credential-requests.js +452 -0
  12. package/dist/credential-requests.js.map +1 -0
  13. package/dist/dispatch.d.ts +34 -0
  14. package/dist/dispatch.d.ts.map +1 -0
  15. package/dist/dispatch.js +461 -0
  16. package/dist/dispatch.js.map +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +372 -2444
  19. package/dist/index.js.map +1 -1
  20. package/dist/merge-batch.d.ts +60 -0
  21. package/dist/merge-batch.d.ts.map +1 -0
  22. package/dist/merge-batch.js +59 -0
  23. package/dist/merge-batch.js.map +1 -0
  24. package/dist/recovery.d.ts +49 -0
  25. package/dist/recovery.d.ts.map +1 -0
  26. package/dist/recovery.js +487 -0
  27. package/dist/recovery.js.map +1 -0
  28. package/dist/steering.d.ts +24 -0
  29. package/dist/steering.d.ts.map +1 -0
  30. package/dist/steering.js +108 -0
  31. package/dist/steering.js.map +1 -0
  32. package/dist/sub-agents.d.ts +20 -0
  33. package/dist/sub-agents.d.ts.map +1 -0
  34. package/dist/sub-agents.js +263 -0
  35. package/dist/sub-agents.js.map +1 -0
  36. package/dist/turn-dispatch.d.ts +34 -0
  37. package/dist/turn-dispatch.d.ts.map +1 -0
  38. package/dist/turn-dispatch.js +179 -0
  39. package/dist/turn-dispatch.js.map +1 -0
  40. package/package.json +7 -7
package/dist/index.js CHANGED
@@ -7,17 +7,24 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
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';
10
+ import { filePathsOf, postOf, isUnaddressedTurn, carriesKey, SENDER_TRUST_METADATA_KEY, workItemState, turnMessageOf, turnKeyOf, leftForNextStart, sessionWriteTrust, AgentRegistry, AgentRunner, InMemorySessionStore, MEMORY_PASS_MAX_MODEL_CALLS, MEMORY_PASS_RULES, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, SESSION_TRUST_METADATA_KEY, latestTurnReply, conversationContextFrom, STRATUS_SKILL_ID, } from '@stratusagent/core';
11
+ import { CREDENTIAL_REQUEST_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageSayTool, createMessageReadTool, createChannelArchiveTool, createChannelCreateTool, createChannelInviteTool, createChannelJoinTool, directMessageReadRefusal, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_SESSION_ID_MARKER, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
12
+ import { createApprovalBroker } from "./approvals.js";
13
+ import { createCredentialRequests } from "./credential-requests.js";
14
+ import { createDispatch } from "./dispatch.js";
12
15
  import { createFleetReader } from "./fleet.js";
13
16
  import { createMemoryPassHost } from "./memory-passes.js";
17
+ import { createRecovery } from "./recovery.js";
14
18
  import { callShownWhole, readReviewReply, REVIEW_RULES, reviewCaseText } from "./review.js";
15
19
  import { createEventIntake, summarizeEvents, } from "./event-intake.js";
16
20
  import { SqliteEventStore } from "./events.js";
17
21
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
18
22
  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";
23
+ import { createSubAgents } from "./sub-agents.js";
24
+ import { createSteering } from "./steering.js";
25
+ import { createTurnDispatch } from "./turn-dispatch.js";
26
+ import { DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS, DEFAULT_RESTART_DRAIN_TIMEOUT_MS, DEFAULT_STOP_DRAIN_TIMEOUT_MS, GatewayStoppingError, MAX_PAUSED_MESSAGE_LENGTH, PauseMessageError, readIntakeState, RESTARTING_TURN_ERROR, RestartUnsupportedError, STOPPING_TURN_ERROR, writeIntakeState, } from "./lifecycle.js";
27
+ import { crossIdentityError, isSubAgentSession, RESERVED_SESSION_METADATA_KEYS, reservedSessionMetadataKey, ROLLED_OVER_FROM_METADATA_KEY, ROLLED_OVER_SESSION_ID_MARKER, ROLLED_OVER_TO_METADATA_KEY, SUB_AGENT_SESSION_ID_MARKER, } from "./session-keys.js";
21
28
  export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
22
29
  export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTakenError, tightenSqliteFile, } from "./sessions.js";
23
30
  export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
@@ -29,7 +36,7 @@ export { REVIEW_RULES, readReviewReply, reviewCaseText } from "./review.js";
29
36
  export { createEventIntake, mapGithubDelivery, parseStandardWebhookEvent, verifyGithubSignature, verifyStandardWebhookSignature, DEFAULT_EVENT_MAX_BODY_BYTES, EVENT_SOURCE_REQUESTS_PER_MINUTE, } from "./event-intake.js";
30
37
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
31
38
  import { loadPlugins, } from '@stratusagent/plugins';
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';
39
+ 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, withSoulFileLock, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, unlistedToolsFor, stratusHomePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, } from '@stratusagent/state';
33
40
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
34
41
  /**
35
42
  * How long a parked call waits for a person. Long enough to survive a
@@ -53,75 +60,6 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647;
53
60
  * question even when the rule answering it is not gateway code.
54
61
  */
55
62
  export { applySoulPins } from '@stratusagent/state';
56
- /**
57
- * The most text one merged user message may hold (card 423). The runner's
58
- * overflow recovery can drop earlier transcript but never part of the
59
- * newest message, so a batch must not grow that message past what one
60
- * message could be on its own: this is Slack's own message-length limit.
61
- * A single message longer than this still runs, alone, as it would have
62
- * without merging.
63
- */
64
- const MERGED_TEXT_BUDGET_CHARS = 40_000;
65
- /** Separator between merged messages; counted against the text budget. */
66
- const MERGED_SEPARATOR = '\n\n';
67
- /**
68
- * Whether `images` all fit the replay budget together. A merged turn's
69
- * images are one message's, and the runner empties whatever falls outside
70
- * the budget before the provider sees it, so a batch that would overflow
71
- * must not take the message that overflows it (card 423).
72
- */
73
- const fitsReplayBudget = (images) => images.length === 0 || imagesWithinReplayBudget([{ id: '', createdAt: '', role: 'user', content: '', images: [...images] }]).size === images.length;
74
- /** A signal that aborts once every one of `signals` has. */
75
- const allAborted = (signals) => joinableSignal(signals).signal;
76
- /**
77
- * `allAborted` for callers that keep joining: a turn that takes in messages
78
- * at its tool steps (card 948) is cancelled only once every caller in it
79
- * is, the ones who joined late included. A caller joining with no signal
80
- * can never cancel, so from then on neither can the turn.
81
- */
82
- const joinableSignal = (initial) => {
83
- const controller = new AbortController();
84
- const signals = [...initial];
85
- let uncancellable = false;
86
- const check = () => {
87
- if (!uncancellable && !controller.signal.aborted && signals.every((signal) => signal.aborted)) {
88
- controller.abort(signals[signals.length - 1]?.reason);
89
- }
90
- };
91
- for (const signal of signals) {
92
- signal.addEventListener('abort', check, { once: true });
93
- }
94
- check();
95
- return {
96
- signal: controller.signal,
97
- join: (signal) => {
98
- if (signal === undefined) {
99
- uncancellable = true;
100
- return;
101
- }
102
- signals.push(signal);
103
- signal.addEventListener('abort', check, { once: true });
104
- },
105
- };
106
- };
107
- /**
108
- * A value from a form, without the line breaks around it.
109
- *
110
- * Forms take multi-line values now, so a one-line key copied with the line
111
- * break after it would otherwise be stored with it, and fail later inside
112
- * whatever header or command it lands in. Only line breaks: a value's own
113
- * spaces are its own, as on the CLI.
114
- */
115
- const formCredentialValue = (value) => value.replace(/^[\r\n]+|[\r\n]+$/g, '');
116
- /**
117
- * A PEM block (a private key, a certificate) with no line breaks left, which
118
- * is what a one-line field makes of a pasted key file. PEM readers need the
119
- * line after the `-----BEGIN …-----` header, so storing it would only move
120
- * the failure to the first call that reads the key. Only a value that *is*
121
- * one: a PEM inside JSON (a service account file) escapes its line breaks,
122
- * so it has none of its own and is fine as sent.
123
- */
124
- const isFlattenedPem = (value) => /^\s*-----BEGIN [A-Z0-9 ]+-----/.test(value) && !value.includes('\n');
125
63
  /**
126
64
  * The always-on Stratus process. One gateway holds the roster, the durable
127
65
  * session store, the shared event bus, and a pool of runners keyed by
@@ -209,6 +147,7 @@ export const createGateway = (options = {}) => {
209
147
  if (Object.keys(eventSources).length > 0) {
210
148
  openEvents();
211
149
  }
150
+ const startedChannels = [];
212
151
  /**
213
152
  * The started adapter of one kind that speaks for an agent and has the
214
153
  * capability asked for. `carriesOthers` says there were capable adapters
@@ -316,307 +255,15 @@ export const createGateway = (options = {}) => {
316
255
  if (approvalTimeoutMs > MAX_TIMER_DELAY_MS) {
317
256
  warn(`approval timeout ${approvalTimeoutMs}ms is above Node's maximum timer delay; using ${MAX_TIMER_DELAY_MS}ms (~24.8 days)`);
318
257
  }
319
- const pendingApprovals = new Map();
320
- /**
321
- * In-flight `tool.approval-resolved` emissions. `EventBus.emit` awaits its
322
- * subscribers in order, so one async handler ahead of a channel adapter
323
- * suspends the emission before the adapter ever sees it. Nothing today is
324
- * async in front of one — which is exactly the problem: the shutdown
325
- * guarantee below would rest on that staying true, and the first async
326
- * subscriber anyone adds would silently leave live-looking buttons in a
327
- * workspace the daemon has left.
328
- */
329
- const approvalEmissions = new Set();
330
- /**
331
- * Publishes one parked call and settles when somebody answers it, the
332
- * request expires, or the turn is cancelled.
333
- *
334
- * All three endings run through the same `settle`, which removes the
335
- * request from the registry BEFORE resolving. That ordering is the whole
336
- * point: a request that is no longer pending cannot be resolved twice,
337
- * so a click racing the timeout — or arriving after an abort — is refused
338
- * rather than executing a tool for a turn that has already moved on.
339
- */
340
- /**
341
- * The conversation a delegated sub-session's work was asked from, read
342
- * from the store rather than from anything the caller wrote: the root
343
- * session `agent.delegate` recorded, which is where the person waiting
344
- * on the answer is. Undefined for an ordinary session, or a root that no
345
- * longer exists — the request is then asked the way it always was.
346
- */
347
- const delegationOriginOf = async (session) => {
348
- if (!isDelegatedSession(session)) {
349
- return undefined;
350
- }
351
- const rootId = session.metadata?.[ROOT_SESSION_ID_METADATA_KEY];
352
- if (typeof rootId !== 'string' || rootId === session.id) {
353
- return undefined;
354
- }
355
- let root;
356
- try {
357
- root = await store.get(rootId);
358
- }
359
- catch (error) {
360
- warn(`${session.id}: could not read the delegating session ${rootId} to route an approval (${error instanceof Error ? error.message : String(error)})`);
361
- return undefined;
362
- }
363
- if (!root) {
364
- return undefined;
365
- }
366
- return { agentId: root.agent.id, sessionId: root.id, ...(root.metadata ? { metadata: root.metadata } : {}) };
367
- };
368
- /**
369
- * Which sessions are sub-agents, as the host knows it rather than as the
370
- * session says (roadmap 24). Every tool in a slice is handed the same
371
- * mutable Session, so the metadata marker and the id are claims a tool
372
- * can rewrite. The ids are the ones `subAgents.run` minted, held while
373
- * the sub-agent runs. A session object's id is read once, by the
374
- * executor on its first tool call, before any tool has been handed it,
375
- * so a session a tool renamed is still known by the id it ran under.
376
- */
377
- const liveSubAgentIds = new Set();
378
- const idAtFirstCall = new WeakMap();
379
- const hostIdOf = (session) => idAtFirstCall.get(session) ?? session.id;
380
- const isHostSubAgent = (session) => liveSubAgentIds.has(hostIdOf(session)) || isSubAgentSession(session);
381
- const requestApproval = async (request) => {
382
- // A sub-agent asks no one (roadmap 24): its parent turn is the one a
383
- // person is accountable for, and that turn gets the refusal back as a
384
- // result and decides what to do. Denied as undeliverable, since nobody
385
- // was asked, and announced only as a resolution, so no channel renders
386
- // a question nobody may answer.
387
- if (isHostSubAgent(request.session)) {
388
- void bus.emit({
389
- type: 'tool.approval-resolved',
390
- sessionId: request.session.id,
391
- requestId: randomUUID(),
392
- answer: 'deny',
393
- reason: 'undeliverable',
394
- });
395
- return { answer: 'deny' };
396
- }
397
- const delegatedFrom = await delegationOriginOf(request.session);
398
- return requestApprovalFrom(request, delegatedFrom);
399
- };
400
- const requestApprovalFrom = (request, delegatedFrom) => new Promise((resolve) => {
401
- // A shutdown denies what is parked once, at the top of stop(). Turns
402
- // already running keep going through the drain, though, and one of them
403
- // can reach a gated tool AFTER that snapshot — finishing a provider
404
- // call, or moving to the next call in the same response. Parking it
405
- // would deadlock the drain against a question nobody is left to answer:
406
- // stop() waits for the turn, the turn waits for the approval, and the
407
- // approval waits out its timeout — forever when there is none.
408
- if (stopping) {
409
- resolve({ answer: 'deny' });
410
- void bus.emit({
411
- type: 'tool.approval-resolved',
412
- sessionId: request.session.id,
413
- requestId: randomUUID(),
414
- answer: 'deny',
415
- reason: 'cancelled',
416
- });
417
- return;
418
- }
419
- const requestId = randomUUID();
420
- let timer;
421
- const onAbort = () => settle('deny', 'cancelled');
422
- /**
423
- * Settles once the request has finished being announced.
424
- *
425
- * Both emissions go through the same subscriber list, in order, and
426
- * `EventBus.emit` awaits each one — so an async subscriber ahead of a
427
- * channel can hold the announcement while a timeout or abort fires
428
- * behind it, and the channel would hear that a request it has never
429
- * heard of was resolved. It drops that, then renders the announcement
430
- * when it finally arrives, leaving buttons for a settled request that
431
- * nothing will ever retract.
432
- *
433
- * Chaining is the fix rather than remembering orphaned resolutions in
434
- * the channel: a resolution that arrives before its request is not
435
- * something a consumer can act on, so it should not be able to happen.
436
- * Starts resolved, because a turn already aborted never announces at
437
- * all and its denial should not wait on an emission that never runs.
438
- */
439
- let announced = Promise.resolve();
440
- // Read when the request is made, from the scheduler's own records: a
441
- // live firing of a schedule approved with a destination, nothing else.
442
- // Delegated work is not included yet; its sub-session is not a firing.
443
- const scheduleDestination = scheduler.approvalDestinationFor(request.session);
444
- // Work one agent does for another is allowed one call at a time. The
445
- // people asked are the delegating conversation's, and a standing grant
446
- // would widen what the other agent may do on its own work for good —
447
- // so nothing offers **Always allow** here, and an `always` that
448
- // arrives anyway (the control API takes all three answers) runs the
449
- // call once, recorded as exactly that.
450
- const forAnotherAgent = delegatedFrom !== undefined && delegatedFrom.agentId !== request.session.agent.id;
451
- const oneShot = request.oneShot === true || forAnotherAgent;
452
- const always = forAnotherAgent ? undefined : request.always;
453
- const settle = (given, reason, actor) => {
454
- const answer = forAnotherAgent && given === 'always' ? 'once' : given;
455
- // Identity, not presence: a request that already settled is gone from
456
- // the registry, and comparing against this exact function is what
457
- // makes every ending — click, timeout, abort, shutdown — idempotent.
458
- if (pendingApprovals.get(requestId)?.settle !== settle) {
459
- return;
460
- }
461
- pendingApprovals.delete(requestId);
462
- if (timer) {
463
- clearTimeout(timer);
464
- }
465
- request.signal?.removeEventListener('abort', onAbort);
466
- resolve({ answer, ...(actor ? { actor } : {}) });
467
- const emitted = announced.then(() => bus.emit({
468
- type: 'tool.approval-resolved',
469
- sessionId: request.session.id,
470
- requestId,
471
- answer,
472
- reason,
473
- ...(actor ? { actor } : {}),
474
- }));
475
- approvalEmissions.add(emitted);
476
- void emitted.finally(() => approvalEmissions.delete(emitted));
477
- };
478
- // What is left of the window, not a fresh one: a re-asked request
479
- // carries when it originally parked, and the elapsed time counts.
480
- // Computed before registration because the summary below quotes the
481
- // deadline, and a listing that disagreed with the announcing event about
482
- // when a request expires would be worse than one that said nothing.
483
- const alreadyWaitedMs = request.parkedAt ? Date.now() - Date.parse(request.parkedAt) : 0;
484
- const remainingTimeoutMs = effectiveApprovalTimeoutMs > 0
485
- ? effectiveApprovalTimeoutMs - (Number.isFinite(alreadyWaitedMs) ? Math.max(0, alreadyWaitedMs) : 0)
486
- : 0;
487
- const expiresAt = remainingTimeoutMs > 0
488
- ? new Date(Date.now() + remainingTimeoutMs).toISOString()
489
- : undefined;
490
- pendingApprovals.set(requestId, {
491
- settle,
492
- pending: {
493
- requestId,
494
- sessionId: request.session.id,
495
- agentId: request.session.agent.id,
496
- call: request.call,
497
- risk: request.risk,
498
- ...(request.origin !== undefined ? { origin: request.origin } : {}),
499
- ...(oneShot ? { oneShot: true } : {}),
500
- ...(always !== undefined ? { always } : {}),
501
- ...(request.reviewNote !== undefined ? { reviewNote: request.reviewNote } : {}),
502
- parkedAt: request.parkedAt ?? new Date().toISOString(),
503
- ...(expiresAt ? { expiresAt } : {}),
504
- ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
505
- ...(delegatedFrom ? { delegatedFrom } : {}),
506
- ...(scheduleDestination ? { scheduleDestination } : {}),
507
- },
508
- });
509
- if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
510
- // Nothing left to wait with. Settled here rather than armed with a
511
- // zero timer, which `setTimeout` would still run a tick later — long
512
- // enough to announce a request that is already over.
513
- settle('deny', 'timeout');
514
- return;
515
- }
516
- if (remainingTimeoutMs > 0) {
517
- // Deliberately NOT unref'd. A parked turn is real outstanding work,
518
- // and its timer is the only thing that will ever finish it — letting
519
- // the process exit out from under one would abandon the turn instead
520
- // of denying it. Shutdown is handled where it belongs, in stop(),
521
- // which settles every outstanding request before draining.
522
- timer = setTimeout(() => settle('deny', 'timeout'), remainingTimeoutMs);
523
- }
524
- // The abort listener is attached before the request is announced, so a
525
- // turn cancelled while the event is still being delivered still ends
526
- // the request rather than leaving it pending forever.
527
- if (request.signal) {
528
- if (request.signal.aborted) {
529
- settle('deny', 'cancelled');
530
- return;
531
- }
532
- request.signal.addEventListener('abort', onAbort, { once: true });
533
- }
534
- // `announced` is assigned BEFORE the emission starts, because emit runs
535
- // its first subscribers synchronously — one of them settling the
536
- // request inline would otherwise chain onto the already-resolved
537
- // placeholder and race the very announcement it is answering.
538
- let markAnnounced = () => { };
539
- announced = new Promise((resolveAnnounced) => {
540
- markAnnounced = resolveAnnounced;
541
- });
542
- const announcing = bus.emit({
543
- type: 'tool.approval-requested',
544
- sessionId: request.session.id,
545
- agentId: request.session.agent.id,
546
- requestId,
547
- call: request.call,
548
- risk: request.risk,
549
- ...(request.origin !== undefined ? { origin: request.origin } : {}),
550
- ...(oneShot ? { oneShot: true } : {}),
551
- ...(always !== undefined ? { always } : {}),
552
- ...(request.reviewNote !== undefined ? { reviewNote: request.reviewNote } : {}),
553
- ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
554
- ...(delegatedFrom ? { delegatedFrom } : {}),
555
- ...(scheduleDestination ? { scheduleDestination } : {}),
556
- ...(expiresAt ? { expiresAt } : {}),
557
- });
558
- // Drained at shutdown alongside the resolutions: a channel that has not
559
- // yet been told a request exists cannot be told it was denied either.
560
- approvalEmissions.add(announcing);
561
- void announcing.finally(() => {
562
- approvalEmissions.delete(announcing);
563
- markAnnounced();
564
- });
258
+ // `stopping` is read late: it is declared further down, with shutdown.
259
+ const { pendingApprovals, approvalEmissions, liveSubAgentIds, idAtFirstCall, hostIdOf, isHostSubAgent, requestApproval, resolveApproval, suspendWatchdog, approvalWaits, holdApprovalWait, } = createApprovalBroker({
260
+ warn,
261
+ bus,
262
+ store,
263
+ scheduler,
264
+ effectiveApprovalTimeoutMs,
265
+ isStopping: () => stopping,
565
266
  });
566
- const resolveApproval = (input) => {
567
- const parked = pendingApprovals.get(input.requestId);
568
- if (!parked) {
569
- return false;
570
- }
571
- parked.settle(input.answer, input.reason ?? 'decided', input.actor);
572
- return true;
573
- };
574
- /**
575
- * How a turn's watchdog stops its own clock, registered while it runs.
576
- *
577
- * Recording that a tool phase is open is not enough on its own: the
578
- * timer is only re-evaluated when an event arrives, so a phase opened by
579
- * something that emits nothing — an approval policy handed straight to
580
- * the gateway — would leave an already-armed timer running to fire
581
- * mid-wait.
582
- */
583
- const suspendWatchdog = new Map();
584
- /**
585
- * Approval waits in progress, per session.
586
- *
587
- * Deliberately a balanced counter and not a set of call ids. An earlier
588
- * shape had the approval wrapper *open* a phase that the watchdog's
589
- * event observer was expected to close, and that handoff was wrong in
590
- * every way it could be: the observer does not exist when no watchdog is
591
- * armed, so nothing closed it; a policy that threw closed nothing; and a
592
- * stale entry did not merely leak, it suppressed the watchdog for the
593
- * next turn on that session. Four separate exits, each patched in turn.
594
- *
595
- * A counter incremented and decremented in one `finally` cannot have any
596
- * of those bugs, because the thing that opens the phase is the thing
597
- * that closes it, in the same scope, on every path out.
598
- */
599
- const approvalWaits = new Map();
600
- const holdApprovalWait = (sessionId) => {
601
- approvalWaits.set(sessionId, (approvalWaits.get(sessionId) ?? 0) + 1);
602
- // Stop any clock already running: this wait produces no events, so
603
- // nothing else would.
604
- suspendWatchdog.get(sessionId)?.();
605
- let released = false;
606
- return () => {
607
- if (released) {
608
- return;
609
- }
610
- released = true;
611
- const remaining = (approvalWaits.get(sessionId) ?? 1) - 1;
612
- if (remaining > 0) {
613
- approvalWaits.set(sessionId, remaining);
614
- }
615
- else {
616
- approvalWaits.delete(sessionId);
617
- }
618
- };
619
- };
620
267
  const configuredApprovals = typeof options.approvals === 'function'
621
268
  ? options.approvals({
622
269
  request: requestApproval,
@@ -1396,823 +1043,185 @@ export const createGateway = (options = {}) => {
1396
1043
  return own !== undefined && own === source.to ? undefined : directMessageReadRefusal(`${source.channel}:${source.to}`);
1397
1044
  },
1398
1045
  }));
1046
+ const { provideCredential } = createCredentialRequests({
1047
+ env,
1048
+ warn,
1049
+ bus,
1050
+ startedChannels,
1051
+ channelCarrying,
1052
+ registry,
1053
+ sources,
1054
+ unlistedTools,
1055
+ tools,
1056
+ });
1057
+ // dispatchInternal and onSessionChain are declared further down: late-bound.
1058
+ const { bindToolCalls, subAgents } = createSubAgents({
1059
+ options,
1060
+ store,
1061
+ registry,
1062
+ unlistedTools,
1063
+ tools,
1064
+ dispatchInternal: (...args) => dispatchInternal(...args),
1065
+ onSessionChain: (...args) => onSessionChain(...args),
1066
+ liveSubAgentIds,
1067
+ idAtFirstCall,
1068
+ hostIdOf,
1069
+ isHostSubAgent,
1070
+ });
1071
+ const runners = new Map();
1072
+ // Every provider-construction input participates in the key — model,
1073
+ // endpoint, credentials, system prompt, fallback config. A rotated
1074
+ // credential therefore resolves to a NEW runner on the next dispatch
1075
+ // instead of silently reusing a provider built with the old secret.
1076
+ // (Hashed so raw secrets never sit in a map key; the soul is excluded
1077
+ // because provider construction ignores it.)
1078
+ const runnerKeyFor = (config) => {
1079
+ const { soul: _soul, soulPath: _soulPath, fetch: _fetch, ...providerInputs } = config;
1080
+ return createHash('sha256').update(JSON.stringify(providerInputs)).digest('hex');
1081
+ };
1082
+ // A sub-agent's runner has no memory store, so no turn of it injects or
1083
+ // reads the agent's memory; no skill catalog, so its prompt is its
1084
+ // instruction and its tools; and grants nothing to every agent, since
1085
+ // the credential request asks a person.
1086
+ const runnerFor = (config, agentId, subAgent = false) => {
1087
+ // The budget is a provider-construction input too (the harness
1088
+ // runtimes take it as their inner limit), so it is part of the key:
1089
+ // two agents on one model with different budgets get their own runners.
1090
+ const maxTurns = options.maxTurnsFor?.(agentId) ?? options.maxTurns;
1091
+ const autoContinue = options.autoContinueFor?.(agentId);
1092
+ const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}${subAgent ? ':sub' : ''}`;
1093
+ const existing = runners.get(key);
1094
+ if (existing) {
1095
+ return existing;
1096
+ }
1097
+ // The Claude Code runtime executes kernel tools by calling back into
1098
+ // the runner built just below — late-bound because the runner needs
1099
+ // the provider first.
1100
+ let hostedRunner;
1101
+ const provider = createRuntimeProvider(config, (error) => warn(`default model failed (${error instanceof Error ? error.message : String(error)}); using the fallback model`), async (session, call, context) => {
1102
+ if (!hostedRunner) {
1103
+ throw new Error('The Stratus runtime is not ready to execute tools yet.');
1104
+ }
1105
+ return hostedRunner.executeHostedToolCall(session, call, context);
1106
+ }, maxTurns,
1107
+ // The sticky-fallback switch is durable the moment it happens, not
1108
+ // when the turn's next save lands — a daemon killed mid-fallback
1109
+ // must not retry the primary on restart.
1110
+ (session) => store.save(session), providerContributions);
1111
+ const runner = new AgentRunner({
1112
+ provider,
1113
+ tools,
1114
+ // Runners are built on dispatch, after the plugins loaded and the
1115
+ // selection below ran, so a contributed executor is in place for
1116
+ // the first turn.
1117
+ executor: bindToolCalls(selectedExecutor ?? createLocalCommandExecutor()),
1118
+ ...(approvals ? { approvals } : {}),
1119
+ store,
1120
+ bus,
1121
+ agents: registry,
1122
+ // Asking a person for a key is harmless to grant: they decide
1123
+ // everything after the question, so no soul has to list it.
1124
+ grantedToEveryAgent: subAgent ? [] : [CREDENTIAL_REQUEST_TOOL_NAME],
1125
+ unlistedTools: unlistedTools(),
1126
+ ...(subAgent ? {} : { skills: skillCatalog, memory }),
1127
+ streaming: true,
1128
+ ...(maxTurns !== undefined ? { maxTurns } : {}),
1129
+ ...(autoContinue !== undefined
1130
+ ? { autoContinue, waitingInput: (sessionId) => (waitingDispatches.get(sessionId) ?? 0) > 0 }
1131
+ : {}),
1132
+ providerCallGuards,
1133
+ });
1134
+ hostedRunner = runner;
1135
+ runners.set(key, runner);
1136
+ return runner;
1137
+ };
1399
1138
  /**
1400
- * Credential requests waiting on a person, by request id.
1139
+ * The runner that hears and never speaks. `observe` runs no turn, so it
1140
+ * needs no provider — and resolving one anyway would refuse an overhear
1141
+ * whenever the agent's runtime cannot currently be built: credentials
1142
+ * missing after a restart, a sign-in that lapsed. That is precisely the
1143
+ * window in which the agent cannot answer and the thread carries on
1144
+ * without it, so it is the window in which hearing matters most, and
1145
+ * what it heard is there once the configuration is repaired.
1401
1146
  *
1402
- * In memory on purpose: a request is a question somebody is being asked
1403
- * now, and a restart that forgot it only means the agent asks again. What
1404
- * matters is that the entry carries everything the answer needs, so a
1405
- * channel quoting the id back decides nothing about where the key goes.
1147
+ * A runner rather than a store write, because the label a speaker
1148
+ * lowers the session to is the runner's rule and there is one
1149
+ * implementation of it. Same store and bus as every speaking runner, so
1150
+ * a message it appends is the message the next turn reads, and its
1151
+ * event goes where every other event goes.
1406
1152
  */
1407
- const credentialRequests = new Map();
1408
- tools.register(createCredentialRequestTool(async (request, session) => {
1409
- const agentId = session.agent.id;
1410
- const soulPath = sources.get(agentId)?.soulPath;
1411
- if (soulPath === undefined) {
1412
- throw new Error(`${session.agent.name} is the built-in agent and has no soul file to grant a credential in. `
1413
- + `Your operator can store one on the machine with \`stratus credential set ${request.name}\`.`);
1414
- }
1415
- if (!CREDENTIAL_NAME_PATTERN.test(request.name)) {
1416
- throw new Error(`${JSON.stringify(request.name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
1417
- + 'starting with a letter, the way the tool that needs it spells it: search.apiKey, github.token.');
1418
- }
1419
- // The stricter rule, for a name the model picked: checked before anyone
1420
- // is asked, so an approver never sees a form whose binding the store
1421
- // would refuse, and so a variable that changes what a judged command
1422
- // does (NODE_OPTIONS) is never offered at all.
1423
- if (request.env !== undefined) {
1424
- const problem = credentialVariableProblem(request.env, { requested: true });
1425
- if (problem !== undefined) {
1426
- throw new Error(`${problem} Nothing is pending.`);
1427
- }
1428
- }
1429
- // A key already stored is not something to ask a person for again: the
1430
- // form only adds, so it could only fail. Say what is actually missing.
1431
- const source = await namedCredentialSource(env, agentId, request.name);
1432
- const stored = source !== undefined;
1433
- const granted = session.agent.credentials?.includes(request.name) === true;
1434
- // Binding a key the agent already holds asks for no value, and every
1435
- // form today has a value field, so phase 1 leaves it to the machine.
1436
- const bindOnMachine = request.env !== undefined
1437
- ? ` To have your shell read it as ${request.env}, ask your operator to run \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\` on the machine.`
1438
- : '';
1439
- if (request.replace === true) {
1440
- // Checked here, before anyone is asked, so an approver is never shown
1441
- // a form whose answer could only be refused.
1442
- if (!stored || !granted) {
1443
- throw new Error(`You don't hold ${request.name}, so there is nothing to replace. Ask for it without replace.`);
1444
- }
1445
- if (source === 'environment') {
1446
- throw new Error(`${request.name} comes from the daemon's environment, which a form can't change. `
1447
- + 'Ask your operator to change it where that environment is set.');
1448
- }
1449
- if (source === 'shared' && request.scope !== 'shared') {
1450
- throw new Error(`${request.name} is a shared credential, so replacing it changes the key for every agent granted it. `
1451
- + 'Ask again with scope "shared" if that is what you mean.');
1153
+ const observer = new AgentRunner({
1154
+ provider: {
1155
+ name: 'observer',
1156
+ async generate() {
1157
+ throw new Error('The observing runner never runs a turn.');
1158
+ },
1159
+ },
1160
+ store,
1161
+ bus,
1162
+ });
1163
+ // ---- watchdog -----------------------------------------------------------
1164
+ /**
1165
+ * True when this configuration's provider actually emits deltas: the
1166
+ * Anthropic API path streams; the Claude Code runtime, OpenAI-compatible
1167
+ * adapters, and the demo provider resolve in one piece. An activity
1168
+ * watchdog is only honest where activity signals exist — for
1169
+ * non-streaming providers the idle timer would be a wall-clock timeout
1170
+ * that kills slow-but-healthy turns, so it stays off for them (their
1171
+ * tool phases are covered by executor timeouts regardless).
1172
+ */
1173
+ const fallbackStreamsDeltas = (fallback) =>
1174
+ // The same two modes as a primary. A subscription fallback that is not
1175
+ // recognised as streaming gets no watchdog at all once a session
1176
+ // switches to it, so a stall there would run to the provider's own
1177
+ // ten-minute timer instead of the idle timeout the operator set.
1178
+ (fallback.provider === 'anthropic' && Boolean(fallback.apiKey || fallback.authToken))
1179
+ // The codex harness streams item snapshots in every auth mode.
1180
+ || fallback.provider === 'codex'
1181
+ // A contributed fallback says for itself, exactly as a contributed
1182
+ // primary does below; a session that switched to one must not lose
1183
+ // the watchdog the plugin declared it could have.
1184
+ || (isRegisteredProviderName(fallback.provider)
1185
+ && providerContributions.get(registeredProviderNameOf(fallback.provider))?.streams === true);
1186
+ const streamsDeltas = (config) =>
1187
+ // Both Anthropic modes stream now: an API key through the Messages
1188
+ // API, a subscription token through the Agent SDK's partial messages —
1189
+ // and the codex harness streams item snapshots in every auth mode. A
1190
+ // contributed provider says for itself, and one that did not say is
1191
+ // read as not streaming: a watchdog armed over a silent-until-done
1192
+ // call would abort every long turn.
1193
+ (config.provider === 'anthropic' && Boolean(config.apiKey || config.authToken))
1194
+ || config.provider === 'codex'
1195
+ || (isRegisteredProviderName(config.provider)
1196
+ && providerContributions.get(registeredProviderNameOf(config.provider))?.streams === true);
1197
+ /**
1198
+ * Progress-based abort, armed only while the turn is actually awaiting a
1199
+ * streaming provider — the one phase where silence means a stall. Tool
1200
+ * executions and approval waits emit no deltas (executor timeouts cover
1201
+ * them), so the timer suspends at each provider.response and re-arms once
1202
+ * every tool call from that response has settled and the loop heads back
1203
+ * to the provider. A reset delta marks the mid-turn, session-sticky
1204
+ * switch to the fallback provider, and the timer follows the active
1205
+ * provider through it: switching to a non-streaming fallback suspends it
1206
+ * for the rest of the turn (silence is healthy there), while a streaming
1207
+ * fallback taking over from a non-streaming primary arms it for the
1208
+ * first time.
1209
+ */
1210
+ const withWatchdog = async (sessionId, external, idleEnabled, fallbackStreams, run) => {
1211
+ const effectiveIdleMs = idleEnabled || fallbackStreams ? idleTimeoutMs : 0;
1212
+ if (effectiveIdleMs <= 0 && !external) {
1213
+ // No watchdog and nobody outside to cancel it — but still a turn an
1214
+ // announced restart must be able to cut short at the window.
1215
+ const idle = new AbortController();
1216
+ trackTurn(sessionId, idle);
1217
+ if (abortingWith !== undefined) {
1218
+ idle.abort(shutdownAbort());
1452
1219
  }
1453
- if (source === 'agent' && request.scope === 'shared') {
1454
- throw new Error(`${request.name} is your own credential, not a shared one. Ask again with scope "agent" to replace it.`);
1220
+ try {
1221
+ return await run(idle.signal);
1455
1222
  }
1456
- if (request.via === 'link') {
1457
- throw new Error('A replacement is asked for in a form an approver answers, never a link. Nothing is pending.');
1458
- }
1459
- }
1460
- else if (stored && granted) {
1461
- throw new Error(`You already hold ${request.name}; the tools that need it use it for you. To rotate it for a new value, ask again with replace: true.${bindOnMachine}`
1462
- + (request.env !== undefined ? ' Nothing is pending.' : ''));
1463
- }
1464
- else if (stored) {
1465
- throw new Error(`${request.name} is already ${source === 'environment' ? "supplied by the daemon's environment" : 'stored'} but not granted to you. `
1466
- + `Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.${bindOnMachine}`);
1467
- }
1468
- const onMachine = request.env === undefined
1469
- ? `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``
1470
- : request.scope === 'agent'
1471
- ? `\`stratus credential set ${request.name} --agent ${quoteShellArg(agentId)} --env ${request.env}\``
1472
- : `\`stratus credential set ${request.name}\`, then \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\``;
1473
- const record = {
1474
- agentId,
1475
- sessionId: session.id,
1476
- name: request.name,
1477
- scope: request.scope,
1478
- soulPath,
1479
- ...(request.replace === true ? { replace: true } : {}),
1480
- ...(request.env !== undefined ? { env: request.env } : {}),
1481
- };
1482
- const announce = async (requestId, via) => {
1483
- await bus.emit({
1484
- type: 'credential.requested',
1485
- sessionId: session.id,
1486
- agentId,
1487
- requestId,
1488
- name: request.name,
1489
- scope: request.scope,
1490
- ...(request.reason !== undefined ? { reason: request.reason } : {}),
1491
- via,
1492
- ...(request.replace === true ? { replace: true } : {}),
1493
- ...(request.env !== undefined ? { env: request.env } : {}),
1494
- ...(session.metadata ? { metadata: session.metadata } : {}),
1495
- });
1496
- };
1497
- // A link only for an agent whose soul lists the tool. Every agent may
1498
- // ask through a form, because a form goes to an approver; a link is a
1499
- // bearer credential the model is handed, and whoever it reaches can
1500
- // store a key and amend the soul. That is the operator's to allow.
1501
- const allowlist = session.agent.tools ?? registry.get(agentId)?.tools;
1502
- // An omitted list grants the safe tools, this one among them, but a
1503
- // link is the operator's to allow by naming it, so only a list says yes.
1504
- // On a home an older build set up an omitted list is every tool, and
1505
- // has always allowed a link there.
1506
- const linkAllowed = allowlist === undefined
1507
- ? unlistedTools() === 'every'
1508
- : matchesToolAllowlist(CREDENTIAL_REQUEST_TOOL_NAME, allowlist);
1509
- // Why no form went up, said to the agent beside the link that replaces
1510
- // it, so it can tell the person why they are getting a link instead.
1511
- let formUnavailable;
1512
- if (request.via !== 'link') {
1513
- // Only a conversation a channel started has anywhere to show a form,
1514
- // and only a channel that renders one can show it: a scheduled or
1515
- // HTTP turn, or a channel with no form, would record a request nobody
1516
- // sees while the agent tells its user the operator was asked.
1517
- const kind = session.metadata?.channel;
1518
- const { adapter } = typeof kind === 'string'
1519
- ? channelCarrying(kind, agentId, (candidate) => candidate.requestCredential !== undefined
1520
- && (request.replace !== true || candidate.credentialReplaceForms === true)
1521
- // A form that can't say the key will be shell-readable must not
1522
- // be the approval for it: a link says it instead.
1523
- && (request.env === undefined || candidate.credentialEnvForms === true))
1524
- : { adapter: undefined };
1525
- if (!adapter?.requestCredential || !session.metadata) {
1526
- formUnavailable = typeof kind === 'string'
1527
- ? `This conversation's channel (${kind}) cannot show your operator a ${request.replace === true ? 'replacement' : 'credential'} form here${request.env !== undefined ? ' that says which variable your shell will read it from' : ''}.`
1528
- : 'This conversation is not in a channel that can show your operator a form.';
1529
- }
1530
- else {
1531
- const requestId = randomUUID();
1532
- credentialRequests.set(requestId, { ...record, via: 'form' });
1533
- // Delivered before it is announced, and dropped if delivery fails: a
1534
- // request whose post never landed has no button to answer it, so
1535
- // keeping it would leave it pending for good behind a tool result
1536
- // saying the operator was asked.
1537
- try {
1538
- await adapter.requestCredential({
1539
- sessionId: session.id,
1540
- agentId,
1541
- requestId,
1542
- name: request.name,
1543
- scope: request.scope,
1544
- ...(request.reason !== undefined ? { reason: request.reason } : {}),
1545
- ...(request.replace === true ? { replace: true } : {}),
1546
- ...(request.env !== undefined ? { env: request.env } : {}),
1547
- metadata: session.metadata,
1548
- });
1549
- await announce(requestId, 'form');
1550
- return { requestId, via: 'form' };
1551
- }
1552
- catch (error) {
1553
- credentialRequests.delete(requestId);
1554
- formUnavailable = `The form could not be shown here: ${error instanceof Error ? error.message : String(error)}`;
1555
- }
1556
- }
1557
- if (request.replace === true) {
1558
- // Never a link: whoever holds one could change a key in use.
1559
- throw new Error(`${formUnavailable} Nothing is pending. A replacement needs a conversation that can show an approver a form.`);
1560
- }
1561
- if (request.via === 'form') {
1562
- throw new Error(`${formUnavailable} Nothing is pending. ${linkAllowed ? 'Ask for a link instead (via: "link"), or ask' : 'Ask'} your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1563
- }
1564
- }
1565
- if (!linkAllowed) {
1566
- throw new Error(`${formUnavailable !== undefined ? `${formUnavailable} ` : ''}A one-time link needs ${CREDENTIAL_REQUEST_TOOL_NAME} in your soul's tools: list, and it is not there. Nothing is pending. `
1567
- + `Ask in a conversation that can show your operator a form, or ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1568
- }
1569
- // A link where no form can go, from whichever adapter issues them: the
1570
- // control API, which is not tied to any one agent's transport.
1571
- const linker = startedChannels.find((candidate) => candidate.requestCredentialLink !== undefined);
1572
- if (!linker?.requestCredentialLink) {
1573
- throw new Error(`${formUnavailable !== undefined ? `${formUnavailable} ` : ''}No control API is running to issue a link either. `
1574
- + `Ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1575
- }
1576
- const requestId = randomUUID();
1577
- credentialRequests.set(requestId, { ...record, via: 'link' });
1578
- let link;
1579
- try {
1580
- link = await linker.requestCredentialLink({
1581
- sessionId: session.id,
1582
- agentId,
1583
- agentName: session.agent.name,
1584
- requestId,
1585
- name: request.name,
1586
- scope: request.scope,
1587
- ...(request.reason !== undefined ? { reason: request.reason } : {}),
1588
- ...(request.env !== undefined ? { env: request.env } : {}),
1589
- });
1590
- }
1591
- catch (error) {
1592
- credentialRequests.delete(requestId);
1593
- throw new Error(`A link for ${request.name} could not be issued: ${error instanceof Error ? error.message : String(error)} `
1594
- + `Nothing is pending. Ask your operator to store it on the machine with ${onMachine}.`);
1595
- }
1596
- await announce(requestId, 'link');
1597
- return {
1598
- requestId,
1599
- via: 'link',
1600
- url: link.url,
1601
- expiresAt: link.expiresAt,
1602
- ...(link.localOnly === true ? { localOnly: true } : {}),
1603
- ...(formUnavailable !== undefined ? { formUnavailable } : {}),
1604
- };
1605
- }));
1606
- /**
1607
- * Requests an answer is being stored for now. Claimed before the first
1608
- * await: two approvers submitting one form at once would otherwise both
1609
- * start storing, and the second's add-only conflict would retire a
1610
- * request the first was about to settle as added.
1611
- */
1612
- const answering = new Set();
1613
- const provideCredential = async (input) => {
1614
- const request = credentialRequests.get(input.requestId);
1615
- if (!request) {
1616
- return { ok: false, retired: true, message: 'That credential request is no longer pending. Ask the agent to request it again.' };
1617
- }
1618
- if (answering.has(input.requestId)) {
1619
- return { ok: false, message: 'Someone else\'s answer to this request is being stored right now. Wait a moment before trying again.' };
1620
- }
1621
- answering.add(input.requestId);
1622
- try {
1623
- return await answerCredentialRequest(input, request);
1624
- }
1625
- finally {
1626
- answering.delete(input.requestId);
1627
- }
1628
- };
1629
- /**
1630
- * The audit line for a key changed from a chat or a link. After the
1631
- * store, since it records what happened; a line that can't be written is
1632
- * warned about rather than undoing a change that already landed.
1633
- */
1634
- const auditCredentialChange = async (action, request, actor) => {
1635
- try {
1636
- await appendCredentialAudit(env, {
1637
- action,
1638
- name: request.name,
1639
- ...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
1640
- ...(actor !== undefined ? { actor } : {}),
1641
- via: request.via ?? 'form',
1642
- requestedBy: request.agentId,
1643
- ...(request.env !== undefined ? { env: request.env, bindingFor: request.agentId } : {}),
1644
- });
1645
- }
1646
- catch (error) {
1647
- warn(`credentials: ${request.name} was ${action === 'add' ? 'added' : 'replaced'}, but the audit line could not be written (${error instanceof Error ? error.message : String(error)}).`);
1648
- }
1649
- };
1650
- const answerCredentialRequest = async (input, request) => {
1651
- // The agent must still be served from the file the request captured: a
1652
- // repointed default soul (same id, another file) replaces the source,
1653
- // and a grant landing in the old file would report a key the agent now
1654
- // running cannot use.
1655
- const served = sources.get(request.agentId)?.soulPath;
1656
- if (served !== request.soulPath) {
1657
- credentialRequests.delete(input.requestId);
1658
- return {
1659
- ok: false,
1660
- retired: true,
1661
- message: `${request.agentId} is no longer served from ${request.soulPath}, so nothing was stored. Ask ${request.agentId} to request it again.`,
1662
- };
1663
- }
1664
- // The soul the grant will land in must still be the requester's, and is
1665
- // checked before anything is stored: a file reassigned to another agent
1666
- // while the request waited would otherwise receive a key nobody meant
1667
- // to give it. `grantSoulCredential` checks again as it writes.
1668
- let owner;
1669
- try {
1670
- owner = (await loadSoulFile(request.soulPath)).agent.id;
1671
- }
1672
- catch (error) {
1673
- return {
1674
- ok: false,
1675
- message: `${request.agentId}'s soul could not be read (${error instanceof Error ? error.message : String(error)}), so nothing was stored.`,
1676
- };
1677
- }
1678
- if (owner !== request.agentId) {
1679
- credentialRequests.delete(input.requestId);
1680
- return {
1681
- ok: false,
1682
- retired: true,
1683
- message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
1684
- };
1685
- }
1686
- const value = formCredentialValue(input.value);
1687
- if (isFlattenedPem(value)) {
1688
- // Refused, not retired: the same form can still take the key whole.
1689
- const command = request.scope === 'agent'
1690
- ? `stratus credential set ${request.name} --agent ${quoteShellArg(request.agentId)} < key.pem`
1691
- : `stratus credential set ${request.name} < key.pem`;
1692
- return {
1693
- ok: false,
1694
- message: 'That looks like a PEM key whose line breaks were lost, so nothing was stored: whatever reads it would fail. '
1695
- + `Paste it again from the original file with its line breaks, or store it on the machine: ${command}.`,
1696
- };
1697
- }
1698
- const store = {
1699
- ...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
1700
- // The binding the form promised, written with the value, always for
1701
- // the agent that asked: consent is per agent even for a shared key.
1702
- ...(request.env !== undefined ? { bind: { agentId: request.agentId, variable: request.env } } : {}),
1703
- };
1704
- if (request.replace === true) {
1705
- // Re-read under the soul lock, so a grant removed while the form
1706
- // waited (a shared key most of all) is never rotated on its behalf.
1707
- let stillGranted;
1708
- try {
1709
- stillGranted = await withSoulFileLock(async () => {
1710
- const soul = await loadSoulFile(request.soulPath);
1711
- if (soul.agent.id !== request.agentId || soul.agent.credentials?.includes(request.name) !== true) {
1712
- return false;
1713
- }
1714
- await replaceNamedCredential(env, { name: request.name, value, ...store });
1715
- return true;
1716
- });
1717
- }
1718
- catch (error) {
1719
- // Removed since the request was made: no value can land now.
1720
- if (error instanceof NamedCredentialMissingError) {
1721
- credentialRequests.delete(input.requestId);
1722
- return { ok: false, retired: true, message: error.message };
1723
- }
1724
- return { ok: false, message: error instanceof Error ? error.message : String(error) };
1725
- }
1726
- if (!stillGranted) {
1727
- credentialRequests.delete(input.requestId);
1728
- return {
1729
- ok: false,
1730
- retired: true,
1731
- message: `${request.agentId} is no longer granted ${request.name} in ${request.soulPath}, so the stored key was kept. Ask ${request.agentId} to request it again if it still needs it.`,
1732
- };
1733
- }
1734
- credentialRequests.delete(input.requestId);
1735
- await auditCredentialChange('replace', request, input.actor);
1736
- await bus.emit({
1737
- type: 'credential.provided',
1738
- sessionId: request.sessionId,
1739
- agentId: request.agentId,
1740
- requestId: input.requestId,
1741
- name: request.name,
1742
- scope: request.scope,
1743
- replace: true,
1744
- ...(request.env !== undefined ? { env: request.env } : {}),
1745
- ...(input.actor !== undefined ? { actor: input.actor } : {}),
1746
- });
1747
- // The soul already lists it: a replace is only asked for a key held.
1748
- return { ok: true, name: request.name, scope: request.scope, agentId: request.agentId, granted: false };
1749
- }
1750
- try {
1751
- await addNamedCredential(env, {
1752
- name: request.name,
1753
- value,
1754
- ...store,
1755
- });
1756
- }
1757
- catch (error) {
1758
- // A name stored since the request was made (another form for the
1759
- // same shared key, or the machine) is final under add-only: no value
1760
- // typed into this form can ever land, so the request is retired
1761
- // rather than left answering every submission with the same refusal.
1762
- if (error instanceof NamedCredentialExistsError) {
1763
- credentialRequests.delete(input.requestId);
1764
- return { ok: false, retired: true, message: error.message };
1765
- }
1766
- // Otherwise refused, not failed: the request stays pending, so a
1767
- // person who typed an empty value can try again from the same form.
1768
- return { ok: false, message: error instanceof Error ? error.message : String(error) };
1769
- }
1770
- credentialRequests.delete(input.requestId);
1771
- await auditCredentialChange('add', request, input.actor);
1772
- // Stored first, granted second: a key nobody may use is recoverable by
1773
- // editing the soul, while a soul naming a key that was never stored
1774
- // fails every call that reaches for it. Announced either way, because
1775
- // the store already changed and a shared key is already usable.
1776
- //
1777
- // The served path is checked again under the soul lock, which roster
1778
- // reloads take too: a default repointed while the key was being
1779
- // written, or while the grant queued behind another soul edit, would
1780
- // otherwise be granted in a file the running agent no longer reads.
1781
- let granted = false;
1782
- let grantError;
1783
- try {
1784
- granted = await grantSoulCredential(request.soulPath, request.name, request.agentId, {
1785
- check: () => {
1786
- const servedNow = sources.get(request.agentId)?.soulPath;
1787
- if (servedNow !== request.soulPath) {
1788
- throw new Error(`${request.agentId} is now served from ${servedNow ?? 'no soul file'}, not ${request.soulPath}`);
1789
- }
1790
- },
1791
- });
1792
- }
1793
- catch (error) {
1794
- grantError = error instanceof Error ? error.message : String(error);
1795
- }
1796
- await bus.emit({
1797
- type: 'credential.provided',
1798
- sessionId: request.sessionId,
1799
- agentId: request.agentId,
1800
- requestId: input.requestId,
1801
- name: request.name,
1802
- scope: request.scope,
1803
- ...(request.env !== undefined ? { env: request.env } : {}),
1804
- ...(input.actor !== undefined ? { actor: input.actor } : {}),
1805
- ...(grantError !== undefined ? { grantError } : {}),
1806
- });
1807
- if (grantError !== undefined) {
1808
- return {
1809
- ok: false,
1810
- retired: true,
1811
- message: `${request.name} was stored, but could not be added to ${request.soulPath} `
1812
- + `(${grantError}). Add it to the soul's credentials list by hand.`,
1813
- };
1814
- }
1815
- return { ok: true, name: request.name, scope: request.scope, agentId: request.agentId, granted };
1816
- };
1817
- tools.register(createDelegateTool({
1818
- registry,
1819
- dispatch: (input) => dispatchInternal({
1820
- sessionId: input.sessionId,
1821
- agentId: input.agent.id,
1822
- userMessage: input.userMessage,
1823
- metadata: input.metadata,
1824
- // A cancelled parent turn cancels its delegated runs too.
1825
- ...(input.signal ? { signal: input.signal } : {}),
1826
- }),
1827
- }));
1828
- // ---- sub-agents (roadmap 24) -------------------------------------------
1829
- /**
1830
- * Running sub-agents per parent session, and who waits for a slot. A
1831
- * parent session runs one turn at a time, so this is per turn.
1832
- */
1833
- const maxSubAgentsPerTurn = options.subAgents?.maxConcurrentPerTurn ?? DEFAULT_SUB_AGENTS_PER_TURN;
1834
- // NaN, 0, a fraction or Infinity would queue every sub-agent forever or
1835
- // defeat the cap, so refuse it here instead of failing later in a turn.
1836
- if (!Number.isInteger(maxSubAgentsPerTurn) || maxSubAgentsPerTurn < 1) {
1837
- throw new Error(`subAgents.maxConcurrentPerTurn must be a whole number of at least 1 (got ${String(maxSubAgentsPerTurn)}).`);
1838
- }
1839
- const subAgentSlots = new Map();
1840
- let subAgentCount = 0;
1841
- /**
1842
- * The session object each in-flight tool call was handed, with that
1843
- * call's turn signal. `subAgents.run` accepts only a parent found here:
1844
- * the session a running tool was given, by identity, never an id, so a
1845
- * plugin cannot start sub-agents of another turn (another agent's
1846
- * included) just because that turn is running. The signal is how the
1847
- * parent's cancel reaches its sub-agents whether or not the plugin
1848
- * passes one.
1849
- */
1850
- const toolCallsInFlight = new Map();
1851
- const bindToolCalls = (inner) => ({
1852
- async execute(call, tool, session, context) {
1853
- if (!idAtFirstCall.has(session)) {
1854
- idAtFirstCall.set(session, session.id);
1855
- }
1856
- const entry = toolCallsInFlight.get(session) ?? [];
1857
- toolCallsInFlight.set(session, entry);
1858
- const signal = context?.signal;
1859
- entry.push(signal);
1860
- try {
1861
- return await inner.execute(call, tool, session, context);
1862
- }
1863
- finally {
1864
- entry.splice(entry.indexOf(signal), 1);
1865
- if (entry.length === 0) {
1866
- toolCallsInFlight.delete(session);
1867
- }
1868
- }
1869
- },
1870
- });
1871
- const subAgentSlot = (parentId, signal) => new Promise((resolve, reject) => {
1872
- const entry = subAgentSlots.get(parentId) ?? { running: 0, waiting: [] };
1873
- subAgentSlots.set(parentId, entry);
1874
- const release = () => {
1875
- entry.running -= 1;
1876
- const next = entry.waiting.shift();
1877
- if (next) {
1878
- next();
1879
- }
1880
- else if (entry.running === 0) {
1881
- subAgentSlots.delete(parentId);
1882
- }
1883
- };
1884
- const take = () => {
1885
- entry.running += 1;
1886
- resolve(release);
1887
- };
1888
- if (entry.running < maxSubAgentsPerTurn) {
1889
- take();
1890
- return;
1891
- }
1892
- const onAbort = () => {
1893
- const at = entry.waiting.indexOf(waiter);
1894
- if (at >= 0) {
1895
- entry.waiting.splice(at, 1);
1896
- }
1897
- reject(abortErrorFor(signal));
1898
- };
1899
- const waiter = () => {
1900
- signal?.removeEventListener('abort', onAbort);
1901
- take();
1902
- };
1903
- entry.waiting.push(waiter);
1904
- signal?.addEventListener('abort', onAbort, { once: true });
1905
- });
1906
- /** Why this slice cannot be a sub-agent's, or undefined when it can. */
1907
- const subAgentSliceProblem = (parent, requested) => {
1908
- if (!Array.isArray(requested) || requested.some((name) => typeof name !== 'string' || name.length === 0)) {
1909
- return 'A sub-agent\'s tools are a list of tool names.';
1910
- }
1911
- // Read the way the runner reads it for the parent's own calls: the
1912
- // session's copy, else the registered agent's.
1913
- const allowlist = parent.agent.tools ?? registry.get(parent.agent.id)?.tools;
1914
- for (const name of requested) {
1915
- if (/[*?]/.test(name)) {
1916
- return `A sub-agent's tools are exact names, and ${name} is a pattern.`;
1917
- }
1918
- if (subAgentNeverGets(name)) {
1919
- return `${name} is never given to a sub-agent, even by an agent that holds it.`;
1920
- }
1921
- const tool = tools.get(name);
1922
- if (!tool) {
1923
- return `There is no tool named ${name}.`;
1924
- }
1925
- // The parent's allowlist as its running turn holds it: a slice can
1926
- // only narrow what that turn may already do. With no list, that is
1927
- // the tools registered as safe (every tool on a home an older build
1928
- // set up), and the slice is held to the same rule: naming a gated
1929
- // tool must not hand it to the sub-agent.
1930
- if (allowlist === undefined) {
1931
- if (unlistedTools() !== 'every' && resolveToolRisk(tool) !== 'safe') {
1932
- 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}.`;
1933
- }
1934
- }
1935
- else if (!matchesToolAllowlist(name, allowlist)) {
1936
- return `${name} is not one of ${parent.agent.id}'s tools, so a sub-agent of it cannot have it.`;
1937
- }
1938
- }
1939
- return undefined;
1940
- };
1941
- const subAgents = {
1942
- async run(request) {
1943
- // Copied before the first await, and only the copy is read from here
1944
- // on: the plugin keeps its own objects and can change them while this
1945
- // request is checked or waits for a slot, and a slice changed after
1946
- // it was checked must not be the slice the sub-agent runs with.
1947
- const handed = request?.parent;
1948
- const callSignals = handed ? toolCallsInFlight.get(handed) : undefined;
1949
- if (!callSignals) {
1950
- throw new Error('subAgents.run starts sub-agents only for the session your tool was handed, while that call is running. Pass that session object as "parent".');
1951
- }
1952
- // The parent turn's own stop, and the caller's as an extra one: a
1953
- // plugin that passes no signal still has its sub-agents cancelled
1954
- // with the turn.
1955
- const stops = [...callSignals, request.signal].filter((signal) => signal !== undefined);
1956
- const input = {
1957
- parent: { id: hostIdOf(handed) },
1958
- instruction: request?.instruction,
1959
- tools: Array.isArray(request?.tools) ? [...request.tools] : request?.tools,
1960
- model: request?.model,
1961
- signal: stops.length === 0 ? undefined : stops.length === 1 ? stops[0] : AbortSignal.any(stops),
1962
- };
1963
- const parentId = typeof input.parent.id === 'string' ? input.parent.id : '';
1964
- if (!parentId) {
1965
- throw new Error('subAgents.run needs the session of the turn asking.');
1966
- }
1967
- const instruction = typeof input.instruction === 'string' ? input.instruction.trim() : '';
1968
- if (!instruction) {
1969
- throw new Error('subAgents.run needs an instruction.');
1970
- }
1971
- if (input.model !== undefined) {
1972
- throw new Error('A sub-agent\'s model cannot be chosen yet: it runs on its parent\'s. Leave "model" out.');
1973
- }
1974
- const parent = await store.get(parentId);
1975
- if (!parent) {
1976
- throw new Error(`There is no session ${parentId} to start a sub-agent from.`);
1977
- }
1978
- if (isHostSubAgent(handed) || isSubAgentSession(parent)) {
1979
- throw new Error('A sub-agent cannot start sub-agents.');
1980
- }
1981
- if (parent.status !== 'running') {
1982
- throw new Error(`Session ${parentId} is not running a turn, and sub-agents start only from one.`);
1983
- }
1984
- const depth = typeof parent.metadata?.[DELEGATION_DEPTH_METADATA_KEY] === 'number'
1985
- ? parent.metadata[DELEGATION_DEPTH_METADATA_KEY]
1986
- : 0;
1987
- if (depth >= DEFAULT_MAX_DELEGATION_DEPTH) {
1988
- throw new Error(`Delegation depth limit reached (${DEFAULT_MAX_DELEGATION_DEPTH}).`);
1989
- }
1990
- const problem = subAgentSliceProblem(parent, input.tools);
1991
- if (problem) {
1992
- throw new Error(problem);
1993
- }
1994
- if (input.signal?.aborted) {
1995
- throw abortErrorFor(input.signal);
1996
- }
1997
- const release = await subAgentSlot(parent.id, input.signal);
1998
- subAgentCount += 1;
1999
- const sessionId = `${parent.id}${SUB_AGENT_SESSION_ID_MARKER}${depth + 1}:${subAgentCount}-${randomUUID().slice(0, 8)}`;
2000
- // The parent agent, narrowed: its id, provider and credentials, the
2001
- // slice for tools, and nothing else — no instructions (the persona),
2002
- // no skills, no delegates.
2003
- const definition = {
2004
- id: parent.agent.id,
2005
- name: parent.agent.name,
2006
- tools: [...new Set(input.tools)],
2007
- skills: [],
2008
- delegates: [],
2009
- ...(parent.agent.credentials !== undefined ? { credentials: [...parent.agent.credentials] } : {}),
2010
- };
2011
- let session;
2012
- liveSubAgentIds.add(sessionId);
2013
- try {
2014
- session = await onSessionChain(sessionId, () => dispatchInternal({
2015
- sessionId,
2016
- agentId: parent.agent.id,
2017
- userMessage: instruction,
2018
- metadata: {
2019
- [SUB_AGENT_METADATA_KEY]: true,
2020
- [DELEGATION_DEPTH_METADATA_KEY]: depth + 1,
2021
- [DELEGATED_BY_METADATA_KEY]: parent.agent.id,
2022
- [ROOT_SESSION_ID_METADATA_KEY]: typeof parent.metadata?.[ROOT_SESSION_ID_METADATA_KEY] === 'string'
2023
- ? parent.metadata[ROOT_SESSION_ID_METADATA_KEY]
2024
- : parent.id,
2025
- // The instruction is the parent's text, at the parent's label:
2026
- // a tainted parent must not hand a stranger's words to a
2027
- // session that has seen nothing.
2028
- [SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(parent),
2029
- // On the runtime serving the parent's turn: a parent that has
2030
- // switched to its sticky fallback must not have its helpers
2031
- // retry the primary it left.
2032
- ...(parent.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true ? { [FALLBACK_ACTIVE_METADATA_KEY]: true } : {}),
2033
- },
2034
- ...(input.signal ? { signal: input.signal } : {}),
2035
- }, [], definition));
2036
- }
2037
- catch (error) {
2038
- if (input.signal?.aborted) {
2039
- throw error;
2040
- }
2041
- // The error text may be the provider quoting what it refused,
2042
- // which is this sub-agent's context: labelled as nobody can vouch.
2043
- return { status: 'error', error: error instanceof Error ? error.message : String(error), sessionId, trust: 'unknown' };
2044
- }
2045
- finally {
2046
- liveSubAgentIds.delete(sessionId);
2047
- release();
2048
- }
2049
- const trust = sessionTrustOf(session);
2050
- if (session.status === 'failed') {
2051
- return { status: 'error', error: session.lastError ?? 'The sub-agent failed.', sessionId, trust: 'unknown' };
2052
- }
2053
- const reply = [...session.messages]
2054
- .reverse()
2055
- .find((message) => message.role === 'assistant' && message.content.trim().length > 0);
2056
- const outcome = reply
2057
- ? { status: 'result', text: reply.content, sessionId, trust }
2058
- : { status: 'empty', sessionId, trust };
2059
- return outcome;
2060
- },
2061
- };
2062
- const runners = new Map();
2063
- // Every provider-construction input participates in the key — model,
2064
- // endpoint, credentials, system prompt, fallback config. A rotated
2065
- // credential therefore resolves to a NEW runner on the next dispatch
2066
- // instead of silently reusing a provider built with the old secret.
2067
- // (Hashed so raw secrets never sit in a map key; the soul is excluded
2068
- // because provider construction ignores it.)
2069
- const runnerKeyFor = (config) => {
2070
- const { soul: _soul, soulPath: _soulPath, fetch: _fetch, ...providerInputs } = config;
2071
- return createHash('sha256').update(JSON.stringify(providerInputs)).digest('hex');
2072
- };
2073
- // A sub-agent's runner has no memory store, so no turn of it injects or
2074
- // reads the agent's memory; no skill catalog, so its prompt is its
2075
- // instruction and its tools; and grants nothing to every agent, since
2076
- // the credential request asks a person.
2077
- const runnerFor = (config, agentId, subAgent = false) => {
2078
- // The budget is a provider-construction input too (the harness
2079
- // runtimes take it as their inner limit), so it is part of the key:
2080
- // two agents on one model with different budgets get their own runners.
2081
- const maxTurns = options.maxTurnsFor?.(agentId) ?? options.maxTurns;
2082
- const autoContinue = options.autoContinueFor?.(agentId);
2083
- const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}${subAgent ? ':sub' : ''}`;
2084
- const existing = runners.get(key);
2085
- if (existing) {
2086
- return existing;
2087
- }
2088
- // The Claude Code runtime executes kernel tools by calling back into
2089
- // the runner built just below — late-bound because the runner needs
2090
- // the provider first.
2091
- let hostedRunner;
2092
- const provider = createRuntimeProvider(config, (error) => warn(`default model failed (${error instanceof Error ? error.message : String(error)}); using the fallback model`), async (session, call, context) => {
2093
- if (!hostedRunner) {
2094
- throw new Error('The Stratus runtime is not ready to execute tools yet.');
2095
- }
2096
- return hostedRunner.executeHostedToolCall(session, call, context);
2097
- }, maxTurns,
2098
- // The sticky-fallback switch is durable the moment it happens, not
2099
- // when the turn's next save lands — a daemon killed mid-fallback
2100
- // must not retry the primary on restart.
2101
- (session) => store.save(session), providerContributions);
2102
- const runner = new AgentRunner({
2103
- provider,
2104
- tools,
2105
- // Runners are built on dispatch, after the plugins loaded and the
2106
- // selection below ran, so a contributed executor is in place for
2107
- // the first turn.
2108
- executor: bindToolCalls(selectedExecutor ?? createLocalCommandExecutor()),
2109
- ...(approvals ? { approvals } : {}),
2110
- store,
2111
- bus,
2112
- agents: registry,
2113
- // Asking a person for a key is harmless to grant: they decide
2114
- // everything after the question, so no soul has to list it.
2115
- grantedToEveryAgent: subAgent ? [] : [CREDENTIAL_REQUEST_TOOL_NAME],
2116
- unlistedTools: unlistedTools(),
2117
- ...(subAgent ? {} : { skills: skillCatalog, memory }),
2118
- streaming: true,
2119
- ...(maxTurns !== undefined ? { maxTurns } : {}),
2120
- ...(autoContinue !== undefined
2121
- ? { autoContinue, waitingInput: (sessionId) => (waitingDispatches.get(sessionId) ?? 0) > 0 }
2122
- : {}),
2123
- providerCallGuards,
2124
- });
2125
- hostedRunner = runner;
2126
- runners.set(key, runner);
2127
- return runner;
2128
- };
2129
- /**
2130
- * The runner that hears and never speaks. `observe` runs no turn, so it
2131
- * needs no provider — and resolving one anyway would refuse an overhear
2132
- * whenever the agent's runtime cannot currently be built: credentials
2133
- * missing after a restart, a sign-in that lapsed. That is precisely the
2134
- * window in which the agent cannot answer and the thread carries on
2135
- * without it, so it is the window in which hearing matters most, and
2136
- * what it heard is there once the configuration is repaired.
2137
- *
2138
- * A runner rather than a store write, because the label a speaker
2139
- * lowers the session to is the runner's rule and there is one
2140
- * implementation of it. Same store and bus as every speaking runner, so
2141
- * a message it appends is the message the next turn reads, and its
2142
- * event goes where every other event goes.
2143
- */
2144
- const observer = new AgentRunner({
2145
- provider: {
2146
- name: 'observer',
2147
- async generate() {
2148
- throw new Error('The observing runner never runs a turn.');
2149
- },
2150
- },
2151
- store,
2152
- bus,
2153
- });
2154
- // ---- watchdog -----------------------------------------------------------
2155
- /**
2156
- * True when this configuration's provider actually emits deltas: the
2157
- * Anthropic API path streams; the Claude Code runtime, OpenAI-compatible
2158
- * adapters, and the demo provider resolve in one piece. An activity
2159
- * watchdog is only honest where activity signals exist — for
2160
- * non-streaming providers the idle timer would be a wall-clock timeout
2161
- * that kills slow-but-healthy turns, so it stays off for them (their
2162
- * tool phases are covered by executor timeouts regardless).
2163
- */
2164
- const fallbackStreamsDeltas = (fallback) =>
2165
- // The same two modes as a primary. A subscription fallback that is not
2166
- // recognised as streaming gets no watchdog at all once a session
2167
- // switches to it, so a stall there would run to the provider's own
2168
- // ten-minute timer instead of the idle timeout the operator set.
2169
- (fallback.provider === 'anthropic' && Boolean(fallback.apiKey || fallback.authToken))
2170
- // The codex harness streams item snapshots in every auth mode.
2171
- || fallback.provider === 'codex'
2172
- // A contributed fallback says for itself, exactly as a contributed
2173
- // primary does below; a session that switched to one must not lose
2174
- // the watchdog the plugin declared it could have.
2175
- || (isRegisteredProviderName(fallback.provider)
2176
- && providerContributions.get(registeredProviderNameOf(fallback.provider))?.streams === true);
2177
- const streamsDeltas = (config) =>
2178
- // Both Anthropic modes stream now: an API key through the Messages
2179
- // API, a subscription token through the Agent SDK's partial messages —
2180
- // and the codex harness streams item snapshots in every auth mode. A
2181
- // contributed provider says for itself, and one that did not say is
2182
- // read as not streaming: a watchdog armed over a silent-until-done
2183
- // call would abort every long turn.
2184
- (config.provider === 'anthropic' && Boolean(config.apiKey || config.authToken))
2185
- || config.provider === 'codex'
2186
- || (isRegisteredProviderName(config.provider)
2187
- && providerContributions.get(registeredProviderNameOf(config.provider))?.streams === true);
2188
- /**
2189
- * Progress-based abort, armed only while the turn is actually awaiting a
2190
- * streaming provider — the one phase where silence means a stall. Tool
2191
- * executions and approval waits emit no deltas (executor timeouts cover
2192
- * them), so the timer suspends at each provider.response and re-arms once
2193
- * every tool call from that response has settled and the loop heads back
2194
- * to the provider. A reset delta marks the mid-turn, session-sticky
2195
- * switch to the fallback provider, and the timer follows the active
2196
- * provider through it: switching to a non-streaming fallback suspends it
2197
- * for the rest of the turn (silence is healthy there), while a streaming
2198
- * fallback taking over from a non-streaming primary arms it for the
2199
- * first time.
2200
- */
2201
- const withWatchdog = async (sessionId, external, idleEnabled, fallbackStreams, run) => {
2202
- const effectiveIdleMs = idleEnabled || fallbackStreams ? idleTimeoutMs : 0;
2203
- if (effectiveIdleMs <= 0 && !external) {
2204
- // No watchdog and nobody outside to cancel it — but still a turn an
2205
- // announced restart must be able to cut short at the window.
2206
- const idle = new AbortController();
2207
- trackTurn(sessionId, idle);
2208
- if (abortingWith !== undefined) {
2209
- idle.abort(shutdownAbort());
2210
- }
2211
- try {
2212
- return await run(idle.signal);
2213
- }
2214
- finally {
2215
- untrackTurn(sessionId, idle);
1223
+ finally {
1224
+ untrackTurn(sessionId, idle);
2216
1225
  }
2217
1226
  }
2218
1227
  const controller = new AbortController();
@@ -2546,843 +1555,183 @@ export const createGateway = (options = {}) => {
2546
1555
  */
2547
1556
  const openBatches = new Map();
2548
1557
  /** The start-up snapshot `recoverParkedTurns` judges orphans from, for a repeat that gets there first. */
2549
- let orphanedAtStart = new Set();
2550
- /**
2551
- * The runtime one agent would run on right now — config snapshot, soul
2552
- * pins, degradation and all.
2553
- *
2554
- * Extracted so recovery resolves it exactly as a dispatch does. A second
2555
- * copy of this chain is the difference between a recovered turn finishing
2556
- * on its agent's provider and finishing on the daemon's default, and it
2557
- * would drift the first time either side gained a rule.
2558
- */
2559
- const runtimeForAgent = async (source) => {
2560
- // Config re-resolves per dispatch, so a changed model, credential, or
2561
- // soul pin applies without a restart. The pool dedupes runners by the
2562
- // resolved configuration. The gateway-wide selection and the daemon's
2563
- // environment are defaults, not overrides: resolveRuntimeConfig gives
2564
- // both higher precedence than the soul (they are explicit flags and
2565
- // env vars on the CLI), so any default the soul pins over is dropped
2566
- // here — an agent pinned to a provider keeps it whatever the gateway
2567
- // was started with, and a default model never rides along to a
2568
- // different provider than it was chosen for.
2569
- const selection = { ...options.selection };
2570
- let resolveEnv = env;
2571
- // One config read per dispatch, through a last-known-good cache: a
2572
- // config.json an operator saved mid-edit must neither fail dispatches
2573
- // (a daemon keeps serving) nor silently change what it configured —
2574
- // discarding a broken config wholesale would flip a config-provided
2575
- // provider back to the demo default, durably recording canned replies
2576
- // in a real conversation. With no known-good snapshot to fall back
2577
- // on, failing is the honest outcome. Credential and provider errors
2578
- // are never masked — only the file itself degrades.
2579
- let configSnapshot;
2580
- try {
2581
- const location = await resolveConfigLocation(options.selection?.configPath ? { configPath: options.selection.configPath } : {}, env);
2582
- configSnapshot = location
2583
- ? { config: await loadConfigFile(location.path), trusted: location.trusted, path: location.path }
2584
- : { config: {}, trusted: true };
2585
- lastGoodConfigSnapshot = configSnapshot;
2586
- }
2587
- catch (error) {
2588
- if (!(error instanceof ConfigFileError) || !lastGoodConfigSnapshot) {
2589
- throw error;
2590
- }
2591
- warn(`${error.message}; using the last known-good config`);
2592
- configSnapshot = lastGoodConfigSnapshot;
2593
- }
2594
- selection.presetConfig = configSnapshot;
2595
- const pins = source.soul;
2596
- if (pins) {
2597
- // One soul read per dispatch: resolution uses the exact snapshot
2598
- // refreshAgent just loaded and identity-checked. Handing the path
2599
- // to resolveRuntimeConfig instead would read the file a second
2600
- // time, racing a concurrent replacement — this turn could keep one
2601
- // agent's definition while adopting another's provider pins.
2602
- const normalized = applySoulPins(pins, selection, env, {
2603
- ...(options.selection?.provider !== undefined ? { selectionProvider: options.selection.provider } : {}),
2604
- ...(configSnapshot.config.provider !== undefined ? { configProvider: configSnapshot.config.provider } : {}),
2605
- configPresent: configSnapshot.path !== undefined,
2606
- });
2607
- resolveEnv = normalized.env;
2608
- }
2609
- else {
2610
- // A soul-less agent (the built-in default) resolves with no soul at
2611
- // all: the config file's default soul belongs to another identity —
2612
- // and when the default route degrades to the built-in, it is exactly
2613
- // because that file is currently unusable.
2614
- selection.presetSoul = null;
2615
- }
2616
- // The preset snapshot means resolution reads no config file — every
2617
- // failure from here is a credential or provider problem and surfaces.
2618
- return resolveRuntimeConfig(selection, resolveEnv);
2619
- };
2620
- /**
2621
- * Whether the turn about to run streams deltas, which arms the idle
2622
- * watchdog — on the sticky fallback when the session already switched to
2623
- * it, since that is the provider the turn will be served by.
2624
- */
2625
- const watchdogStreamsFor = (session, config) => {
2626
- const switchedToFallback = session?.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true;
2627
- const fallbackStreams = config.provider !== 'demo' && config.fallback
2628
- ? fallbackStreamsDeltas(config.fallback)
2629
- : false;
2630
- const effectiveStreams = switchedToFallback && config.provider !== 'demo' && config.fallback
2631
- ? fallbackStreamsDeltas(config.fallback)
2632
- : streamsDeltas(config);
2633
- return { switchedToFallback, effectiveStreams, fallbackStreams };
2634
- };
2635
- /**
2636
- * Whether a turn on this config could be served by a harness
2637
- * (`runsOnHarness`): the primary or, since a turn can switch mid-run, the
2638
- * fallback. Once a session has switched for good the primary is never
2639
- * called again, and only the fallback counts.
2640
- */
2641
- const reachesHarness = (config, switchedToFallback) => {
2642
- const fallback = config.provider !== 'demo' ? config.fallback : undefined;
2643
- return switchedToFallback && fallback !== undefined
2644
- ? runsOnHarness(fallback)
2645
- : runsOnHarness(config) || (fallback !== undefined && runsOnHarness(fallback));
2646
- };
2647
- /**
2648
- * A keyed turn the shutdown cut short and the runner left for the next
2649
- * start (`leftForNextStart`): its caller is told so, the way a message
2650
- * refused at the door is, and keeps it for the next daemon, which
2651
- * continues the turn. Read back on the chain, so it is this dispatch's
2652
- * own message the session is still running on.
2653
- */
2654
- const settleLeftForNextStart = async (input, turn) => {
2655
- try {
2656
- return await turn;
2657
- }
2658
- catch (error) {
2659
- if (error instanceof RunAbortedError && error.resumable) {
2660
- const left = await store.get(input.sessionId).catch(() => undefined);
2661
- // The dispatch's own key, or, for an unkeyed one whose turn took
2662
- // keyed messages in at a tool step (card 948), the key the turn has
2663
- // from them: its steered callers are told what a keyed caller is.
2664
- const key = input.idempotencyKey ?? (left === undefined ? undefined : turnKeyOf(turnMessageOf(left)));
2665
- if (left !== undefined && key !== undefined && workItemState(left, key) === 'unfinished' && leftForNextStart(left, error)) {
2666
- throw leftForNext();
2667
- }
2668
- }
2669
- throw error;
2670
- }
2671
- };
2672
- /**
2673
- * `subAgent` is the narrowed definition `subAgents.run` built: the turn
2674
- * runs as that, on the parent's resolved provider, with no memory and no
2675
- * soul. Only that path passes it.
2676
- */
2677
- const dispatchInternal = async (input, absorbedKeys = [], subAgent,
2678
- /**
2679
- * See `RunInput.steer`; passed by `dispatch` for an opted-in caller.
2680
- * Handed the turn's own signal — the one the runner was given, which
2681
- * the watchdog, `abortTurns` and a restart's window abort as well as
2682
- * the callers — so it takes nothing into a turn that is already
2683
- * stopping (card 959).
2684
- */
2685
- steer) => {
2686
- // Re-read on every turn, not once at start: a newer build that stamps
2687
- // the home while this daemon is serving has formats this build does not
2688
- // understand, and a daemon that kept writing into them would be the
2689
- // downgrade the stamp exists to refuse — from the inside. Inside the
2690
- // session chain, so a turn already accepted is counted by the drain
2691
- // before anything else runs. One small file read per turn.
2692
- assertStateCompatible(env);
2693
- // A dispatch whose signal fired while it queued behind another turn
2694
- // must not touch durable state: without this check, the runner would
2695
- // load the session, append the cancelled user message, and save it as
2696
- // failed — polluting future model history with input never processed.
2697
- if (input.signal?.aborted) {
2698
- throw abortErrorFor(input.signal);
2699
- }
2700
- // A session pins its agent: when the caller names none, an existing
2701
- // conversation keeps the agent it was created with even if the
2702
- // configured default soul has changed since — only a brand-new session
2703
- // takes the current default. (Loaded here, before the watchdog, also
2704
- // because whether this turn streams depends on durable session state.)
2705
- const existing = await store.get(input.sessionId);
2706
- const agentId = input.agentId ?? existing?.agent.id ?? await defaultAgentId();
2707
- const source = await refreshAgent(agentId);
2708
- const agent = subAgent ?? source.definition;
2709
- if (subAgent !== undefined && existing) {
2710
- throw new Error(`Session ${input.sessionId} already exists; a sub-agent's session is new every time.`);
2711
- }
2712
- // An archived transcript is the record of a conversation that has moved
2713
- // on. Continuing it would revive a session the operator deliberately
2714
- // left behind, under an id nothing routes to any more.
2715
- const continuedAs = existing?.metadata?.[ROLLED_OVER_TO_METADATA_KEY];
2716
- if (typeof continuedAs === 'string') {
2717
- throw new Error(`Session ${input.sessionId} was rolled over; it is an archived transcript and the conversation continues as ${continuedAs}.`);
2718
- }
2719
- const config = await runtimeForAgent(source);
2720
- const runner = runnerFor(config, source.definition.id, subAgent !== undefined);
2721
- // Which executor the turn's tool calls run through — the built-in
2722
- // under the name `stratus run` has always recorded, a contributed one
2723
- // under its registered name — so a transcript says where a command
2724
- // ran, not only which model asked for it.
2725
- const executorRecord = selectedExecutor && options.executor !== undefined ? options.executor : 'local-command';
2726
- const metadata = {
2727
- provider: config.provider,
2728
- // A contributed provider may leave the model to its own default, in
2729
- // which case there is none to record.
2730
- ...(config.provider !== 'demo' && config.model !== undefined ? { model: config.model } : {}),
2731
- ...input.metadata,
2732
- // After the caller's metadata, and reserved besides: a dispatch that
2733
- // could write it could make a transcript claim a sandbox ran what
2734
- // the host ran.
2735
- [EXECUTOR_METADATA_KEY]: executorRecord,
2736
- };
2737
- if (existing && existing.agent.id !== agent.id) {
2738
- throw crossIdentityError(input.sessionId, existing.agent.id, agent.id);
2739
- }
2740
- // A sub-agent's session is new, but may start on its parent's fallback.
2741
- const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(existing ?? (subAgent !== undefined ? { metadata } : undefined), config);
2742
- // Recorded with a keyed message so a restart judges the turn by what it
2743
- // could have run on, not by whatever the config resolves to by then.
2744
- // The fallback counts: a turn can switch to it mid-run. Once a session
2745
- // has switched for good, the primary is never called again, and only
2746
- // the fallback counts.
2747
- const hostedLoop = input.idempotencyKey !== undefined && reachesHarness(config, switchedToFallback);
2748
- return settleLeftForNextStart(input, onTurnRuntime(input.sessionId, { source, config }, () => withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
2749
- // The preflight above (agent refresh, config resolution, session
2750
- // load) awaited filesystem work; the entry check has long passed.
2751
- // Recheck before the runner touches durable state, or a dispatch
2752
- // cancelled mid-preflight would still persist its user message.
2753
- if (signal.aborted) {
2754
- throw abortErrorFor(signal);
2755
- }
2756
- if (existing) {
2757
- // Refresh the stored definition before resuming, so the turn runs
2758
- // with the current instructions, tools, and credentials.
2759
- existing.agent = agent;
2760
- // A sub-session reached here is being continued from outside — a
2761
- // delegation never resumes its own child; its ids are minted once.
2762
- // The delegating turn is over, so the markers come off: what is
2763
- // now an ordinary conversation must not be closed as an orphan if
2764
- // a later turn of it parks and the daemon dies.
2765
- if (isDelegatedSession(existing) && existing.metadata) {
2766
- existing.metadata = withoutDelegation(existing.metadata);
2767
- }
2768
- // The same for a sub-agent's session, continued as the agent itself:
2769
- // the marker that denies its approvals unasked comes off with the
2770
- // delegation markers it carries.
2771
- if (isSubAgentSession(existing) && existing.metadata) {
2772
- const { [SUB_AGENT_METADATA_KEY]: _subAgent, ...rest } = withoutDelegation(existing.metadata);
2773
- existing.metadata = rest;
2774
- }
2775
- // The executor record follows the daemon, not the session's first
2776
- // turn: a restart that selected another executor runs this turn's
2777
- // commands there, and a session from before the record existed
2778
- // gets one now rather than never.
2779
- if (existing.metadata?.[EXECUTOR_METADATA_KEY] !== executorRecord) {
2780
- existing.metadata = { ...existing.metadata, [EXECUTOR_METADATA_KEY]: executorRecord };
2781
- }
2782
- // The room rides on the turn, but two readers have only the
2783
- // session's: a turn parked on approval and recovered after a
2784
- // restart, and a turn that brings no room of its own (a schedule
2785
- // firing into the thread). Both get the latest room this way, not
2786
- // the first turn's, which a channel made public since would make
2787
- // read as more private than it is. A turn from a channel that
2788
- // could not tell the room (its lookup failed) clears it instead:
2789
- // no room is the generic line, and an old one is a claim.
2790
- if (typeof input.metadata?.channel === 'string') {
2791
- const room = input.metadata[CONVERSATION_METADATA_KEY];
2792
- const { [CONVERSATION_METADATA_KEY]: _stale, ...rest } = existing.metadata ?? {};
2793
- existing.metadata = room !== undefined ? { ...rest, [CONVERSATION_METADATA_KEY]: room } : rest;
2794
- }
2795
- await store.save(existing);
2796
- // The turn's metadata rides along for the sender's trust: without
2797
- // it a resumed turn has no way to say who sent it, and a stranger
2798
- // mentioning the agent in a thread the operator opened would
2799
- // inherit the operator's label.
2800
- return runner.resume({
2801
- sessionId: input.sessionId,
2802
- userMessage: input.userMessage,
2803
- ...(input.images !== undefined ? { images: input.images } : {}),
2804
- ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
2805
- ...(input.replyOptional === true ? { replyOptional: true } : {}),
2806
- ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
2807
- ...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
2808
- ...(hostedLoop ? { hostedLoop } : {}),
2809
- ...(input.metadata ? { metadata: input.metadata } : {}),
2810
- runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
2811
- ...(steer !== undefined ? { steer: () => steer(signal) } : {}),
2812
- signal,
2813
- });
2814
- }
2815
- return runner.run({
2816
- sessionId: input.sessionId,
2817
- agent,
2818
- userMessage: input.userMessage,
2819
- ...(input.images !== undefined ? { images: input.images } : {}),
2820
- ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
2821
- ...(input.replyOptional === true ? { replyOptional: true } : {}),
2822
- ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
2823
- ...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
2824
- ...(hostedLoop ? { hostedLoop } : {}),
2825
- ...(input.earlier !== undefined && input.earlier.length > 0 ? { earlier: input.earlier } : {}),
2826
- metadata,
2827
- runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
2828
- ...(steer !== undefined ? { steer: () => steer(signal) } : {}),
2829
- signal,
2830
- });
2831
- })));
2832
- };
2833
- /**
2834
- * Finishes the turns that were parked on a human when this daemon last
2835
- * went down.
2836
- *
2837
- * Run after the channels are up, deliberately: recovery re-asks, and a
2838
- * request emitted into a gateway with no live adapter would be declined
2839
- * for want of anywhere to put it — turning "your approval survived the
2840
- * restart" into "your approval was denied by the restart".
2841
- *
2842
- * Each session is recovered independently and failures are per-session:
2843
- * one unrecoverable turn must not stop the daemon from serving, or from
2844
- * recovering the others.
2845
- */
2846
- const recoverParkedTurns = async (orphaned) => {
2847
- if (!store.listIdsByStatus) {
2848
- return;
2849
- }
2850
- const parked = await store.listIdsByStatus('pending_approval');
2851
- if (parked.length === 0) {
2852
- return;
2853
- }
2854
- log(`recovering ${parked.length} turn(s) parked on approval`);
2855
- // Started together, not one after another. Each recovery re-asks and
2856
- // then waits on a human, so awaiting them in sequence would mean the
2857
- // second parked turn is not even *announced* until the first is
2858
- // answered — fifteen minutes later by default, and never at all with a
2859
- // zero timeout. They are independent turns; only same-session work is
2860
- // ordered, and onSessionChain already guarantees that.
2861
- await Promise.allSettled(parked.map((sessionId) => recoverOne(sessionId, orphaned.has(sessionId))));
2862
- };
2863
- /**
2864
- * The parked sub-sessions whose delegating turn the last process was
2865
- * still inside — read BEFORE the channels start, for the same reason the
2866
- * abandoned list is.
2867
- *
2868
- * The proof is the parent's transcript: only the runner writes an
2869
- * assistant `agent.delegate` call, and only a delegation in flight leaves
2870
- * one with no result behind it. That evidence does not survive contact
2871
- * with a channel, though — a message for the parent arriving once the
2872
- * adapters are up resumes it, and the resume closes the dangling call as
2873
- * interrupted. Read live from the sweep, the parent would then look as if
2874
- * it awaited nothing and the child would be re-asked after all, which is
2875
- * the bug this exists to stop. So it is read while nothing can dispatch,
2876
- * and the sweep judges from the snapshot; a child whose parent is missing
2877
- * or not inside such a call is left to ordinary recovery.
2878
- *
2879
- * Matched call by call, not parent by parent: the call that awaits a
2880
- * child names it (see `outstandingDelegationFor`), and a call can await
2881
- * only one. Two parked children claiming the same call — one of them a
2882
- * sub-session continued from outside under a version that kept its
2883
- * markers, parked again on something of its own — is an ambiguity this
2884
- * cannot settle, and both are left to ordinary recovery rather than one
2885
- * of them being failed for a delegation that was never its.
2886
- */
2887
- const listOrphanedDelegations = async () => {
2888
- const claimants = new Map();
2889
- if (!store.listIdsByStatus) {
2890
- return new Set();
2891
- }
2892
- for (const sessionId of await store.listIdsByStatus('pending_approval')) {
2893
- const parentId = delegatingSessionIdOf(sessionId);
2894
- if (parentId === undefined) {
2895
- continue;
2896
- }
2897
- const session = await store.get(sessionId);
2898
- if (!session || !isDelegatedSession(session)) {
2899
- continue;
2900
- }
2901
- const parent = await store.get(parentId);
2902
- const callId = parent ? outstandingDelegationFor(parent, session) : undefined;
2903
- if (callId !== undefined) {
2904
- const key = `${parentId}\u0000${callId}`;
2905
- claimants.set(key, [...(claimants.get(key) ?? []), sessionId]);
2906
- }
2907
- }
2908
- return new Set([...claimants.values()].filter((ids) => ids.length === 1).map(([id]) => id));
2909
- };
2910
- const recoverOne = (sessionId, orphaned) => onSessionChain(sessionId, () => recoverParked(sessionId, orphaned));
2911
- /**
2912
- * `recoverOne`'s work, for a caller already on the session's chain: a
2913
- * repeated idempotency key that reached the chain before the parked sweep
2914
- * did, and would otherwise wait on a turn nothing is running yet.
2915
- */
2916
- const recoverParked = async (sessionId, orphaned) => {
2917
- if (stopping) {
2918
- return;
2919
- }
2920
- try {
2921
- const session = await store.get(sessionId);
2922
- if (!session) {
2923
- return;
2924
- }
2925
- const record = readPendingApproval(session);
2926
- // A delegated sub-session is only ever awaited by its parent's
2927
- // `agent.delegate` call, and that parent was running when the last
2928
- // process died — it is in the abandoned sweep, not in this one.
2929
- // Nothing will read the reply, so the parked call is not re-asked:
2930
- // a person would be approving a command that runs for no one.
2931
- //
2932
- // Judged on the re-read, on the chain: the channels are up before
2933
- // this sweep starts, so a message for this id can have taken the
2934
- // chain first and moved the session on — and a turn that finished
2935
- // normally must not be rewritten as failed. The delegation metadata
2936
- // is durable, so it proves what the session is, not what state it
2937
- // is in; the status and the checkpoint say that.
2938
- //
2939
- // Whether it IS an orphan was decided before the channels started
2940
- // (`listOrphanedDelegations`), from the parent's transcript, which
2941
- // a message arriving since may already have rewritten.
2942
- // A sub-agent never parks (nobody is asked for it), and if one ever
2943
- // did, its parent's turn died with the last process: nothing would
2944
- // read its answer, and recovering it would run it as the full agent.
2945
- if (isSubAgentSession(session) && session.status === 'pending_approval') {
2946
- log(`${sessionId}: a sub-agent parked when the last stratusd stopped; failing it`);
2947
- await failParked(session, ABANDONED_TURN_ERROR);
2948
- return;
2949
- }
2950
- if (orphaned && isDelegatedSession(session) && session.status === 'pending_approval' && record) {
2951
- log(`${sessionId}: parked on ${record.call.toolName} for a delegating turn the last stratusd was still running; failing it`);
2952
- await failOrphanedDelegation(session);
2953
- return;
2954
- }
2955
- // A wait that outlived its window while the daemon was down is
2956
- // honoured, not restarted: the request really did go unanswered for
2957
- // the whole time it was configured to wait, and downtime is not a
2958
- // reason to extend a security decision. Measured from when the turn
2959
- // parked against this daemon's timeout — the transport's original
2960
- // deadline was chosen after the checkpoint was written and is gone
2961
- // with the process that chose it. Denying goes through the same
2962
- // recovery path, so the queue behind it still drains.
2963
- const parkedAt = record ? Date.parse(record.parkedAt) : Number.NaN;
2964
- const expired = effectiveApprovalTimeoutMs > 0
2965
- && Number.isFinite(parkedAt)
2966
- && Date.now() - parkedAt >= effectiveApprovalTimeoutMs;
2967
- if (expired) {
2968
- log(`${sessionId}: the approval for ${record?.call.toolName} outlived its window while the daemon was down; denying it`);
2969
- }
2970
- // Resolved the way a dispatch for this agent would resolve it, so a
2971
- // recovered turn finishes on the same provider, model, and
2972
- // credentials it was parked on.
2973
- const source = await refreshAgent(session.agent.id);
2974
- // And on the CURRENT definition, saved before recovery reads the
2975
- // session back. An allowlist is a permission boundary: a soul that
2976
- // dropped a tool while the daemon was down must not have that tool
2977
- // executed by the turn that outlived the change, and
2978
- // `allowedToolsFor` reads the definition frozen into the session
2979
- // ahead of the registry. Dispatch refreshes it the same way before
2980
- // resuming.
2981
- session.agent = source.definition;
2982
- await store.save(session);
2983
- const recoveredConfig = await runtimeForAgent(source);
2984
- const runner = runnerFor(recoveredConfig, source.definition.id);
2985
- // Tracked like a dispatched turn's controller: a recovered turn that
2986
- // runs on past its approval is a turn like any other, so a restart's
2987
- // window has to be able to cut it short with the same reason (or it
2988
- // runs unseen to the second window and the restart reports
2989
- // undrained), and a stop for the session has to find it. Only once
2990
- // the checkpoint has been read on this chain, right before the runner
2991
- // takes it: a stop counted then always ends in the turn's own
2992
- // outcome. A stop sent during the reads above finds nothing running
2993
- // and reaches the session as an ordinary message, queued behind it.
2994
- const controller = new AbortController();
2995
- if (record) {
2996
- trackTurn(sessionId, controller);
2997
- if (abortingWith !== undefined) {
2998
- controller.abort(shutdownAbort());
2999
- }
3000
- }
3001
- try {
3002
- await onTurnRuntime(sessionId, { source, config: recoveredConfig }, () => runner.recoverPendingApproval(sessionId, {
3003
- denyPending: expired,
3004
- runtime: runtimeContextFor(source, recoveredConfig, session.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true, session.metadata),
3005
- signal: controller.signal,
3006
- }));
3007
- }
3008
- finally {
3009
- untrackTurn(sessionId, controller);
3010
- // A recovered firing's row outlived the process that would have
3011
- // retired it, and the firing is over either way — a recovery that
3012
- // failed has durably failed its session, which is as finished as
3013
- // completing. The scheduler decides whether there is a row to
3014
- // retire, and only this firing's own.
3015
- const scheduleId = session.metadata?.[SCHEDULE_ID_METADATA_KEY];
3016
- if (typeof scheduleId === 'string') {
3017
- scheduler.retireSpentOneShot(scheduleId, sessionId);
3018
- }
3019
- }
3020
- }
3021
- catch (error) {
3022
- warn(`could not recover parked session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
3023
- // A keyed item is a promise to its caller, and one whose recovery
3024
- // cannot even start (its agent gone, its provider unresolvable) would
3025
- // otherwise stay parked for every later start and redelivery to try
3026
- // again. An unkeyed one keeps waiting for a sweep that can, as before.
3027
- const after = await store.get(sessionId).catch(() => undefined);
3028
- const key = after === undefined ? undefined : turnKeyOf(turnMessageOf(after));
3029
- if (after !== undefined && key !== undefined && after.status === 'pending_approval' && workItemState(after, key) === 'unfinished') {
3030
- await failParked(after, ABANDONED_TURN_ERROR).catch(() => { });
3031
- }
3032
- }
3033
- };
3034
- /**
3035
- * Closes an orphaned sub-session the way the abandoned sweep closes its
3036
- * parent: durably failed, with the checkpoint retired so no later sweep
3037
- * can find a call to re-enter, and announced where surfaces can hear it.
3038
- */
3039
- const failOrphanedDelegation = (session) => failParked(session, ORPHANED_DELEGATION_ERROR);
3040
- const failParked = async (session, error) => {
3041
- const metadata = { ...(session.metadata ?? {}) };
3042
- delete metadata[PENDING_APPROVAL_METADATA_KEY];
3043
- session.metadata = metadata;
3044
- recordTurnFailure(session, error);
3045
- await store.save(session);
3046
- await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
3047
- await bus.emit({ type: 'session.failed', sessionId: session.id, error });
3048
- };
3049
- /**
3050
- * Turns the last process was still running when it stopped being able to
3051
- * finish them.
3052
- *
3053
- * `pending_approval` is the one window a turn can be picked up from, and
3054
- * it has its own sweep above. Everything else a turn does is saved as
3055
- * `running`: the provider call, the executor, and — on a provider that
3056
- * hosts its own loop — the approval wait too, since a hosted call is
3057
- * deliberately not checkpointed (`executeHostedToolCall` passes
3058
- * `recoverable: false`, because the SDK's inner loop is not something a
3059
- * restart can rebuild). None of those can be re-entered where they
3060
- * stopped, and nothing else sweeps them, so the record would go on
3061
- * claiming the turn is running for as long as the session exists. A turn
3062
- * whose caller gave it an idempotency key is continued from its
3063
- * transcript instead, with its dangling calls closed as interrupted
3064
- * (`settleAbandonedTurn`); the rest are failed.
3065
- *
3066
- * Note what this is *not* for. A graceful stop denies everything parked
3067
- * and drains the turns those denials release, so an operator restarting
3068
- * the daemon normally has nothing here. These are the turns a crash, a
3069
- * SIGKILL, or a lost machine left behind.
3070
- */
3071
- const listAbandonedTurns = async () => {
3072
- if (!store.listIdsByStatus) {
3073
- return [];
3074
- }
3075
- return store.listIdsByStatus('running');
3076
- };
3077
- /**
3078
- * Settles each of them — continued if keyed, otherwise marked failed with
3079
- * a reason that says what happened.
3080
- *
3081
- * Read BEFORE the channels start and applied after, which is the only
3082
- * ordering that is both honest and safe. Reading first means the list is
3083
- * exactly what the last process left, with no chance of a turn this
3084
- * process started being in it. Applying after means the failure is
3085
- * emitted into a gateway whose surfaces are listening, rather than into
3086
- * one where nothing is.
3087
- *
3088
- * The gap between the two is real, though: an inbound message for one of
3089
- * these sessions can arrive as soon as the channels are up. What makes
3090
- * that safe is single-flight — every writer of a session goes through
3091
- * `onSessionChain`, so this runs either wholly before that turn (it
3092
- * fails, and `resume()` then clears `lastError` and starts fresh) or
3093
- * wholly after it (the status has moved off `running`, and the re-read
3094
- * below leaves it alone). There is no interleaving in which this
3095
- * overwrites a live turn.
3096
- *
3097
- * What that invariant does NOT cover is a second daemon on the same
3098
- * database, whose turns are running with no chain of ours in front of
3099
- * them. Two processes sharing a session store is already unsupported —
3100
- * the parked sweep above would re-ask that daemon's approvals for the
3101
- * same reason — and no check here would make it supported.
3102
- */
3103
- const failAbandonedTurns = async (abandoned) => {
3104
- if (abandoned.length === 0) {
3105
- return;
3106
- }
3107
- log(`settling ${abandoned.length} turn(s) left running by the last stratusd`);
3108
- await Promise.allSettled(abandoned.map((id) => failAbandonedTurn(id)));
3109
- };
3110
- const failAbandonedTurn = (id) => onSessionChain(id, async () => {
3111
- if (stopping) {
3112
- return;
3113
- }
3114
- try {
3115
- const session = await store.get(id);
3116
- // Re-read on the chain, because the snapshot is from before the
3117
- // channels came up: a message that arrived since has already taken
3118
- // this session somewhere else, and that turn owns it now.
3119
- if (!session || session.status !== 'running') {
3120
- return;
3121
- }
3122
- await settleAbandonedTurn(session);
3123
- }
3124
- catch (error) {
3125
- warn(`could not settle abandoned turn ${id}: ${error instanceof Error ? error.message : String(error)}`);
3126
- }
3127
- });
3128
- /**
3129
- * A turn left `running` with nothing running it, read on its chain.
3130
- *
3131
- * One whose message carries an idempotency key is a work item its caller
3132
- * was promised would finish, so it is continued from the transcript
3133
- * (`AgentRunner.continueTurn`) — once. A turn that already was, and is
3134
- * here again, took the last process down with it, and continuing it again
3135
- * would do the same on every start; it is failed like an unkeyed one,
3136
- * whose caller was never promised anything and is told to send again.
3137
- */
3138
- const settleAbandonedTurn = async (session) => {
3139
- const message = turnMessageOf(session);
3140
- if (message === undefined || turnKeyOf(message) === undefined) {
3141
- return failAbandoned(session);
3142
- }
3143
- // An answer already in the transcript is completed whatever came before
3144
- // — a second crash, a harness, an agent or provider gone since —
3145
- // because completing it needs none of them and runs nothing again;
3146
- // failing it would throw away a finished reply.
3147
- if (lastResponseIsAnswer(session)) {
3148
- log(`${session.id}: a keyed turn the last stratusd was running had saved its answer; completing it`);
3149
- return completeAnsweredTurn(session, store, bus);
3150
- }
3151
- if (message.continuedAfterCrash === true) {
3152
- return failAbandoned(session);
3153
- }
3154
- if (message.hostedLoop === true) {
3155
- log(`${session.id}: a keyed turn the last stratusd was running could reach a harness, which cannot be continued; failing it`);
3156
- return failAbandoned(session);
3157
- }
3158
- return continueAbandonedTurn(session);
3159
- };
3160
- const failAbandoned = async (session) => {
3161
- recordTurnFailure(session, ABANDONED_TURN_ERROR);
3162
- await store.save(session);
3163
- await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
3164
- await bus.emit({ type: 'session.failed', sessionId: session.id, error: ABANDONED_TURN_ERROR });
3165
- return session;
3166
- };
1558
+ let orphanedAtStart = new Set();
3167
1559
  /**
3168
- * Continues an abandoned turn on the configuration a dispatch for its
3169
- * agent would resolve now, and under the same watchdog — it is a provider
3170
- * call like any turn's. The definition is refreshed and saved first, as
3171
- * `recoverOne` does and for the same reason: an allowlist is a permission
3172
- * boundary, and a tool dropped while the daemon was down must not run.
1560
+ * The runtime one agent would run on right now — config snapshot, soul
1561
+ * pins, degradation and all.
3173
1562
  *
3174
- * Not on a harness (`runsOnHarness`) — neither one the turn could have
3175
- * run on (`Message.hostedLoop`, recorded when it was accepted) nor one it
3176
- * would continue on. A harness runs its own tool loop inside a single
3177
- * provider call and keeps its own conversation, so the prompt the dead
3178
- * turn sent may already have run tools there that the transcript never
3179
- * saw, and continuing would send it again. That turn is failed as an
3180
- * unkeyed one is — the same answer as before keys existed. (A turn whose
3181
- * answer is already saved never gets here: `settleAbandonedTurn`
3182
- * completes it first.)
1563
+ * Extracted so recovery resolves it exactly as a dispatch does. A second
1564
+ * copy of this chain is the difference between a recovered turn finishing
1565
+ * on its agent's provider and finishing on the daemon's default, and it
1566
+ * would drift the first time either side gained a rule.
3183
1567
  */
3184
- const continueAbandonedTurn = async (session) => {
3185
- // Its parent's turn died with the last process, and continuing it here
3186
- // would run it as the full agent. (Sub-agent turns carry no key today,
3187
- // so this is a guard, not a path.)
3188
- if (isSubAgentSession(session)) {
3189
- return failAbandoned(session);
3190
- }
3191
- // Settled either way: a turn whose agent left the roster, or whose
3192
- // provider no longer resolves, must not stay `running` for every later
3193
- // start and every redelivery to find unfinished and trip over again.
3194
- let source;
3195
- let config;
1568
+ const runtimeForAgent = async (source) => {
1569
+ // Config re-resolves per dispatch, so a changed model, credential, or
1570
+ // soul pin applies without a restart. The pool dedupes runners by the
1571
+ // resolved configuration. The gateway-wide selection and the daemon's
1572
+ // environment are defaults, not overrides: resolveRuntimeConfig gives
1573
+ // both higher precedence than the soul (they are explicit flags and
1574
+ // env vars on the CLI), so any default the soul pins over is dropped
1575
+ // here — an agent pinned to a provider keeps it whatever the gateway
1576
+ // was started with, and a default model never rides along to a
1577
+ // different provider than it was chosen for.
1578
+ const selection = { ...options.selection };
1579
+ let resolveEnv = env;
1580
+ // One config read per dispatch, through a last-known-good cache: a
1581
+ // config.json an operator saved mid-edit must neither fail dispatches
1582
+ // (a daemon keeps serving) nor silently change what it configured —
1583
+ // discarding a broken config wholesale would flip a config-provided
1584
+ // provider back to the demo default, durably recording canned replies
1585
+ // in a real conversation. With no known-good snapshot to fall back
1586
+ // on, failing is the honest outcome. Credential and provider errors
1587
+ // are never masked — only the file itself degrades.
1588
+ let configSnapshot;
3196
1589
  try {
3197
- source = await refreshAgent(session.agent.id);
3198
- config = await runtimeForAgent(source);
1590
+ const location = await resolveConfigLocation(options.selection?.configPath ? { configPath: options.selection.configPath } : {}, env);
1591
+ configSnapshot = location
1592
+ ? { config: await loadConfigFile(location.path), trusted: location.trusted, path: location.path }
1593
+ : { config: {}, trusted: true };
1594
+ lastGoodConfigSnapshot = configSnapshot;
3199
1595
  }
3200
1596
  catch (error) {
3201
- log(`${session.id}: a keyed turn the last stratusd was running cannot be continued (${error instanceof Error ? error.message : String(error)}); failing it`);
3202
- return failAbandoned(session);
3203
- }
3204
- const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(session, config);
3205
- // The message's own record covers the runtimes the turn could have run
3206
- // on; this covers the ones it would continue on — a fallback included —
3207
- // which a changed config may make a harness.
3208
- if (reachesHarness(config, switchedToFallback)) {
3209
- log(`${session.id}: a keyed turn the last stratusd was running would continue on a harness; failing it`);
3210
- return failAbandoned(session);
3211
- }
3212
- log(`${session.id}: continuing a keyed turn the last stratusd was still running`);
3213
- session.agent = source.definition;
3214
- await store.save(session);
3215
- const runner = runnerFor(config, source.definition.id);
3216
- return onTurnRuntime(session.id, { source, config }, () => withWatchdog(session.id, undefined, effectiveStreams, fallbackStreams, async (signal) => await runner.continueTurn(session.id, {
3217
- runtime: runtimeContextFor(source, config, switchedToFallback, session.metadata),
3218
- signal,
3219
- }) ?? session));
3220
- };
3221
- /**
3222
- * A repeated idempotency key that reached the session's chain, so nothing
3223
- * else is running on the session. `undefined` means the key is new here
3224
- * and the dispatch runs as any other; otherwise this is the session to
3225
- * resolve with, and no new turn starts.
3226
- *
3227
- * An unfinished item here has no live turn — the live map would have
3228
- * answered first — so it is one the last process left, reached before
3229
- * the start-up sweep that would have settled it: settled now, the way
3230
- * that sweep would, which then finds it no longer needs anything.
3231
- */
3232
- const settleRepeat = async (sessionId, idempotencyKey, agentId, turnId, onRepeat) => {
3233
- const session = await store.get(sessionId);
3234
- if (session === undefined) {
3235
- return undefined;
3236
- }
3237
- // Before anything is returned: the turn a dispatch would run is refused
3238
- // for naming another agent, and the one it would repeat is no less that
3239
- // agent's — a repeat is not a way to read its transcript.
3240
- if (agentId !== undefined && session.agent.id !== agentId) {
3241
- throw crossIdentityError(sessionId, session.agent.id, agentId);
3242
- }
3243
- const state = workItemState(session, idempotencyKey);
3244
- if (state === undefined) {
3245
- const archived = await archivedRepeat(session, idempotencyKey);
3246
- if (archived !== undefined) {
3247
- onRepeat?.('finished');
1597
+ if (!(error instanceof ConfigFileError) || !lastGoodConfigSnapshot) {
1598
+ throw error;
3248
1599
  }
3249
- return archived;
3250
- }
3251
- if (state === 'finished') {
3252
- onRepeat?.('finished');
3253
- return session;
3254
- }
3255
- // Run now, on this caller's behalf, so its events are this caller's
3256
- // turn — the adapter waiting on them has no other way to claim them.
3257
- if (turnId !== undefined) {
3258
- activeTurns.set(sessionId, turnId);
1600
+ warn(`${error.message}; using the last known-good config`);
1601
+ configSnapshot = lastGoodConfigSnapshot;
3259
1602
  }
3260
- try {
3261
- return await settleUnfinished(session);
1603
+ selection.presetConfig = configSnapshot;
1604
+ const pins = source.soul;
1605
+ if (pins) {
1606
+ // One soul read per dispatch: resolution uses the exact snapshot
1607
+ // refreshAgent just loaded and identity-checked. Handing the path
1608
+ // to resolveRuntimeConfig instead would read the file a second
1609
+ // time, racing a concurrent replacement — this turn could keep one
1610
+ // agent's definition while adopting another's provider pins.
1611
+ const normalized = applySoulPins(pins, selection, env, {
1612
+ ...(options.selection?.provider !== undefined ? { selectionProvider: options.selection.provider } : {}),
1613
+ ...(configSnapshot.config.provider !== undefined ? { configProvider: configSnapshot.config.provider } : {}),
1614
+ configPresent: configSnapshot.path !== undefined,
1615
+ });
1616
+ resolveEnv = normalized.env;
3262
1617
  }
3263
- finally {
3264
- if (turnId !== undefined) {
3265
- activeTurns.delete(sessionId);
3266
- }
1618
+ else {
1619
+ // A soul-less agent (the built-in default) resolves with no soul at
1620
+ // all: the config file's default soul belongs to another identity —
1621
+ // and when the default route degrades to the built-in, it is exactly
1622
+ // because that file is currently unusable.
1623
+ selection.presetSoul = null;
3267
1624
  }
1625
+ // The preset snapshot means resolution reads no config file — every
1626
+ // failure from here is a credential or provider problem and surfaces.
1627
+ return resolveRuntimeConfig(selection, resolveEnv);
3268
1628
  };
3269
1629
  /**
3270
- * The archived transcript holding a key the live row does not, when the
3271
- * row is a rollover's fresh one made inside `ROLLOVER_REPEAT_WINDOW_MS`.
3272
- * Only the one rollover back: a second inside the window is a redelivery
3273
- * outliving two deliberate resets, and the chain is not walked for it.
3274
- * Always finished — a rollover refuses a session with a turn in flight.
1630
+ * Whether the turn about to run streams deltas, which arms the idle
1631
+ * watchdog — on the sticky fallback when the session already switched to
1632
+ * it, since that is the provider the turn will be served by.
3275
1633
  */
3276
- const archivedRepeat = async (session, idempotencyKey) => {
3277
- const archivedAs = session.metadata?.[ROLLED_OVER_FROM_METADATA_KEY];
3278
- if (typeof archivedAs !== 'string' || Date.now() - Date.parse(session.createdAt) > ROLLOVER_REPEAT_WINDOW_MS) {
3279
- return undefined;
3280
- }
3281
- const archived = await store.get(archivedAs);
3282
- return archived !== undefined && workItemState(archived, idempotencyKey) !== undefined ? archived : undefined;
3283
- };
3284
- /** An unfinished item with no live turn, settled the way the start-up sweeps would. */
3285
- const settleUnfinished = async (session) => {
3286
- if (session.status === 'pending_approval') {
3287
- await recoverParked(session.id, orphanedAtStart.has(session.id));
3288
- return (await store.get(session.id)) ?? session;
3289
- }
3290
- return settleAbandonedTurn(session);
1634
+ const watchdogStreamsFor = (session, config) => {
1635
+ const switchedToFallback = session?.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true;
1636
+ const fallbackStreams = config.provider !== 'demo' && config.fallback
1637
+ ? fallbackStreamsDeltas(config.fallback)
1638
+ : false;
1639
+ const effectiveStreams = switchedToFallback && config.provider !== 'demo' && config.fallback
1640
+ ? fallbackStreamsDeltas(config.fallback)
1641
+ : streamsDeltas(config);
1642
+ return { switchedToFallback, effectiveStreams, fallbackStreams };
3291
1643
  };
3292
1644
  /**
3293
- * What a running turn takes in at its next tool step (card 948): the
3294
- * merged-turn batch waiting behind it, whole — leader and absorbed alike
3295
- * — when it is for the same agent. The dispatches it takes join `into`,
3296
- * which the running turn settles with its own outcome (`runSteerable`).
3297
- *
3298
- * A part cancelled while it waited is refused, as `runMerged` refuses it.
3299
- * A part whose key this session (or the archive it rolled over from)
3300
- * already holds is a repeat, and judging a repeat can mean settling a
3301
- * turn — never something to do from inside one — so such a batch is not
3302
- * taken: it waits and runs next, merged, where that is judged. Nor while
3303
- * the gateway is stopping, when the batch is what the drain refuses.
1645
+ * Whether a turn on this config could be served by a harness
1646
+ * (`runsOnHarness`): the primary or, since a turn can switch mid-run, the
1647
+ * fallback. Once a session has switched for good the primary is never
1648
+ * called again, and only the fallback counts.
3304
1649
  */
3305
- const takeSteered = async (sessionId, agentId, into,
1650
+ const reachesHarness = (config, switchedToFallback) => {
1651
+ const fallback = config.provider !== 'demo' ? config.fallback : undefined;
1652
+ return switchedToFallback && fallback !== undefined
1653
+ ? runsOnHarness(fallback)
1654
+ : runsOnHarness(config) || (fallback !== undefined && runsOnHarness(fallback));
1655
+ };
3306
1656
  /**
3307
- * Asked last, with nothing awaited after it: whether the running turn
3308
- * still takes these callers, joining them to its cancellation if so. A
3309
- * turn cancelled while the store was read takes nothing, and the batch
3310
- * waits and runs next, as it would have.
1657
+ * A keyed turn the shutdown cut short and the runner left for the next
1658
+ * start (`leftForNextStart`): its caller is told so, the way a message
1659
+ * refused at the door is, and keeps it for the next daemon, which
1660
+ * continues the turn. Read back on the chain, so it is this dispatch's
1661
+ * own message the session is still running on.
3311
1662
  */
3312
- admit) => {
3313
- const batch = openBatches.get(sessionId);
3314
- if (stopping || batch === undefined || !batch.firstInLine || batch.agentId !== agentId) {
3315
- return undefined;
1663
+ const settleLeftForNextStart = async (input, turn) => {
1664
+ try {
1665
+ return await turn;
3316
1666
  }
3317
- // Until every key in it is checked with nothing awaited since: a message
3318
- // can join while the store is read, and is checked on the next pass; a
3319
- // batch closed meanwhile is no longer this turn's to take.
3320
- const checked = new Set();
3321
- let session;
3322
- for (;;) {
3323
- if (stopping || openBatches.get(sessionId) !== batch) {
3324
- return undefined;
3325
- }
3326
- const unchecked = [batch.leader, ...batch.absorbed].flatMap((part) => (part.input.idempotencyKey !== undefined && !checked.has(part.input.idempotencyKey) ? [part.input.idempotencyKey] : []));
3327
- if (unchecked.length === 0) {
3328
- break;
3329
- }
3330
- session ??= await store.get(sessionId);
3331
- for (const key of unchecked) {
3332
- checked.add(key);
3333
- if (session !== undefined && (workItemState(session, key) !== undefined || await archivedRepeat(session, key) !== undefined)) {
3334
- return undefined;
1667
+ catch (error) {
1668
+ if (error instanceof RunAbortedError && error.resumable) {
1669
+ const left = await store.get(input.sessionId).catch(() => undefined);
1670
+ // The dispatch's own key, or, for an unkeyed one whose turn took
1671
+ // keyed messages in at a tool step (card 948), the key the turn has
1672
+ // from them: its steered callers are told what a keyed caller is.
1673
+ const key = input.idempotencyKey ?? (left === undefined ? undefined : turnKeyOf(turnMessageOf(left)));
1674
+ if (left !== undefined && key !== undefined && workItemState(left, key) === 'unfinished' && leftForNextStart(left, error)) {
1675
+ throw leftForNext();
3335
1676
  }
3336
1677
  }
1678
+ throw error;
3337
1679
  }
3338
- const all = [batch.leader, ...batch.absorbed];
3339
- if (!admit(all.filter((part) => part.input.signal?.aborted !== true))) {
3340
- return undefined;
3341
- }
3342
- openBatches.delete(sessionId);
3343
- batch.steered = true;
3344
- // Its place on the chain runs nothing now, so it is not ahead of a batch
3345
- // opening behind it, which can then be steered at a later step.
3346
- releaseQueued(sessionId);
3347
- // Not waiting any more; the leader's place on the chain does not count it again.
3348
- const left = (waitingDispatches.get(sessionId) ?? all.length) - all.length;
3349
- if (left > 0) {
3350
- waitingDispatches.set(sessionId, left);
3351
- }
3352
- else {
3353
- waitingDispatches.delete(sessionId);
3354
- }
3355
- const runnable = [];
3356
- for (const part of all) {
3357
- if (part.input.signal?.aborted) {
3358
- part.reject(abortErrorFor(part.input.signal));
3359
- continue;
3360
- }
3361
- runnable.push(part);
3362
- }
3363
- if (runnable.length === 0) {
3364
- return undefined;
3365
- }
3366
- const keys = runnable.flatMap((part) => (part.input.idempotencyKey !== undefined ? [part.input.idempotencyKey] : []));
3367
- const items = liveWorkItems.get(sessionId);
3368
- for (const key of keys) {
3369
- const item = items?.get(key);
3370
- if (item !== undefined) {
3371
- item.confirmed = true;
3372
- }
3373
- }
3374
- into.push(...runnable);
3375
- const labelled = runnable.some((part) => part.input.metadata !== undefined && SENDER_TRUST_METADATA_KEY in part.input.metadata);
3376
- const lowest = leastTrusted(...runnable.map((part) => senderTrustOf(part.input.metadata)));
3377
- const images = runnable.flatMap((part) => part.input.images ?? []);
3378
- log(`${sessionId}: ${runnable.length} message(s) sent during the running turn reached it at its next step`);
3379
- return {
3380
- userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
3381
- ...(images.length > 0 ? { images } : {}),
3382
- ...(labelled ? { metadata: { [SENDER_TRUST_METADATA_KEY]: lowest } } : {}),
3383
- ...(keys.length > 0 ? { keys } : {}),
3384
- };
3385
1680
  };
1681
+ // `selectedExecutor` is read late: plugins set it when they start.
1682
+ const { dispatchInternal } = createTurnDispatch({
1683
+ options,
1684
+ env,
1685
+ store,
1686
+ getSelectedExecutor: () => selectedExecutor,
1687
+ defaultAgentId,
1688
+ refreshAgent,
1689
+ runtimeContextFor,
1690
+ runnerFor,
1691
+ withWatchdog,
1692
+ onTurnRuntime,
1693
+ runtimeForAgent,
1694
+ watchdogStreamsFor,
1695
+ reachesHarness,
1696
+ settleLeftForNextStart,
1697
+ });
1698
+ // `stopping`, `abortingWith` and `orphanedAtStart` are read late, and
1699
+ // `onSessionChain` is declared further down.
1700
+ const { recoverParkedTurns, listOrphanedDelegations, listAbandonedTurns, failAbandonedTurns, settleRepeat, archivedRepeat, settleUnfinished, } = createRecovery({
1701
+ log,
1702
+ warn,
1703
+ bus,
1704
+ store,
1705
+ scheduler,
1706
+ effectiveApprovalTimeoutMs,
1707
+ refreshAgent,
1708
+ runtimeContextFor,
1709
+ runnerFor,
1710
+ withWatchdog,
1711
+ isStopping: () => stopping,
1712
+ trackTurn,
1713
+ untrackTurn,
1714
+ getAbortingWith: () => abortingWith,
1715
+ shutdownAbort,
1716
+ activeTurns,
1717
+ onTurnRuntime,
1718
+ getOrphanedAtStart: () => orphanedAtStart,
1719
+ runtimeForAgent,
1720
+ watchdogStreamsFor,
1721
+ reachesHarness,
1722
+ onSessionChain: (...args) => onSessionChain(...args),
1723
+ });
1724
+ // `stopping` is read late: it is set at shutdown.
1725
+ const { takeSteered } = createSteering({
1726
+ log,
1727
+ store,
1728
+ releaseQueued,
1729
+ waitingDispatches,
1730
+ isStopping: () => stopping,
1731
+ liveWorkItems,
1732
+ openBatches,
1733
+ archivedRepeat,
1734
+ });
3386
1735
  /**
3387
1736
  * Settles a keyed item the last process left unfinished before any other
3388
1737
  * message joins its session. Channels are up before the sweeps reach it,
@@ -3661,448 +2010,25 @@ export const createGateway = (options = {}) => {
3661
2010
  return dispatchInternal(input);
3662
2011
  });
3663
2012
  };
3664
- const dispatch = async (input) => {
3665
- if (stopping) {
3666
- throw refusal();
3667
- }
3668
- // The reserved namespace is the scheduler's alone. A caller that could
3669
- // name a firing's session id — derivable from `GET /schedules` — would
3670
- // otherwise queue a turn of its own behind the firing (single-flight)
3671
- // and run inside the grant, using `message.send` to the schedule's
3672
- // approved destination without its own approval. Refused here, at the
3673
- // one door every external dispatch comes through.
3674
- if (isScheduleSessionId(input.sessionId)) {
3675
- throw new Error(`Session ids beginning with "${SCHEDULE_SESSION_ID_PREFIX}" are reserved for scheduled firings and cannot be dispatched externally.`);
3676
- }
3677
- // The daemon's own bookkeeping keys, by the same logic as the reserved
3678
- // id namespace above: what the runner, the scheduler, and delegation
3679
- // record about a session must be something only they can have written.
3680
- const reserved = reservedSessionMetadataKey(input.metadata);
3681
- if (reserved !== undefined) {
3682
- throw new Error(`Session metadata key "${reserved}" is reserved for the daemon's own records and cannot be supplied by a dispatch. Reserved keys: ${RESERVED_SESSION_METADATA_KEYS.join(', ')}.`);
3683
- }
3684
- const { turnId, idempotencyKey } = input;
3685
- if (idempotencyKey !== undefined && (idempotencyKey.length === 0 || idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH)) {
3686
- throw new Error(`An idempotency key is 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} characters; this one is ${idempotencyKey.length}.`);
3687
- }
3688
- // A repeat of a dispatch still queued or running is that dispatch: it
3689
- // resolves with it rather than queueing behind it, which would let
3690
- // another caller's turn land in between and then find a finished item.
3691
- // Attached only when it names no other agent than the live dispatch
3692
- // did; otherwise it queues, and `settleRepeat` refuses it there.
3693
- const live = idempotencyKey !== undefined ? liveWorkItems.get(input.sessionId)?.get(idempotencyKey) : undefined;
3694
- // A claim naming an agent other than this delivery does, that the chain
3695
- // has not yet confirmed — this delivery naming nobody, or someone else.
3696
- // The claim may be refused for who it named, and then the item is still
3697
- // unowned: inheriting the refusal would drop a valid message, and
3698
- // queueing behind it would leave this delivery to find the turn that a
3699
- // later valid delivery runs `finished`, and answer it twice. So it
3700
- // waits for the claim to settle and decides then: a claim that stood
3701
- // is repeated, by a delivery naming its owner or nobody, and refuses
3702
- // anyone else; a claim that left the item unowned is dispatched again,
3703
- // where the first delivery to get there runs it and the rest attach.
3704
- if (live !== undefined && !live.confirmed && live.agentId !== undefined && input.agentId !== live.agentId) {
3705
- const claim = live.turn;
3706
- const claimedBy = input.agentId;
3707
- const repeatOf = (owner) => {
3708
- if (claimedBy !== undefined && claimedBy !== owner) {
3709
- throw crossIdentityError(input.sessionId, owner, claimedBy);
3710
- }
3711
- input.onRepeat?.('live');
3712
- };
3713
- return claim.then((session) => {
3714
- repeatOf(session.agent.id);
3715
- return session;
3716
- }, async (error) => {
3717
- // Refused for who it named, or failed before its keyed message was
3718
- // ever written (an agent gone, a provider it could not build): the
3719
- // item is still unowned, and there is no turn to have repeated.
3720
- const stored = error instanceof CrossIdentityError || idempotencyKey === undefined ? undefined : await store.get(input.sessionId);
3721
- const owned = stored !== undefined && idempotencyKey !== undefined && workItemState(stored, idempotencyKey) !== undefined;
3722
- if (owned) {
3723
- repeatOf(stored.agent.id);
3724
- throw error;
3725
- }
3726
- // Its own cleanup runs a tick later; the dispatch below must not
3727
- // find the dead claim still standing and wait on it again.
3728
- const items = liveWorkItems.get(input.sessionId);
3729
- if (idempotencyKey !== undefined && items?.get(idempotencyKey)?.turn === claim) {
3730
- items.delete(idempotencyKey);
3731
- if (items.size === 0) {
3732
- liveWorkItems.delete(input.sessionId);
3733
- }
3734
- }
3735
- return dispatch(input);
3736
- });
3737
- }
3738
- if (live !== undefined && (input.agentId === undefined || input.agentId === live.agentId)) {
3739
- input.onRepeat?.('live');
3740
- return live.turn;
3741
- }
3742
- // The one door every external dispatch comes through, so a pause holds
3743
- // for every channel at once. Work already accepted is not behind this
3744
- // check: a turn running, a redelivery of a message whose turn is live
3745
- // (answered above), a message queued behind it, a delegation or
3746
- // an approval resuming a parked turn all go on.
3747
- if (intakeState.paused) {
3748
- throw new IntakePausedError(intakeState.message ?? DEFAULT_PAUSED_MESSAGE);
3749
- }
3750
- if (heldState) {
3751
- throw new IntakePausedError(HELD_MESSAGE);
3752
- }
3753
- // The live dispatch named no agent, so whose session it is is known
3754
- // only once its turn resolves. Queueing instead would make this a
3755
- // `finished` repeat of a turn the live caller is still rendering — two
3756
- // answers — so it attaches, and is held to the agent it named then:
3757
- // told it repeated only if that agent owns the session, refused if not.
3758
- const claimed = input.agentId;
3759
- if (live !== undefined && live.agentId === undefined && claimed !== undefined) {
3760
- const shared = live.turn;
3761
- // Whichever way the turn ends: a failed one is still that agent's,
3762
- // its owner read from the store, and a repeat of it is still told so
3763
- // — or refused, rather than handed another agent's error.
3764
- const attach = async () => {
3765
- let outcome;
3766
- try {
3767
- outcome = { session: await shared };
3768
- }
3769
- catch (error) {
3770
- outcome = { error };
3771
- }
3772
- const owner = 'session' in outcome ? outcome.session.agent.id : (await store.get(input.sessionId))?.agent.id;
3773
- if (owner !== undefined && owner !== claimed) {
3774
- throw crossIdentityError(input.sessionId, owner, claimed);
3775
- }
3776
- // No owner means the dispatch failed before its session was ever
3777
- // written: there is no turn to have repeated, only its error.
3778
- if (owner !== undefined) {
3779
- input.onRepeat?.('live');
3780
- }
3781
- if ('error' in outcome) {
3782
- throw outcome.error;
3783
- }
3784
- return outcome.session;
3785
- };
3786
- return attach();
3787
- }
3788
- // Merged turns (card 423): an opted-in message behind a running turn
3789
- // joins the batch already waiting, if there is one for the same agent.
3790
- const mergeable = input.onAbsorbed !== undefined && input.addressed !== false;
3791
- const open = openBatches.get(input.sessionId);
3792
- if (mergeable &&
3793
- open !== undefined &&
3794
- open.agentId === input.agentId &&
3795
- fitsReplayBudget([...open.images, ...(input.images ?? [])]) &&
3796
- open.chars + MERGED_SEPARATOR.length + input.userMessage.length <= MERGED_TEXT_BUDGET_CHARS) {
3797
- open.images.push(...(input.images ?? []));
3798
- open.chars += MERGED_SEPARATOR.length + input.userMessage.length;
3799
- waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
3800
- const joined = new Promise((resolve, reject) => {
3801
- open.absorbed.push({ input, resolve, reject });
3802
- });
3803
- if (idempotencyKey !== undefined) {
3804
- // Provisional, like a queued dispatch's: a redelivery while it waits
3805
- // attaches here; the merged turn confirms it once it owns the key.
3806
- const items = liveWorkItems.get(input.sessionId) ?? new Map();
3807
- if (!items.has(idempotencyKey)) {
3808
- items.set(idempotencyKey, { turn: joined, agentId: input.agentId, confirmed: false, addressed: true });
3809
- liveWorkItems.set(input.sessionId, items);
3810
- }
3811
- void joined.catch(() => { }).finally(() => {
3812
- const current = liveWorkItems.get(input.sessionId);
3813
- if (current?.get(idempotencyKey)?.turn === joined) {
3814
- current.delete(idempotencyKey);
3815
- if (current.size === 0) {
3816
- liveWorkItems.delete(input.sessionId);
3817
- }
3818
- }
3819
- });
3820
- }
3821
- return joined;
3822
- }
3823
- // It cannot join (or its images or text would overflow the batch's
3824
- // budget), so the open batch takes no more: a message arriving
3825
- // after this one must not be answered ahead of it.
3826
- if (open !== undefined) {
3827
- openBatches.delete(input.sessionId);
3828
- }
3829
- // Otherwise it leads a new batch, if it is waiting behind a turn at all:
3830
- // a message to an idle session runs at once, alone.
3831
- const batch = mergeable && sessionChains.has(input.sessionId)
3832
- ? {
3833
- agentId: input.agentId,
3834
- // Settled through `settleLeader`, read when called: it is assigned below.
3835
- leader: { input, resolve: (session) => settleLeader({ session }), reject: (error) => settleLeader({ error }) },
3836
- firstInLine: (queuedWork.get(input.sessionId) ?? 0) === 0,
3837
- absorbed: [],
3838
- steered: false,
3839
- images: [...(input.images ?? [])],
3840
- chars: input.userMessage.length,
3841
- }
3842
- : undefined;
3843
- if (batch !== undefined) {
3844
- openBatches.set(input.sessionId, batch);
3845
- }
3846
- // Reads `turn` only once it is assigned: the registration below runs
3847
- // after it, and the chain's work runs in a later tick (`.then`).
3848
- const claimLiveItem = (key, confirmed) => {
3849
- const items = liveWorkItems.get(input.sessionId) ?? new Map();
3850
- items.set(key, { turn, agentId: input.agentId, confirmed, addressed: input.addressed !== false });
3851
- liveWorkItems.set(input.sessionId, items);
3852
- };
3853
- /**
3854
- * This dispatch's turn with the messages it absorbed (card 423). Each
3855
- * part is judged on its own first, as it would have been alone: one
3856
- * cancelled while it waited is refused, one that repeats a turn the
3857
- * session already holds is that turn. What is left runs as one turn.
3858
- */
3859
- const runMerged = async (absorbed) => {
3860
- const parts = [{ input }, ...absorbed.map((joiner) => ({ input: joiner.input, joiner }))];
3861
- let leader;
3862
- const settle = (part, outcome) => {
3863
- if (part.joiner === undefined) {
3864
- // Settled the moment it is known, not when the chain moves on: a
3865
- // leader cancelled or repeated while it waited must leave its
3866
- // caller's queue before the turn it handed over starts rendering.
3867
- leader = outcome;
3868
- settleLeader(outcome);
3869
- }
3870
- else if ('session' in outcome) {
3871
- part.joiner.resolve(outcome.session);
3872
- }
3873
- else {
3874
- part.joiner.reject(outcome.error);
3875
- }
3876
- };
3877
- const runnable = [];
3878
- for (const part of parts) {
3879
- const own = part.input;
3880
- if (own.signal?.aborted) {
3881
- settle(part, { error: abortErrorFor(own.signal) });
3882
- continue;
3883
- }
3884
- if (own.idempotencyKey !== undefined) {
3885
- try {
3886
- const repeated = await settleRepeat(input.sessionId, own.idempotencyKey, own.agentId, own.turnId, own.onRepeat);
3887
- if (repeated !== undefined) {
3888
- settle(part, { session: repeated });
3889
- continue;
3890
- }
3891
- }
3892
- catch (error) {
3893
- settle(part, { error });
3894
- continue;
3895
- }
3896
- }
3897
- runnable.push(part);
3898
- }
3899
- const head = runnable[0];
3900
- if (head === undefined) {
3901
- return finish(leader);
3902
- }
3903
- // Every runnable key now belongs to this turn.
3904
- const keys = runnable.flatMap((part) => (part.input.idempotencyKey !== undefined ? [part.input.idempotencyKey] : []));
3905
- const items = liveWorkItems.get(input.sessionId);
3906
- for (const key of keys) {
3907
- const item = items?.get(key);
3908
- if (item !== undefined) {
3909
- item.confirmed = true;
3910
- }
3911
- }
3912
- // The turn runs at the least trusted sender in it: everyone's words
3913
- // are in its one message.
3914
- const labelled = runnable.some((part) => part.input.metadata !== undefined && SENDER_TRUST_METADATA_KEY in part.input.metadata);
3915
- const lowest = leastTrusted(...runnable.map((part) => senderTrustOf(part.input.metadata)));
3916
- const newest = runnable[runnable.length - 1].input;
3917
- const metadata = labelled ? { ...newest.metadata, [SENDER_TRUST_METADATA_KEY]: lowest } : newest.metadata;
3918
- const images = runnable.flatMap((part) => part.input.images ?? []);
3919
- const signals = runnable.map((part) => part.input.signal);
3920
- // Cancelled only when every caller in it is: one caller giving up
3921
- // must not take the others' answer with it.
3922
- const signal = signals.every((candidate) => candidate !== undefined)
3923
- ? allAborted(signals)
3924
- : undefined;
3925
- const [primaryKey, ...absorbedKeys] = keys;
3926
- const merged = {
3927
- sessionId: input.sessionId,
3928
- ...(head.input.agentId !== undefined ? { agentId: head.input.agentId } : {}),
3929
- userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
3930
- ...(images.length > 0 ? { images } : {}),
3931
- ...(runnable.every((part) => part.input.replyOptional === true) ? { replyOptional: true } : {}),
3932
- ...(head.input.earlier !== undefined ? { earlier: head.input.earlier } : {}),
3933
- ...(metadata !== undefined ? { metadata } : {}),
3934
- ...(signal !== undefined ? { signal } : {}),
3935
- ...(head.input.turnId !== undefined ? { turnId: head.input.turnId } : {}),
3936
- ...(primaryKey !== undefined ? { idempotencyKey: primaryKey } : {}),
3937
- };
3938
- await settleOrphanedItem(input.sessionId);
3939
- let outcome;
3940
- if (merged.turnId !== undefined) {
3941
- activeTurns.set(input.sessionId, merged.turnId);
3942
- }
3943
- try {
3944
- outcome = { session: await runSteerable(merged, absorbedKeys) };
3945
- }
3946
- catch (error) {
3947
- outcome = { error };
3948
- }
3949
- finally {
3950
- if (merged.turnId !== undefined) {
3951
- activeTurns.delete(input.sessionId);
3952
- }
3953
- }
3954
- for (const part of runnable) {
3955
- if (part !== head) {
3956
- part.input.onAbsorbed?.();
3957
- }
3958
- settle(part, outcome);
3959
- }
3960
- return finish(leader);
3961
- };
3962
- const finish = (outcome) => {
3963
- if (outcome === undefined) {
3964
- throw new Error('A merged turn settled without an outcome for its leader.');
3965
- }
3966
- if ('error' in outcome) {
3967
- throw outcome.error;
3968
- }
3969
- return outcome.session;
3970
- };
3971
- // What this caller is handed. It follows the chain's work, except that a
3972
- // batch leader settles it early (`settle` in `runMerged`).
3973
- let settleLeader = () => { };
3974
- const turn = new Promise((resolve, reject) => {
3975
- settleLeader = (outcome) => ('error' in outcome ? reject(outcome.error) : resolve(outcome.session));
3976
- });
3977
- waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
3978
- /**
3979
- * Runs this dispatch's turn, handing it whatever queues behind it at
3980
- * each tool step (`takeSteered`), then settles every dispatch it took
3981
- * with the turn's outcome — the outcome they joined. Only for a caller
3982
- * that opted into merged turns: it is the one that renders an answer
3983
- * covering messages other than its own.
3984
- */
3985
- const runSteerable = async (turnInput, absorbedKeys = []) => {
3986
- const steered = [];
3987
- // Cancelled only when every caller in it is, as a merged turn is, the
3988
- // ones steered in later included: one caller giving up must not take
3989
- // the others' answer with it. A turn with a caller who cannot cancel
3990
- // has no signal to join.
3991
- const joint = turnInput.signal !== undefined ? joinableSignal([turnInput.signal]) : undefined;
3992
- // Admitted on the turn's own signal, not only the callers' joint one:
3993
- // the watchdog, `abortTurns` and a restart's window stop the turn
3994
- // without touching its callers, and a batch taken into a turn stopped
3995
- // while the store was read would share its failure instead of running
3996
- // next (card 959). The turn's signal follows the joint one, so this
3997
- // covers every caller giving up too.
3998
- const steer = mergeable
3999
- ? (turnSignal) => takeSteered(input.sessionId, input.agentId, steered, (parts) => {
4000
- if (turnSignal.aborted) {
4001
- return false;
4002
- }
4003
- for (const part of parts) {
4004
- joint?.join(part.input.signal);
4005
- }
4006
- return true;
4007
- })
4008
- : undefined;
4009
- const running = joint !== undefined ? { ...turnInput, signal: joint.signal } : turnInput;
4010
- let outcome;
4011
- try {
4012
- outcome = { session: await dispatchInternal(running, absorbedKeys, undefined, steer) };
4013
- }
4014
- catch (error) {
4015
- outcome = { error };
4016
- }
4017
- for (const part of steered) {
4018
- part.input.onAbsorbed?.();
4019
- if ('session' in outcome) {
4020
- part.resolve(outcome.session);
4021
- }
4022
- else {
4023
- part.reject(outcome.error);
4024
- }
4025
- }
4026
- if ('error' in outcome) {
4027
- throw outcome.error;
4028
- }
4029
- return outcome.session;
4030
- };
4031
- const chained = onSessionChain(input.sessionId, async () => {
4032
- // Steered into the turn that ran ahead of it, which has answered and
4033
- // settled everyone in the batch already — this caller included.
4034
- if (batch?.steered === true) {
4035
- return turn;
4036
- }
4037
- // The batch closes here: anything arriving from now on waits behind
4038
- // this turn and leads the next one.
4039
- if (batch !== undefined && openBatches.get(input.sessionId) === batch) {
4040
- openBatches.delete(input.sessionId);
4041
- }
4042
- const absorbed = batch?.absorbed ?? [];
4043
- // No longer waiting once it reaches the head of the chain — nor is
4044
- // anything it absorbed.
4045
- const left = (waitingDispatches.get(input.sessionId) ?? 1) - 1 - absorbed.length;
4046
- if (left > 0) {
4047
- waitingDispatches.set(input.sessionId, left);
4048
- }
4049
- else {
4050
- waitingDispatches.delete(input.sessionId);
4051
- }
4052
- if (absorbed.length > 0) {
4053
- return runMerged(absorbed);
4054
- }
4055
- // Before `activeTurns` is touched: a repeat that runs nothing must
4056
- // not claim the session's turn id, even for the instant it takes.
4057
- const repeated = idempotencyKey !== undefined ? await settleRepeat(input.sessionId, idempotencyKey, input.agentId, turnId, input.onRepeat) : undefined;
4058
- if (repeated !== undefined) {
4059
- return repeated;
4060
- }
4061
- // Past the identity check with a key the session has not seen, so this
4062
- // dispatch owns the item: it becomes the live entry even if a claimant
4063
- // that was then refused registered first, or a repeat arriving while
4064
- // this turn runs would find nothing to attach to, queue, and read the
4065
- // turn as finished — a second answer beside this caller's.
4066
- if (idempotencyKey !== undefined) {
4067
- claimLiveItem(idempotencyKey, true);
4068
- }
4069
- await settleOrphanedItem(input.sessionId);
4070
- if (turnId === undefined) {
4071
- return runSteerable(input);
4072
- }
4073
- activeTurns.set(input.sessionId, turnId);
4074
- try {
4075
- return await runSteerable(input);
4076
- }
4077
- finally {
4078
- // Unconditional, and safe to be: the chain guarantees no other turn
4079
- // for this session ran between the set above and here, so this can
4080
- // only ever be clearing its own entry.
4081
- activeTurns.delete(input.sessionId);
4082
- }
4083
- }, () => batch?.steered === true);
4084
- // A second settle (a leader already settled early) is a no-op.
4085
- chained.then((session) => settleLeader({ session }), (error) => settleLeader({ error }));
4086
- if (idempotencyKey !== undefined) {
4087
- // A repeat that queued instead of attaching — it named another agent,
4088
- // and is about to be refused — must not displace the turn it repeats.
4089
- // The first registration is provisional; the chain confirms it
4090
- // (`claimLiveItem` above) once the dispatch is known to own the item.
4091
- if (liveWorkItems.get(input.sessionId)?.has(idempotencyKey) !== true) {
4092
- claimLiveItem(idempotencyKey, false);
4093
- }
4094
- void turn.catch(() => { }).finally(() => {
4095
- const current = liveWorkItems.get(input.sessionId);
4096
- if (current?.get(idempotencyKey)?.turn === turn) {
4097
- current.delete(idempotencyKey);
4098
- if (current.size === 0) {
4099
- liveWorkItems.delete(input.sessionId);
4100
- }
4101
- }
4102
- });
4103
- }
4104
- return turn;
4105
- };
2013
+ // `stopping`, `intakeState` and `heldState` are read late.
2014
+ const { dispatch } = createDispatch({
2015
+ store,
2016
+ sessionChains,
2017
+ queuedWork,
2018
+ waitingDispatches,
2019
+ isStopping: () => stopping,
2020
+ getIntakeState: () => intakeState,
2021
+ isHeld: () => heldState,
2022
+ refusal,
2023
+ activeTurns,
2024
+ liveWorkItems,
2025
+ openBatches,
2026
+ settleOrphanedItem,
2027
+ onSessionChain,
2028
+ dispatchInternal,
2029
+ settleRepeat,
2030
+ takeSteered,
2031
+ });
4106
2032
  const observe = async (input) => {
4107
2033
  if (stopping) {
4108
2034
  throw refusal();
@@ -4584,7 +2510,6 @@ export const createGateway = (options = {}) => {
4584
2510
  loadedPlugins = [];
4585
2511
  return released;
4586
2512
  };
4587
- const startedChannels = [];
4588
2513
  /** Set once a shutdown has stopped the started channels; a start that finishes after it stops its own. */
4589
2514
  let channelsClosed = false;
4590
2515
  /** Resolves with `true` when `work` settles before `ms` elapse. */
@@ -4885,8 +2810,11 @@ export const createGateway = (options = {}) => {
4885
2810
  // placeholder by a process that died before saving its reply). It is
4886
2811
  // a new gateway seam, and it has to be ordered against the overhears
4887
2812
  // queued on the same session chain, so it is not built yet.
2813
+ // A post a tool handed the thread (`postOf`, AGT-324) is speaking the
2814
+ // same way a file is.
4888
2815
  const spoken = (message) => (message.role === 'assistant' && message.content.trim().length > 0)
4889
- || (message.role === 'tool' && message.toolResult !== undefined && filePathsOf(message.toolResult).length > 0);
2816
+ || (message.role === 'tool' && message.toolResult !== undefined
2817
+ && (filePathsOf(message.toolResult).length > 0 || postOf(message.toolResult) !== undefined));
4890
2818
  const spokeIndex = session.messages.findLastIndex(spoken);
4891
2819
  const lastSpokeAt = spokeIndex >= 0 ? session.messages[spokeIndex]?.createdAt : undefined;
4892
2820
  return {