@stratusagent/gateway 0.14.7 → 0.14.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-source.d.ts +10 -0
- package/dist/agent-source.d.ts.map +1 -0
- package/dist/agent-source.js +7 -0
- package/dist/agent-source.js.map +1 -0
- package/dist/approvals.d.ts +38 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +330 -0
- package/dist/approvals.js.map +1 -0
- package/dist/credential-requests.d.ts +24 -0
- package/dist/credential-requests.d.ts.map +1 -0
- package/dist/credential-requests.js +452 -0
- package/dist/credential-requests.js.map +1 -0
- package/dist/dispatch.d.ts +34 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +461 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/index.d.ts +4 -1290
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +202 -2546
- package/dist/index.js.map +1 -1
- package/dist/lifecycle.d.ts +130 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +147 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/merge-batch.d.ts +60 -0
- package/dist/merge-batch.d.ts.map +1 -0
- package/dist/merge-batch.js +59 -0
- package/dist/merge-batch.js.map +1 -0
- package/dist/recovery.d.ts +49 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +487 -0
- package/dist/recovery.js.map +1 -0
- package/dist/session-keys.d.ts +101 -0
- package/dist/session-keys.d.ts.map +1 -0
- package/dist/session-keys.js +148 -0
- package/dist/session-keys.js.map +1 -0
- package/dist/steering.d.ts +24 -0
- package/dist/steering.d.ts.map +1 -0
- package/dist/steering.js +108 -0
- package/dist/steering.js.map +1 -0
- package/dist/sub-agents.d.ts +20 -0
- package/dist/sub-agents.d.ts.map +1 -0
- package/dist/sub-agents.js +263 -0
- package/dist/sub-agents.js.map +1 -0
- package/dist/turn-dispatch.d.ts +34 -0
- package/dist/turn-dispatch.d.ts.map +1 -0
- package/dist/turn-dispatch.js +179 -0
- package/dist/turn-dispatch.js.map +1 -0
- package/dist/types.d.ts +1103 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -6,18 +6,25 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
-
import { createHash,
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
9
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
+
import { filePathsOf, 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";
|
|
14
15
|
import { createFleetReader } from "./fleet.js";
|
|
15
16
|
import { createMemoryPassHost } from "./memory-passes.js";
|
|
17
|
+
import { createRecovery } from "./recovery.js";
|
|
16
18
|
import { callShownWhole, readReviewReply, REVIEW_RULES, reviewCaseText } from "./review.js";
|
|
17
19
|
import { createEventIntake, summarizeEvents, } from "./event-intake.js";
|
|
18
20
|
import { SqliteEventStore } from "./events.js";
|
|
19
21
|
import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
|
|
20
22
|
import { ShardedSessionStore } from "./sessions.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,
|
|
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,348 +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
|
-
/** What a turn cut short by an announced restart records as its failure. */
|
|
57
|
-
export const RESTARTING_TURN_ERROR = 'Run aborted: stratusd is restarting';
|
|
58
|
-
/** The error a turn still running when a stop's drain window closes is aborted with. */
|
|
59
|
-
export const STOPPING_TURN_ERROR = 'Run aborted: stratusd is stopping';
|
|
60
|
-
/**
|
|
61
|
-
* How long an announced restart lets in-flight turns finish before aborting
|
|
62
|
-
* them. The same as a stop's window (`DEFAULT_STOP_DRAIN_TIMEOUT_MS`), so the
|
|
63
|
-
* two windows an operator can hit agree.
|
|
64
|
-
*/
|
|
65
|
-
export const DEFAULT_RESTART_DRAIN_TIMEOUT_MS = 30_000;
|
|
66
|
-
/** How long a roster reload waits on one channel's `reload()`. */
|
|
67
|
-
export const DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS = 30_000;
|
|
68
|
-
/**
|
|
69
|
-
* How long a stop lets in-flight turns finish before aborting them. The
|
|
70
|
-
* daemon's documented drain ("up to 30 seconds"), and shorter than the
|
|
71
|
-
* service units' stop timeouts, so the abort happens in this process
|
|
72
|
-
* rather than as the manager's SIGKILL. That matters beyond the turn's
|
|
73
|
-
* record: a shell command runs in its own process group, which launchd's
|
|
74
|
-
* kill of the job does not reach, so only the turn's abort stops it.
|
|
75
|
-
*/
|
|
76
|
-
export const DEFAULT_STOP_DRAIN_TIMEOUT_MS = 30_000;
|
|
77
|
-
/** What a paused daemon says when nobody gave it a sentence of its own. */
|
|
78
|
-
export const DEFAULT_PAUSED_MESSAGE = "I'm paused right now and not taking new work. Please try again later.";
|
|
79
|
-
/** What a held gateway answers a dispatch with, before it is promoted. */
|
|
80
|
-
export const HELD_MESSAGE = "I'm starting up and not taking work yet. Please try again in a moment.";
|
|
81
|
-
/** The longest pause message, so a reply to a refused message stays one. */
|
|
82
|
-
export const MAX_PAUSED_MESSAGE_LENGTH = 500;
|
|
83
|
-
/** A pause message the gateway will not take: empty, or too long. */
|
|
84
|
-
export class PauseMessageError extends Error {
|
|
85
|
-
constructor(message) {
|
|
86
|
-
super(message);
|
|
87
|
-
this.name = 'PauseMessageError';
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* A dispatch refused because intake is paused. Its message is the sentence
|
|
92
|
-
* to show whoever sent the work, as it is: a channel posts it as the reply,
|
|
93
|
-
* not as a failure.
|
|
94
|
-
*/
|
|
95
|
-
export class IntakePausedError extends Error {
|
|
96
|
-
constructor(message) {
|
|
97
|
-
super(message);
|
|
98
|
-
this.name = 'IntakePausedError';
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* A dispatch refused because the daemon is restarting or stopping. Channels
|
|
103
|
-
* stay connected while a restart drains, so they hear people during it, and
|
|
104
|
-
* this is what tells them the message reached a daemon on its way down: a
|
|
105
|
-
* channel can say so and keep the message for the next one. Matched by name
|
|
106
|
-
* there, as channel packages do not import the gateway.
|
|
107
|
-
*/
|
|
108
|
-
export class GatewayStoppingError extends Error {
|
|
109
|
-
/** True for a restart, which comes back on its own; false for a stop. */
|
|
110
|
-
restarting;
|
|
111
|
-
constructor(message, restarting) {
|
|
112
|
-
super(message);
|
|
113
|
-
this.name = 'GatewayStoppingError';
|
|
114
|
-
this.restarting = restarting;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* The intake state a previous process left, or open when there is none.
|
|
119
|
-
*
|
|
120
|
-
* Fails closed: a file that exists but cannot be read or parsed starts the
|
|
121
|
-
* daemon paused, with a warning naming it. A pause exists to stop work (a
|
|
122
|
-
* spending cap, a maintenance window), and a file nobody can read is no
|
|
123
|
-
* evidence that it was lifted.
|
|
124
|
-
*/
|
|
125
|
-
const readIntakeState = async (env, warn) => {
|
|
126
|
-
const file = intakeStatePath(env);
|
|
127
|
-
let raw;
|
|
128
|
-
try {
|
|
129
|
-
raw = await readFile(file, 'utf8');
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
if (error.code === 'ENOENT') {
|
|
133
|
-
return { paused: false };
|
|
134
|
-
}
|
|
135
|
-
warn(`intake: ${file} could not be read (${error.message}); starting paused. Resume intake to clear it.`);
|
|
136
|
-
return { paused: true, since: new Date().toISOString(), unreadable: true };
|
|
137
|
-
}
|
|
138
|
-
try {
|
|
139
|
-
const parsed = JSON.parse(raw);
|
|
140
|
-
// Only a pause is ever written here (resuming removes the file), so
|
|
141
|
-
// anything else in it is not a record this build wrote, and the same
|
|
142
|
-
// rule as an unreadable file applies.
|
|
143
|
-
if (typeof parsed !== 'object' || parsed === null || parsed.paused !== true) {
|
|
144
|
-
warn(`intake: ${file} does not hold a pause record; starting paused. Resume intake to clear it.`);
|
|
145
|
-
return { paused: true, since: new Date().toISOString(), unreadable: true };
|
|
146
|
-
}
|
|
147
|
-
return {
|
|
148
|
-
paused: true,
|
|
149
|
-
...(typeof parsed.message === 'string' && parsed.message.trim().length > 0 ? { message: parsed.message } : {}),
|
|
150
|
-
since: typeof parsed.since === 'string' ? parsed.since : new Date().toISOString(),
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
warn(`intake: ${file} is not valid JSON; starting paused. Resume intake to clear it.`);
|
|
155
|
-
return { paused: true, since: new Date().toISOString(), unreadable: true };
|
|
156
|
-
}
|
|
157
|
-
};
|
|
158
|
-
/**
|
|
159
|
-
* Record a pause durably (0600, replaced in one atomic step), or remove the
|
|
160
|
-
* record when intake reopens: open is the default, so nothing on disk
|
|
161
|
-
* means open.
|
|
162
|
-
*/
|
|
163
|
-
const writeIntakeState = async (env, state) => {
|
|
164
|
-
const file = intakeStatePath(env);
|
|
165
|
-
if (!state.paused) {
|
|
166
|
-
await rm(file, { force: true });
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
170
|
-
const staging = `${file}.${randomBytes(8).toString('hex')}`;
|
|
171
|
-
try {
|
|
172
|
-
await writeFileSynced(staging, `${JSON.stringify(state)}\n`, { flag: 'wx', mode: 0o600 });
|
|
173
|
-
await rename(staging, file);
|
|
174
|
-
await syncDirectory(path.dirname(file));
|
|
175
|
-
}
|
|
176
|
-
finally {
|
|
177
|
-
await rm(staging, { force: true });
|
|
178
|
-
}
|
|
179
|
-
};
|
|
180
|
-
/**
|
|
181
|
-
* `restart()` on a gateway whose host gave it no way back. Exported so the
|
|
182
|
-
* control API can answer it as "not supported here" rather than as a
|
|
183
|
-
* failure of the daemon.
|
|
184
|
-
*/
|
|
185
|
-
export class RestartUnsupportedError extends Error {
|
|
186
|
-
constructor() {
|
|
187
|
-
super('This daemon cannot restart itself: the host that started it did not say how. '
|
|
188
|
-
+ 'Restart it from the service manager (stratus service stop, then start) or from the terminal it runs in.');
|
|
189
|
-
this.name = 'RestartUnsupportedError';
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* The most text one merged user message may hold (card 423). The runner's
|
|
194
|
-
* overflow recovery can drop earlier transcript but never part of the
|
|
195
|
-
* newest message, so a batch must not grow that message past what one
|
|
196
|
-
* message could be on its own: this is Slack's own message-length limit.
|
|
197
|
-
* A single message longer than this still runs, alone, as it would have
|
|
198
|
-
* without merging.
|
|
199
|
-
*/
|
|
200
|
-
const MERGED_TEXT_BUDGET_CHARS = 40_000;
|
|
201
|
-
/** Separator between merged messages; counted against the text budget. */
|
|
202
|
-
const MERGED_SEPARATOR = '\n\n';
|
|
203
|
-
/**
|
|
204
|
-
* Whether `images` all fit the replay budget together. A merged turn's
|
|
205
|
-
* images are one message's, and the runner empties whatever falls outside
|
|
206
|
-
* the budget before the provider sees it, so a batch that would overflow
|
|
207
|
-
* must not take the message that overflows it (card 423).
|
|
208
|
-
*/
|
|
209
|
-
const fitsReplayBudget = (images) => images.length === 0 || imagesWithinReplayBudget([{ id: '', createdAt: '', role: 'user', content: '', images: [...images] }]).size === images.length;
|
|
210
|
-
/** A signal that aborts once every one of `signals` has. */
|
|
211
|
-
const allAborted = (signals) => joinableSignal(signals).signal;
|
|
212
|
-
/**
|
|
213
|
-
* `allAborted` for callers that keep joining: a turn that takes in messages
|
|
214
|
-
* at its tool steps (card 948) is cancelled only once every caller in it
|
|
215
|
-
* is, the ones who joined late included. A caller joining with no signal
|
|
216
|
-
* can never cancel, so from then on neither can the turn.
|
|
217
|
-
*/
|
|
218
|
-
const joinableSignal = (initial) => {
|
|
219
|
-
const controller = new AbortController();
|
|
220
|
-
const signals = [...initial];
|
|
221
|
-
let uncancellable = false;
|
|
222
|
-
const check = () => {
|
|
223
|
-
if (!uncancellable && !controller.signal.aborted && signals.every((signal) => signal.aborted)) {
|
|
224
|
-
controller.abort(signals[signals.length - 1]?.reason);
|
|
225
|
-
}
|
|
226
|
-
};
|
|
227
|
-
for (const signal of signals) {
|
|
228
|
-
signal.addEventListener('abort', check, { once: true });
|
|
229
|
-
}
|
|
230
|
-
check();
|
|
231
|
-
return {
|
|
232
|
-
signal: controller.signal,
|
|
233
|
-
join: (signal) => {
|
|
234
|
-
if (signal === undefined) {
|
|
235
|
-
uncancellable = true;
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
signals.push(signal);
|
|
239
|
-
signal.addEventListener('abort', check, { once: true });
|
|
240
|
-
},
|
|
241
|
-
};
|
|
242
|
-
};
|
|
243
|
-
/**
|
|
244
|
-
* A value from a form, without the line breaks around it.
|
|
245
|
-
*
|
|
246
|
-
* Forms take multi-line values now, so a one-line key copied with the line
|
|
247
|
-
* break after it would otherwise be stored with it, and fail later inside
|
|
248
|
-
* whatever header or command it lands in. Only line breaks: a value's own
|
|
249
|
-
* spaces are its own, as on the CLI.
|
|
250
|
-
*/
|
|
251
|
-
const formCredentialValue = (value) => value.replace(/^[\r\n]+|[\r\n]+$/g, '');
|
|
252
|
-
/**
|
|
253
|
-
* A PEM block (a private key, a certificate) with no line breaks left, which
|
|
254
|
-
* is what a one-line field makes of a pasted key file. PEM readers need the
|
|
255
|
-
* line after the `-----BEGIN …-----` header, so storing it would only move
|
|
256
|
-
* the failure to the first call that reads the key. Only a value that *is*
|
|
257
|
-
* one: a PEM inside JSON (a service account file) escapes its line breaks,
|
|
258
|
-
* so it has none of its own and is fine as sent.
|
|
259
|
-
*/
|
|
260
|
-
const isFlattenedPem = (value) => /^\s*-----BEGIN [A-Z0-9 ]+-----/.test(value) && !value.includes('\n');
|
|
261
|
-
/**
|
|
262
|
-
* Recorded on a turn the previous process was still running when it died.
|
|
263
|
-
*
|
|
264
|
-
* Exported because "distinguishable" is the whole point: a surface, a
|
|
265
|
-
* test, or an operator reading `lastError` has to be able to tell a turn
|
|
266
|
-
* nobody finished apart from one a provider or a tool actually failed, and
|
|
267
|
-
* matching on prose that lives in one place beats each caller inventing
|
|
268
|
-
* its own guess at the wording.
|
|
269
|
-
*/
|
|
270
|
-
export const ABANDONED_TURN_ERROR = 'stratusd stopped while this turn was still running; it was not resumed. Send the message again.';
|
|
271
|
-
/**
|
|
272
|
-
* The longest `DispatchInput.idempotencyKey` accepted. A platform message id
|
|
273
|
-
* is far shorter; the bound is so a key cannot be used to grow every row it
|
|
274
|
-
* is stored on.
|
|
275
|
-
*/
|
|
276
|
-
/**
|
|
277
|
-
* Recorded on a delegated sub-session that was parked on a human when the
|
|
278
|
-
* previous process died.
|
|
279
|
-
*
|
|
280
|
-
* Its parent — the turn whose `agent.delegate` call was awaiting the reply
|
|
281
|
-
* — was running, so the restart settles it as abandoned (above). Recovering
|
|
282
|
-
* the child anyway would re-ask a person to approve a call whose result no
|
|
283
|
-
* turn will ever read, and then run it: a command executed, tokens spent,
|
|
284
|
-
* and a reply delivered to nobody. Distinguishable from the parent's error
|
|
285
|
-
* because the remedy differs — there is no message to send again here;
|
|
286
|
-
* the parent's is the one to repeat.
|
|
287
|
-
*/
|
|
288
|
-
/**
|
|
289
|
-
* Session metadata keys the daemon writes for itself, which no external
|
|
290
|
-
* dispatch may supply.
|
|
291
|
-
*
|
|
292
|
-
* Metadata is one bag: the keys a channel attaches to say where a turn is
|
|
293
|
-
* happening sit beside the ones the kernel, the scheduler, the fallback
|
|
294
|
-
* wrapper, and `agent.delegate` write to remember what a session *is* —
|
|
295
|
-
* and the control API forwards a caller's `metadata` into a new session as
|
|
296
|
-
* given. A caller who can write `delegatedBy` can have the restart sweep
|
|
297
|
-
* fail its own parked turn as an orphan; one who can write `fallbackActive`
|
|
298
|
-
* bills its conversation to the fallback model from the first turn. The
|
|
299
|
-
* scheduler already refused to trust its own keys for exactly this reason
|
|
300
|
-
* (see `activeFirings`); the durable ones cannot be re-derived in memory,
|
|
301
|
-
* so they are refused at the door instead.
|
|
302
|
-
*/
|
|
303
|
-
/**
|
|
304
|
-
* A rolled-over session: the fresh row records where its predecessor's
|
|
305
|
-
* transcript went, and the archived row records which live id continues
|
|
306
|
-
* the conversation. See `Gateway.rolloverSession`.
|
|
307
|
-
*/
|
|
308
|
-
export const ROLLED_OVER_FROM_METADATA_KEY = 'rolledOverFrom';
|
|
309
|
-
export const ROLLED_OVER_TO_METADATA_KEY = 'rolledOverTo';
|
|
310
|
-
/** The segment a rollover mints into the archived transcript's id. */
|
|
311
|
-
export const ROLLED_OVER_SESSION_ID_MARKER = ':rolledover:';
|
|
312
|
-
/**
|
|
313
|
-
* How long after a rollover a repeated key is still looked for in the
|
|
314
|
-
* archived transcript. A rollover moves every keyed message out of the
|
|
315
|
-
* live row, so a redelivery arriving after one would otherwise read as a
|
|
316
|
-
* new message and run again. Platforms redeliver within minutes — Slack
|
|
317
|
-
* gives up after about five — and past the window the archive is not
|
|
318
|
-
* loaded at all, since every new message to the fresh row would pay for a
|
|
319
|
-
* read that can no longer find anything.
|
|
320
|
-
*/
|
|
321
|
-
const ROLLOVER_REPEAT_WINDOW_MS = 60 * 60 * 1000;
|
|
322
|
-
/**
|
|
323
|
-
* The one refusal for a dispatch naming an agent other than its session's.
|
|
324
|
-
* Its own type so a repeat attached to a claimant can tell the claimant was
|
|
325
|
-
* refused for who it named — the item then still has no owner — from the
|
|
326
|
-
* claimant's turn failing. Internal: callers read the message.
|
|
327
|
-
*/
|
|
328
|
-
class CrossIdentityError extends Error {
|
|
329
|
-
constructor(sessionId, owner, claimed) {
|
|
330
|
-
super(`Session ${sessionId} belongs to agent ${owner}, not ${claimed} — sessions never cross agent identities.`);
|
|
331
|
-
this.name = 'CrossIdentityError';
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
const crossIdentityError = (sessionId, owner, claimed) => new CrossIdentityError(sessionId, owner, claimed);
|
|
335
|
-
/** Where the session's tool calls ran: `local-command`, or a contributed executor's registered name. */
|
|
336
|
-
export const EXECUTOR_METADATA_KEY = 'executor';
|
|
337
|
-
/**
|
|
338
|
-
* Marks a sub-agent's session (roadmap 24). Reserved: it is what makes the
|
|
339
|
-
* daemon deny the session's gated calls without asking anyone, so a caller
|
|
340
|
-
* that could set it could also clear it.
|
|
341
|
-
*/
|
|
342
|
-
export const SUB_AGENT_METADATA_KEY = 'subAgent';
|
|
343
|
-
export const SUB_AGENT_SESSION_ID_MARKER = ':subagent:';
|
|
344
|
-
/** How many sub-agents one parent turn runs at once unless the host says otherwise. */
|
|
345
|
-
export const DEFAULT_SUB_AGENTS_PER_TURN = 4;
|
|
346
|
-
export const isSubAgentSession = (session) => session.metadata?.[SUB_AGENT_METADATA_KEY] === true && session.id.includes(SUB_AGENT_SESSION_ID_MARKER);
|
|
347
|
-
/**
|
|
348
|
-
* Tools a sub-agent never gets, whatever its parent holds. `agent.spawn`
|
|
349
|
-
* would fan out inside a fan-out; `agent.delegate` runs a roster agent
|
|
350
|
-
* under that agent's own allowlist, which is the way out of a narrowed
|
|
351
|
-
* slice; `memory.*` would let it persist what nobody reviewed; and
|
|
352
|
-
* `credential.request` asks a person, which a sub-agent never does.
|
|
353
|
-
*/
|
|
354
|
-
const subAgentNeverGets = (name) => name === 'agent.spawn'
|
|
355
|
-
|| name === DELEGATE_TOOL_NAME
|
|
356
|
-
|| name.startsWith('memory.')
|
|
357
|
-
|| name === CREDENTIAL_REQUEST_TOOL_NAME;
|
|
358
|
-
export const RESERVED_SESSION_METADATA_KEYS = [
|
|
359
|
-
PENDING_APPROVAL_METADATA_KEY,
|
|
360
|
-
FALLBACK_ACTIVE_METADATA_KEY,
|
|
361
|
-
DELEGATED_BY_METADATA_KEY,
|
|
362
|
-
ROOT_SESSION_ID_METADATA_KEY,
|
|
363
|
-
DELEGATION_DEPTH_METADATA_KEY,
|
|
364
|
-
SCHEDULED_TURN_METADATA_KEY,
|
|
365
|
-
SCHEDULE_ID_METADATA_KEY,
|
|
366
|
-
// The trust label is the runner's to write and only ever goes down; a
|
|
367
|
-
// caller who could seed it would be seeding it upward.
|
|
368
|
-
SESSION_TRUST_METADATA_KEY,
|
|
369
|
-
SESSION_TAINTED_BY_METADATA_KEY,
|
|
370
|
-
ROLLED_OVER_FROM_METADATA_KEY,
|
|
371
|
-
ROLLED_OVER_TO_METADATA_KEY,
|
|
372
|
-
// Which executor ran the turn's tool calls is the daemon's record of
|
|
373
|
-
// where a command ran, and the one audit question a caller must not be
|
|
374
|
-
// able to answer for it.
|
|
375
|
-
EXECUTOR_METADATA_KEY,
|
|
376
|
-
// The context floor is an index into a specific transcript, so it means
|
|
377
|
-
// nothing next to a different one. Reserved for both halves of that: a
|
|
378
|
-
// caller cannot seed it, and a rollover drops it with the transcript it
|
|
379
|
-
// indexed — otherwise the fresh session inherits an absolute floor over
|
|
380
|
-
// no messages and, once it grows past that index, silently stops sending
|
|
381
|
-
// its own earliest turns, with no overflow and no trim event to say so.
|
|
382
|
-
CONTEXT_FLOOR_METADATA_KEY,
|
|
383
|
-
// The summary that stands in below that floor, for both of the same
|
|
384
|
-
// reasons, and one more: the model reads it as something it wrote itself,
|
|
385
|
-
// so a caller who could seed it could put words in the agent's mouth.
|
|
386
|
-
CONTEXT_SUMMARY_METADATA_KEY,
|
|
387
|
-
SUB_AGENT_METADATA_KEY,
|
|
388
|
-
];
|
|
389
|
-
/**
|
|
390
|
-
* The first reserved key `metadata` carries, or undefined when it carries
|
|
391
|
-
* none. Exported so the control API can refuse the request where the
|
|
392
|
-
* caller can see it, with the same rule the gateway applies at dispatch.
|
|
393
|
-
*/
|
|
394
|
-
export const reservedSessionMetadataKey = (metadata) => metadata === undefined
|
|
395
|
-
? undefined
|
|
396
|
-
: RESERVED_SESSION_METADATA_KEYS.find((key) => Object.prototype.hasOwnProperty.call(metadata, key));
|
|
397
|
-
export const ORPHANED_DELEGATION_ERROR = 'stratusd stopped while the turn that delegated this one was still running; the delegation was not resumed. Send the original message again.';
|
|
398
63
|
/**
|
|
399
64
|
* The always-on Stratus process. One gateway holds the roster, the durable
|
|
400
65
|
* session store, the shared event bus, and a pool of runners keyed by
|
|
@@ -482,6 +147,7 @@ export const createGateway = (options = {}) => {
|
|
|
482
147
|
if (Object.keys(eventSources).length > 0) {
|
|
483
148
|
openEvents();
|
|
484
149
|
}
|
|
150
|
+
const startedChannels = [];
|
|
485
151
|
/**
|
|
486
152
|
* The started adapter of one kind that speaks for an agent and has the
|
|
487
153
|
* capability asked for. `carriesOthers` says there were capable adapters
|
|
@@ -589,307 +255,15 @@ export const createGateway = (options = {}) => {
|
|
|
589
255
|
if (approvalTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
590
256
|
warn(`approval timeout ${approvalTimeoutMs}ms is above Node's maximum timer delay; using ${MAX_TIMER_DELAY_MS}ms (~24.8 days)`);
|
|
591
257
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
* workspace the daemon has left.
|
|
601
|
-
*/
|
|
602
|
-
const approvalEmissions = new Set();
|
|
603
|
-
/**
|
|
604
|
-
* Publishes one parked call and settles when somebody answers it, the
|
|
605
|
-
* request expires, or the turn is cancelled.
|
|
606
|
-
*
|
|
607
|
-
* All three endings run through the same `settle`, which removes the
|
|
608
|
-
* request from the registry BEFORE resolving. That ordering is the whole
|
|
609
|
-
* point: a request that is no longer pending cannot be resolved twice,
|
|
610
|
-
* so a click racing the timeout — or arriving after an abort — is refused
|
|
611
|
-
* rather than executing a tool for a turn that has already moved on.
|
|
612
|
-
*/
|
|
613
|
-
/**
|
|
614
|
-
* The conversation a delegated sub-session's work was asked from, read
|
|
615
|
-
* from the store rather than from anything the caller wrote: the root
|
|
616
|
-
* session `agent.delegate` recorded, which is where the person waiting
|
|
617
|
-
* on the answer is. Undefined for an ordinary session, or a root that no
|
|
618
|
-
* longer exists — the request is then asked the way it always was.
|
|
619
|
-
*/
|
|
620
|
-
const delegationOriginOf = async (session) => {
|
|
621
|
-
if (!isDelegatedSession(session)) {
|
|
622
|
-
return undefined;
|
|
623
|
-
}
|
|
624
|
-
const rootId = session.metadata?.[ROOT_SESSION_ID_METADATA_KEY];
|
|
625
|
-
if (typeof rootId !== 'string' || rootId === session.id) {
|
|
626
|
-
return undefined;
|
|
627
|
-
}
|
|
628
|
-
let root;
|
|
629
|
-
try {
|
|
630
|
-
root = await store.get(rootId);
|
|
631
|
-
}
|
|
632
|
-
catch (error) {
|
|
633
|
-
warn(`${session.id}: could not read the delegating session ${rootId} to route an approval (${error instanceof Error ? error.message : String(error)})`);
|
|
634
|
-
return undefined;
|
|
635
|
-
}
|
|
636
|
-
if (!root) {
|
|
637
|
-
return undefined;
|
|
638
|
-
}
|
|
639
|
-
return { agentId: root.agent.id, sessionId: root.id, ...(root.metadata ? { metadata: root.metadata } : {}) };
|
|
640
|
-
};
|
|
641
|
-
/**
|
|
642
|
-
* Which sessions are sub-agents, as the host knows it rather than as the
|
|
643
|
-
* session says (roadmap 24). Every tool in a slice is handed the same
|
|
644
|
-
* mutable Session, so the metadata marker and the id are claims a tool
|
|
645
|
-
* can rewrite. The ids are the ones `subAgents.run` minted, held while
|
|
646
|
-
* the sub-agent runs. A session object's id is read once, by the
|
|
647
|
-
* executor on its first tool call, before any tool has been handed it,
|
|
648
|
-
* so a session a tool renamed is still known by the id it ran under.
|
|
649
|
-
*/
|
|
650
|
-
const liveSubAgentIds = new Set();
|
|
651
|
-
const idAtFirstCall = new WeakMap();
|
|
652
|
-
const hostIdOf = (session) => idAtFirstCall.get(session) ?? session.id;
|
|
653
|
-
const isHostSubAgent = (session) => liveSubAgentIds.has(hostIdOf(session)) || isSubAgentSession(session);
|
|
654
|
-
const requestApproval = async (request) => {
|
|
655
|
-
// A sub-agent asks no one (roadmap 24): its parent turn is the one a
|
|
656
|
-
// person is accountable for, and that turn gets the refusal back as a
|
|
657
|
-
// result and decides what to do. Denied as undeliverable, since nobody
|
|
658
|
-
// was asked, and announced only as a resolution, so no channel renders
|
|
659
|
-
// a question nobody may answer.
|
|
660
|
-
if (isHostSubAgent(request.session)) {
|
|
661
|
-
void bus.emit({
|
|
662
|
-
type: 'tool.approval-resolved',
|
|
663
|
-
sessionId: request.session.id,
|
|
664
|
-
requestId: randomUUID(),
|
|
665
|
-
answer: 'deny',
|
|
666
|
-
reason: 'undeliverable',
|
|
667
|
-
});
|
|
668
|
-
return { answer: 'deny' };
|
|
669
|
-
}
|
|
670
|
-
const delegatedFrom = await delegationOriginOf(request.session);
|
|
671
|
-
return requestApprovalFrom(request, delegatedFrom);
|
|
672
|
-
};
|
|
673
|
-
const requestApprovalFrom = (request, delegatedFrom) => new Promise((resolve) => {
|
|
674
|
-
// A shutdown denies what is parked once, at the top of stop(). Turns
|
|
675
|
-
// already running keep going through the drain, though, and one of them
|
|
676
|
-
// can reach a gated tool AFTER that snapshot — finishing a provider
|
|
677
|
-
// call, or moving to the next call in the same response. Parking it
|
|
678
|
-
// would deadlock the drain against a question nobody is left to answer:
|
|
679
|
-
// stop() waits for the turn, the turn waits for the approval, and the
|
|
680
|
-
// approval waits out its timeout — forever when there is none.
|
|
681
|
-
if (stopping) {
|
|
682
|
-
resolve({ answer: 'deny' });
|
|
683
|
-
void bus.emit({
|
|
684
|
-
type: 'tool.approval-resolved',
|
|
685
|
-
sessionId: request.session.id,
|
|
686
|
-
requestId: randomUUID(),
|
|
687
|
-
answer: 'deny',
|
|
688
|
-
reason: 'cancelled',
|
|
689
|
-
});
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
const requestId = randomUUID();
|
|
693
|
-
let timer;
|
|
694
|
-
const onAbort = () => settle('deny', 'cancelled');
|
|
695
|
-
/**
|
|
696
|
-
* Settles once the request has finished being announced.
|
|
697
|
-
*
|
|
698
|
-
* Both emissions go through the same subscriber list, in order, and
|
|
699
|
-
* `EventBus.emit` awaits each one — so an async subscriber ahead of a
|
|
700
|
-
* channel can hold the announcement while a timeout or abort fires
|
|
701
|
-
* behind it, and the channel would hear that a request it has never
|
|
702
|
-
* heard of was resolved. It drops that, then renders the announcement
|
|
703
|
-
* when it finally arrives, leaving buttons for a settled request that
|
|
704
|
-
* nothing will ever retract.
|
|
705
|
-
*
|
|
706
|
-
* Chaining is the fix rather than remembering orphaned resolutions in
|
|
707
|
-
* the channel: a resolution that arrives before its request is not
|
|
708
|
-
* something a consumer can act on, so it should not be able to happen.
|
|
709
|
-
* Starts resolved, because a turn already aborted never announces at
|
|
710
|
-
* all and its denial should not wait on an emission that never runs.
|
|
711
|
-
*/
|
|
712
|
-
let announced = Promise.resolve();
|
|
713
|
-
// Read when the request is made, from the scheduler's own records: a
|
|
714
|
-
// live firing of a schedule approved with a destination, nothing else.
|
|
715
|
-
// Delegated work is not included yet; its sub-session is not a firing.
|
|
716
|
-
const scheduleDestination = scheduler.approvalDestinationFor(request.session);
|
|
717
|
-
// Work one agent does for another is allowed one call at a time. The
|
|
718
|
-
// people asked are the delegating conversation's, and a standing grant
|
|
719
|
-
// would widen what the other agent may do on its own work for good —
|
|
720
|
-
// so nothing offers **Always allow** here, and an `always` that
|
|
721
|
-
// arrives anyway (the control API takes all three answers) runs the
|
|
722
|
-
// call once, recorded as exactly that.
|
|
723
|
-
const forAnotherAgent = delegatedFrom !== undefined && delegatedFrom.agentId !== request.session.agent.id;
|
|
724
|
-
const oneShot = request.oneShot === true || forAnotherAgent;
|
|
725
|
-
const always = forAnotherAgent ? undefined : request.always;
|
|
726
|
-
const settle = (given, reason, actor) => {
|
|
727
|
-
const answer = forAnotherAgent && given === 'always' ? 'once' : given;
|
|
728
|
-
// Identity, not presence: a request that already settled is gone from
|
|
729
|
-
// the registry, and comparing against this exact function is what
|
|
730
|
-
// makes every ending — click, timeout, abort, shutdown — idempotent.
|
|
731
|
-
if (pendingApprovals.get(requestId)?.settle !== settle) {
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
pendingApprovals.delete(requestId);
|
|
735
|
-
if (timer) {
|
|
736
|
-
clearTimeout(timer);
|
|
737
|
-
}
|
|
738
|
-
request.signal?.removeEventListener('abort', onAbort);
|
|
739
|
-
resolve({ answer, ...(actor ? { actor } : {}) });
|
|
740
|
-
const emitted = announced.then(() => bus.emit({
|
|
741
|
-
type: 'tool.approval-resolved',
|
|
742
|
-
sessionId: request.session.id,
|
|
743
|
-
requestId,
|
|
744
|
-
answer,
|
|
745
|
-
reason,
|
|
746
|
-
...(actor ? { actor } : {}),
|
|
747
|
-
}));
|
|
748
|
-
approvalEmissions.add(emitted);
|
|
749
|
-
void emitted.finally(() => approvalEmissions.delete(emitted));
|
|
750
|
-
};
|
|
751
|
-
// What is left of the window, not a fresh one: a re-asked request
|
|
752
|
-
// carries when it originally parked, and the elapsed time counts.
|
|
753
|
-
// Computed before registration because the summary below quotes the
|
|
754
|
-
// deadline, and a listing that disagreed with the announcing event about
|
|
755
|
-
// when a request expires would be worse than one that said nothing.
|
|
756
|
-
const alreadyWaitedMs = request.parkedAt ? Date.now() - Date.parse(request.parkedAt) : 0;
|
|
757
|
-
const remainingTimeoutMs = effectiveApprovalTimeoutMs > 0
|
|
758
|
-
? effectiveApprovalTimeoutMs - (Number.isFinite(alreadyWaitedMs) ? Math.max(0, alreadyWaitedMs) : 0)
|
|
759
|
-
: 0;
|
|
760
|
-
const expiresAt = remainingTimeoutMs > 0
|
|
761
|
-
? new Date(Date.now() + remainingTimeoutMs).toISOString()
|
|
762
|
-
: undefined;
|
|
763
|
-
pendingApprovals.set(requestId, {
|
|
764
|
-
settle,
|
|
765
|
-
pending: {
|
|
766
|
-
requestId,
|
|
767
|
-
sessionId: request.session.id,
|
|
768
|
-
agentId: request.session.agent.id,
|
|
769
|
-
call: request.call,
|
|
770
|
-
risk: request.risk,
|
|
771
|
-
...(request.origin !== undefined ? { origin: request.origin } : {}),
|
|
772
|
-
...(oneShot ? { oneShot: true } : {}),
|
|
773
|
-
...(always !== undefined ? { always } : {}),
|
|
774
|
-
...(request.reviewNote !== undefined ? { reviewNote: request.reviewNote } : {}),
|
|
775
|
-
parkedAt: request.parkedAt ?? new Date().toISOString(),
|
|
776
|
-
...(expiresAt ? { expiresAt } : {}),
|
|
777
|
-
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
778
|
-
...(delegatedFrom ? { delegatedFrom } : {}),
|
|
779
|
-
...(scheduleDestination ? { scheduleDestination } : {}),
|
|
780
|
-
},
|
|
781
|
-
});
|
|
782
|
-
if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
|
|
783
|
-
// Nothing left to wait with. Settled here rather than armed with a
|
|
784
|
-
// zero timer, which `setTimeout` would still run a tick later — long
|
|
785
|
-
// enough to announce a request that is already over.
|
|
786
|
-
settle('deny', 'timeout');
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
if (remainingTimeoutMs > 0) {
|
|
790
|
-
// Deliberately NOT unref'd. A parked turn is real outstanding work,
|
|
791
|
-
// and its timer is the only thing that will ever finish it — letting
|
|
792
|
-
// the process exit out from under one would abandon the turn instead
|
|
793
|
-
// of denying it. Shutdown is handled where it belongs, in stop(),
|
|
794
|
-
// which settles every outstanding request before draining.
|
|
795
|
-
timer = setTimeout(() => settle('deny', 'timeout'), remainingTimeoutMs);
|
|
796
|
-
}
|
|
797
|
-
// The abort listener is attached before the request is announced, so a
|
|
798
|
-
// turn cancelled while the event is still being delivered still ends
|
|
799
|
-
// the request rather than leaving it pending forever.
|
|
800
|
-
if (request.signal) {
|
|
801
|
-
if (request.signal.aborted) {
|
|
802
|
-
settle('deny', 'cancelled');
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
806
|
-
}
|
|
807
|
-
// `announced` is assigned BEFORE the emission starts, because emit runs
|
|
808
|
-
// its first subscribers synchronously — one of them settling the
|
|
809
|
-
// request inline would otherwise chain onto the already-resolved
|
|
810
|
-
// placeholder and race the very announcement it is answering.
|
|
811
|
-
let markAnnounced = () => { };
|
|
812
|
-
announced = new Promise((resolveAnnounced) => {
|
|
813
|
-
markAnnounced = resolveAnnounced;
|
|
814
|
-
});
|
|
815
|
-
const announcing = bus.emit({
|
|
816
|
-
type: 'tool.approval-requested',
|
|
817
|
-
sessionId: request.session.id,
|
|
818
|
-
agentId: request.session.agent.id,
|
|
819
|
-
requestId,
|
|
820
|
-
call: request.call,
|
|
821
|
-
risk: request.risk,
|
|
822
|
-
...(request.origin !== undefined ? { origin: request.origin } : {}),
|
|
823
|
-
...(oneShot ? { oneShot: true } : {}),
|
|
824
|
-
...(always !== undefined ? { always } : {}),
|
|
825
|
-
...(request.reviewNote !== undefined ? { reviewNote: request.reviewNote } : {}),
|
|
826
|
-
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
827
|
-
...(delegatedFrom ? { delegatedFrom } : {}),
|
|
828
|
-
...(scheduleDestination ? { scheduleDestination } : {}),
|
|
829
|
-
...(expiresAt ? { expiresAt } : {}),
|
|
830
|
-
});
|
|
831
|
-
// Drained at shutdown alongside the resolutions: a channel that has not
|
|
832
|
-
// yet been told a request exists cannot be told it was denied either.
|
|
833
|
-
approvalEmissions.add(announcing);
|
|
834
|
-
void announcing.finally(() => {
|
|
835
|
-
approvalEmissions.delete(announcing);
|
|
836
|
-
markAnnounced();
|
|
837
|
-
});
|
|
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,
|
|
838
266
|
});
|
|
839
|
-
const resolveApproval = (input) => {
|
|
840
|
-
const parked = pendingApprovals.get(input.requestId);
|
|
841
|
-
if (!parked) {
|
|
842
|
-
return false;
|
|
843
|
-
}
|
|
844
|
-
parked.settle(input.answer, input.reason ?? 'decided', input.actor);
|
|
845
|
-
return true;
|
|
846
|
-
};
|
|
847
|
-
/**
|
|
848
|
-
* How a turn's watchdog stops its own clock, registered while it runs.
|
|
849
|
-
*
|
|
850
|
-
* Recording that a tool phase is open is not enough on its own: the
|
|
851
|
-
* timer is only re-evaluated when an event arrives, so a phase opened by
|
|
852
|
-
* something that emits nothing — an approval policy handed straight to
|
|
853
|
-
* the gateway — would leave an already-armed timer running to fire
|
|
854
|
-
* mid-wait.
|
|
855
|
-
*/
|
|
856
|
-
const suspendWatchdog = new Map();
|
|
857
|
-
/**
|
|
858
|
-
* Approval waits in progress, per session.
|
|
859
|
-
*
|
|
860
|
-
* Deliberately a balanced counter and not a set of call ids. An earlier
|
|
861
|
-
* shape had the approval wrapper *open* a phase that the watchdog's
|
|
862
|
-
* event observer was expected to close, and that handoff was wrong in
|
|
863
|
-
* every way it could be: the observer does not exist when no watchdog is
|
|
864
|
-
* armed, so nothing closed it; a policy that threw closed nothing; and a
|
|
865
|
-
* stale entry did not merely leak, it suppressed the watchdog for the
|
|
866
|
-
* next turn on that session. Four separate exits, each patched in turn.
|
|
867
|
-
*
|
|
868
|
-
* A counter incremented and decremented in one `finally` cannot have any
|
|
869
|
-
* of those bugs, because the thing that opens the phase is the thing
|
|
870
|
-
* that closes it, in the same scope, on every path out.
|
|
871
|
-
*/
|
|
872
|
-
const approvalWaits = new Map();
|
|
873
|
-
const holdApprovalWait = (sessionId) => {
|
|
874
|
-
approvalWaits.set(sessionId, (approvalWaits.get(sessionId) ?? 0) + 1);
|
|
875
|
-
// Stop any clock already running: this wait produces no events, so
|
|
876
|
-
// nothing else would.
|
|
877
|
-
suspendWatchdog.get(sessionId)?.();
|
|
878
|
-
let released = false;
|
|
879
|
-
return () => {
|
|
880
|
-
if (released) {
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
released = true;
|
|
884
|
-
const remaining = (approvalWaits.get(sessionId) ?? 1) - 1;
|
|
885
|
-
if (remaining > 0) {
|
|
886
|
-
approvalWaits.set(sessionId, remaining);
|
|
887
|
-
}
|
|
888
|
-
else {
|
|
889
|
-
approvalWaits.delete(sessionId);
|
|
890
|
-
}
|
|
891
|
-
};
|
|
892
|
-
};
|
|
893
267
|
const configuredApprovals = typeof options.approvals === 'function'
|
|
894
268
|
? options.approvals({
|
|
895
269
|
request: requestApproval,
|
|
@@ -1669,750 +1043,112 @@ export const createGateway = (options = {}) => {
|
|
|
1669
1043
|
return own !== undefined && own === source.to ? undefined : directMessageReadRefusal(`${source.channel}:${source.to}`);
|
|
1670
1044
|
},
|
|
1671
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
|
+
};
|
|
1672
1138
|
/**
|
|
1673
|
-
*
|
|
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.
|
|
1674
1146
|
*
|
|
1675
|
-
*
|
|
1676
|
-
*
|
|
1677
|
-
*
|
|
1678
|
-
*
|
|
1679
|
-
|
|
1680
|
-
const credentialRequests = new Map();
|
|
1681
|
-
tools.register(createCredentialRequestTool(async (request, session) => {
|
|
1682
|
-
const agentId = session.agent.id;
|
|
1683
|
-
const soulPath = sources.get(agentId)?.soulPath;
|
|
1684
|
-
if (soulPath === undefined) {
|
|
1685
|
-
throw new Error(`${session.agent.name} is the built-in agent and has no soul file to grant a credential in. `
|
|
1686
|
-
+ `Your operator can store one on the machine with \`stratus credential set ${request.name}\`.`);
|
|
1687
|
-
}
|
|
1688
|
-
if (!CREDENTIAL_NAME_PATTERN.test(request.name)) {
|
|
1689
|
-
throw new Error(`${JSON.stringify(request.name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
|
|
1690
|
-
+ 'starting with a letter, the way the tool that needs it spells it: search.apiKey, github.token.');
|
|
1691
|
-
}
|
|
1692
|
-
// The stricter rule, for a name the model picked: checked before anyone
|
|
1693
|
-
// is asked, so an approver never sees a form whose binding the store
|
|
1694
|
-
// would refuse, and so a variable that changes what a judged command
|
|
1695
|
-
// does (NODE_OPTIONS) is never offered at all.
|
|
1696
|
-
if (request.env !== undefined) {
|
|
1697
|
-
const problem = credentialVariableProblem(request.env, { requested: true });
|
|
1698
|
-
if (problem !== undefined) {
|
|
1699
|
-
throw new Error(`${problem} Nothing is pending.`);
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
// A key already stored is not something to ask a person for again: the
|
|
1703
|
-
// form only adds, so it could only fail. Say what is actually missing.
|
|
1704
|
-
const source = await namedCredentialSource(env, agentId, request.name);
|
|
1705
|
-
const stored = source !== undefined;
|
|
1706
|
-
const granted = session.agent.credentials?.includes(request.name) === true;
|
|
1707
|
-
// Binding a key the agent already holds asks for no value, and every
|
|
1708
|
-
// form today has a value field, so phase 1 leaves it to the machine.
|
|
1709
|
-
const bindOnMachine = request.env !== undefined
|
|
1710
|
-
? ` 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.`
|
|
1711
|
-
: '';
|
|
1712
|
-
if (request.replace === true) {
|
|
1713
|
-
// Checked here, before anyone is asked, so an approver is never shown
|
|
1714
|
-
// a form whose answer could only be refused.
|
|
1715
|
-
if (!stored || !granted) {
|
|
1716
|
-
throw new Error(`You don't hold ${request.name}, so there is nothing to replace. Ask for it without replace.`);
|
|
1717
|
-
}
|
|
1718
|
-
if (source === 'environment') {
|
|
1719
|
-
throw new Error(`${request.name} comes from the daemon's environment, which a form can't change. `
|
|
1720
|
-
+ 'Ask your operator to change it where that environment is set.');
|
|
1721
|
-
}
|
|
1722
|
-
if (source === 'shared' && request.scope !== 'shared') {
|
|
1723
|
-
throw new Error(`${request.name} is a shared credential, so replacing it changes the key for every agent granted it. `
|
|
1724
|
-
+ 'Ask again with scope "shared" if that is what you mean.');
|
|
1725
|
-
}
|
|
1726
|
-
if (source === 'agent' && request.scope === 'shared') {
|
|
1727
|
-
throw new Error(`${request.name} is your own credential, not a shared one. Ask again with scope "agent" to replace it.`);
|
|
1728
|
-
}
|
|
1729
|
-
if (request.via === 'link') {
|
|
1730
|
-
throw new Error('A replacement is asked for in a form an approver answers, never a link. Nothing is pending.');
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
else if (stored && granted) {
|
|
1734
|
-
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}`
|
|
1735
|
-
+ (request.env !== undefined ? ' Nothing is pending.' : ''));
|
|
1736
|
-
}
|
|
1737
|
-
else if (stored) {
|
|
1738
|
-
throw new Error(`${request.name} is already ${source === 'environment' ? "supplied by the daemon's environment" : 'stored'} but not granted to you. `
|
|
1739
|
-
+ `Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.${bindOnMachine}`);
|
|
1740
|
-
}
|
|
1741
|
-
const onMachine = request.env === undefined
|
|
1742
|
-
? `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``
|
|
1743
|
-
: request.scope === 'agent'
|
|
1744
|
-
? `\`stratus credential set ${request.name} --agent ${quoteShellArg(agentId)} --env ${request.env}\``
|
|
1745
|
-
: `\`stratus credential set ${request.name}\`, then \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\``;
|
|
1746
|
-
const record = {
|
|
1747
|
-
agentId,
|
|
1748
|
-
sessionId: session.id,
|
|
1749
|
-
name: request.name,
|
|
1750
|
-
scope: request.scope,
|
|
1751
|
-
soulPath,
|
|
1752
|
-
...(request.replace === true ? { replace: true } : {}),
|
|
1753
|
-
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1754
|
-
};
|
|
1755
|
-
const announce = async (requestId, via) => {
|
|
1756
|
-
await bus.emit({
|
|
1757
|
-
type: 'credential.requested',
|
|
1758
|
-
sessionId: session.id,
|
|
1759
|
-
agentId,
|
|
1760
|
-
requestId,
|
|
1761
|
-
name: request.name,
|
|
1762
|
-
scope: request.scope,
|
|
1763
|
-
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1764
|
-
via,
|
|
1765
|
-
...(request.replace === true ? { replace: true } : {}),
|
|
1766
|
-
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1767
|
-
...(session.metadata ? { metadata: session.metadata } : {}),
|
|
1768
|
-
});
|
|
1769
|
-
};
|
|
1770
|
-
// A link only for an agent whose soul lists the tool. Every agent may
|
|
1771
|
-
// ask through a form, because a form goes to an approver; a link is a
|
|
1772
|
-
// bearer credential the model is handed, and whoever it reaches can
|
|
1773
|
-
// store a key and amend the soul. That is the operator's to allow.
|
|
1774
|
-
const allowlist = session.agent.tools ?? registry.get(agentId)?.tools;
|
|
1775
|
-
// An omitted list grants the safe tools, this one among them, but a
|
|
1776
|
-
// link is the operator's to allow by naming it, so only a list says yes.
|
|
1777
|
-
// On a home an older build set up an omitted list is every tool, and
|
|
1778
|
-
// has always allowed a link there.
|
|
1779
|
-
const linkAllowed = allowlist === undefined
|
|
1780
|
-
? unlistedTools() === 'every'
|
|
1781
|
-
: matchesToolAllowlist(CREDENTIAL_REQUEST_TOOL_NAME, allowlist);
|
|
1782
|
-
// Why no form went up, said to the agent beside the link that replaces
|
|
1783
|
-
// it, so it can tell the person why they are getting a link instead.
|
|
1784
|
-
let formUnavailable;
|
|
1785
|
-
if (request.via !== 'link') {
|
|
1786
|
-
// Only a conversation a channel started has anywhere to show a form,
|
|
1787
|
-
// and only a channel that renders one can show it: a scheduled or
|
|
1788
|
-
// HTTP turn, or a channel with no form, would record a request nobody
|
|
1789
|
-
// sees while the agent tells its user the operator was asked.
|
|
1790
|
-
const kind = session.metadata?.channel;
|
|
1791
|
-
const { adapter } = typeof kind === 'string'
|
|
1792
|
-
? channelCarrying(kind, agentId, (candidate) => candidate.requestCredential !== undefined
|
|
1793
|
-
&& (request.replace !== true || candidate.credentialReplaceForms === true)
|
|
1794
|
-
// A form that can't say the key will be shell-readable must not
|
|
1795
|
-
// be the approval for it: a link says it instead.
|
|
1796
|
-
&& (request.env === undefined || candidate.credentialEnvForms === true))
|
|
1797
|
-
: { adapter: undefined };
|
|
1798
|
-
if (!adapter?.requestCredential || !session.metadata) {
|
|
1799
|
-
formUnavailable = typeof kind === 'string'
|
|
1800
|
-
? `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' : ''}.`
|
|
1801
|
-
: 'This conversation is not in a channel that can show your operator a form.';
|
|
1802
|
-
}
|
|
1803
|
-
else {
|
|
1804
|
-
const requestId = randomUUID();
|
|
1805
|
-
credentialRequests.set(requestId, { ...record, via: 'form' });
|
|
1806
|
-
// Delivered before it is announced, and dropped if delivery fails: a
|
|
1807
|
-
// request whose post never landed has no button to answer it, so
|
|
1808
|
-
// keeping it would leave it pending for good behind a tool result
|
|
1809
|
-
// saying the operator was asked.
|
|
1810
|
-
try {
|
|
1811
|
-
await adapter.requestCredential({
|
|
1812
|
-
sessionId: session.id,
|
|
1813
|
-
agentId,
|
|
1814
|
-
requestId,
|
|
1815
|
-
name: request.name,
|
|
1816
|
-
scope: request.scope,
|
|
1817
|
-
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1818
|
-
...(request.replace === true ? { replace: true } : {}),
|
|
1819
|
-
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1820
|
-
metadata: session.metadata,
|
|
1821
|
-
});
|
|
1822
|
-
await announce(requestId, 'form');
|
|
1823
|
-
return { requestId, via: 'form' };
|
|
1824
|
-
}
|
|
1825
|
-
catch (error) {
|
|
1826
|
-
credentialRequests.delete(requestId);
|
|
1827
|
-
formUnavailable = `The form could not be shown here: ${error instanceof Error ? error.message : String(error)}`;
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
if (request.replace === true) {
|
|
1831
|
-
// Never a link: whoever holds one could change a key in use.
|
|
1832
|
-
throw new Error(`${formUnavailable} Nothing is pending. A replacement needs a conversation that can show an approver a form.`);
|
|
1833
|
-
}
|
|
1834
|
-
if (request.via === 'form') {
|
|
1835
|
-
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.`);
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
if (!linkAllowed) {
|
|
1839
|
-
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. `
|
|
1840
|
-
+ `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.`);
|
|
1841
|
-
}
|
|
1842
|
-
// A link where no form can go, from whichever adapter issues them: the
|
|
1843
|
-
// control API, which is not tied to any one agent's transport.
|
|
1844
|
-
const linker = startedChannels.find((candidate) => candidate.requestCredentialLink !== undefined);
|
|
1845
|
-
if (!linker?.requestCredentialLink) {
|
|
1846
|
-
throw new Error(`${formUnavailable !== undefined ? `${formUnavailable} ` : ''}No control API is running to issue a link either. `
|
|
1847
|
-
+ `Ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
|
|
1848
|
-
}
|
|
1849
|
-
const requestId = randomUUID();
|
|
1850
|
-
credentialRequests.set(requestId, { ...record, via: 'link' });
|
|
1851
|
-
let link;
|
|
1852
|
-
try {
|
|
1853
|
-
link = await linker.requestCredentialLink({
|
|
1854
|
-
sessionId: session.id,
|
|
1855
|
-
agentId,
|
|
1856
|
-
agentName: session.agent.name,
|
|
1857
|
-
requestId,
|
|
1858
|
-
name: request.name,
|
|
1859
|
-
scope: request.scope,
|
|
1860
|
-
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1861
|
-
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1862
|
-
});
|
|
1863
|
-
}
|
|
1864
|
-
catch (error) {
|
|
1865
|
-
credentialRequests.delete(requestId);
|
|
1866
|
-
throw new Error(`A link for ${request.name} could not be issued: ${error instanceof Error ? error.message : String(error)} `
|
|
1867
|
-
+ `Nothing is pending. Ask your operator to store it on the machine with ${onMachine}.`);
|
|
1868
|
-
}
|
|
1869
|
-
await announce(requestId, 'link');
|
|
1870
|
-
return {
|
|
1871
|
-
requestId,
|
|
1872
|
-
via: 'link',
|
|
1873
|
-
url: link.url,
|
|
1874
|
-
expiresAt: link.expiresAt,
|
|
1875
|
-
...(link.localOnly === true ? { localOnly: true } : {}),
|
|
1876
|
-
...(formUnavailable !== undefined ? { formUnavailable } : {}),
|
|
1877
|
-
};
|
|
1878
|
-
}));
|
|
1879
|
-
/**
|
|
1880
|
-
* Requests an answer is being stored for now. Claimed before the first
|
|
1881
|
-
* await: two approvers submitting one form at once would otherwise both
|
|
1882
|
-
* start storing, and the second's add-only conflict would retire a
|
|
1883
|
-
* request the first was about to settle as added.
|
|
1884
|
-
*/
|
|
1885
|
-
const answering = new Set();
|
|
1886
|
-
const provideCredential = async (input) => {
|
|
1887
|
-
const request = credentialRequests.get(input.requestId);
|
|
1888
|
-
if (!request) {
|
|
1889
|
-
return { ok: false, retired: true, message: 'That credential request is no longer pending. Ask the agent to request it again.' };
|
|
1890
|
-
}
|
|
1891
|
-
if (answering.has(input.requestId)) {
|
|
1892
|
-
return { ok: false, message: 'Someone else\'s answer to this request is being stored right now. Wait a moment before trying again.' };
|
|
1893
|
-
}
|
|
1894
|
-
answering.add(input.requestId);
|
|
1895
|
-
try {
|
|
1896
|
-
return await answerCredentialRequest(input, request);
|
|
1897
|
-
}
|
|
1898
|
-
finally {
|
|
1899
|
-
answering.delete(input.requestId);
|
|
1900
|
-
}
|
|
1901
|
-
};
|
|
1902
|
-
/**
|
|
1903
|
-
* The audit line for a key changed from a chat or a link. After the
|
|
1904
|
-
* store, since it records what happened; a line that can't be written is
|
|
1905
|
-
* warned about rather than undoing a change that already landed.
|
|
1906
|
-
*/
|
|
1907
|
-
const auditCredentialChange = async (action, request, actor) => {
|
|
1908
|
-
try {
|
|
1909
|
-
await appendCredentialAudit(env, {
|
|
1910
|
-
action,
|
|
1911
|
-
name: request.name,
|
|
1912
|
-
...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
|
|
1913
|
-
...(actor !== undefined ? { actor } : {}),
|
|
1914
|
-
via: request.via ?? 'form',
|
|
1915
|
-
requestedBy: request.agentId,
|
|
1916
|
-
...(request.env !== undefined ? { env: request.env, bindingFor: request.agentId } : {}),
|
|
1917
|
-
});
|
|
1918
|
-
}
|
|
1919
|
-
catch (error) {
|
|
1920
|
-
warn(`credentials: ${request.name} was ${action === 'add' ? 'added' : 'replaced'}, but the audit line could not be written (${error instanceof Error ? error.message : String(error)}).`);
|
|
1921
|
-
}
|
|
1922
|
-
};
|
|
1923
|
-
const answerCredentialRequest = async (input, request) => {
|
|
1924
|
-
// The agent must still be served from the file the request captured: a
|
|
1925
|
-
// repointed default soul (same id, another file) replaces the source,
|
|
1926
|
-
// and a grant landing in the old file would report a key the agent now
|
|
1927
|
-
// running cannot use.
|
|
1928
|
-
const served = sources.get(request.agentId)?.soulPath;
|
|
1929
|
-
if (served !== request.soulPath) {
|
|
1930
|
-
credentialRequests.delete(input.requestId);
|
|
1931
|
-
return {
|
|
1932
|
-
ok: false,
|
|
1933
|
-
retired: true,
|
|
1934
|
-
message: `${request.agentId} is no longer served from ${request.soulPath}, so nothing was stored. Ask ${request.agentId} to request it again.`,
|
|
1935
|
-
};
|
|
1936
|
-
}
|
|
1937
|
-
// The soul the grant will land in must still be the requester's, and is
|
|
1938
|
-
// checked before anything is stored: a file reassigned to another agent
|
|
1939
|
-
// while the request waited would otherwise receive a key nobody meant
|
|
1940
|
-
// to give it. `grantSoulCredential` checks again as it writes.
|
|
1941
|
-
let owner;
|
|
1942
|
-
try {
|
|
1943
|
-
owner = (await loadSoulFile(request.soulPath)).agent.id;
|
|
1944
|
-
}
|
|
1945
|
-
catch (error) {
|
|
1946
|
-
return {
|
|
1947
|
-
ok: false,
|
|
1948
|
-
message: `${request.agentId}'s soul could not be read (${error instanceof Error ? error.message : String(error)}), so nothing was stored.`,
|
|
1949
|
-
};
|
|
1950
|
-
}
|
|
1951
|
-
if (owner !== request.agentId) {
|
|
1952
|
-
credentialRequests.delete(input.requestId);
|
|
1953
|
-
return {
|
|
1954
|
-
ok: false,
|
|
1955
|
-
retired: true,
|
|
1956
|
-
message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
|
|
1957
|
-
};
|
|
1958
|
-
}
|
|
1959
|
-
const value = formCredentialValue(input.value);
|
|
1960
|
-
if (isFlattenedPem(value)) {
|
|
1961
|
-
// Refused, not retired: the same form can still take the key whole.
|
|
1962
|
-
const command = request.scope === 'agent'
|
|
1963
|
-
? `stratus credential set ${request.name} --agent ${quoteShellArg(request.agentId)} < key.pem`
|
|
1964
|
-
: `stratus credential set ${request.name} < key.pem`;
|
|
1965
|
-
return {
|
|
1966
|
-
ok: false,
|
|
1967
|
-
message: 'That looks like a PEM key whose line breaks were lost, so nothing was stored: whatever reads it would fail. '
|
|
1968
|
-
+ `Paste it again from the original file with its line breaks, or store it on the machine: ${command}.`,
|
|
1969
|
-
};
|
|
1970
|
-
}
|
|
1971
|
-
const store = {
|
|
1972
|
-
...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
|
|
1973
|
-
// The binding the form promised, written with the value, always for
|
|
1974
|
-
// the agent that asked: consent is per agent even for a shared key.
|
|
1975
|
-
...(request.env !== undefined ? { bind: { agentId: request.agentId, variable: request.env } } : {}),
|
|
1976
|
-
};
|
|
1977
|
-
if (request.replace === true) {
|
|
1978
|
-
// Re-read under the soul lock, so a grant removed while the form
|
|
1979
|
-
// waited (a shared key most of all) is never rotated on its behalf.
|
|
1980
|
-
let stillGranted;
|
|
1981
|
-
try {
|
|
1982
|
-
stillGranted = await withSoulFileLock(async () => {
|
|
1983
|
-
const soul = await loadSoulFile(request.soulPath);
|
|
1984
|
-
if (soul.agent.id !== request.agentId || soul.agent.credentials?.includes(request.name) !== true) {
|
|
1985
|
-
return false;
|
|
1986
|
-
}
|
|
1987
|
-
await replaceNamedCredential(env, { name: request.name, value, ...store });
|
|
1988
|
-
return true;
|
|
1989
|
-
});
|
|
1990
|
-
}
|
|
1991
|
-
catch (error) {
|
|
1992
|
-
// Removed since the request was made: no value can land now.
|
|
1993
|
-
if (error instanceof NamedCredentialMissingError) {
|
|
1994
|
-
credentialRequests.delete(input.requestId);
|
|
1995
|
-
return { ok: false, retired: true, message: error.message };
|
|
1996
|
-
}
|
|
1997
|
-
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
1998
|
-
}
|
|
1999
|
-
if (!stillGranted) {
|
|
2000
|
-
credentialRequests.delete(input.requestId);
|
|
2001
|
-
return {
|
|
2002
|
-
ok: false,
|
|
2003
|
-
retired: true,
|
|
2004
|
-
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.`,
|
|
2005
|
-
};
|
|
2006
|
-
}
|
|
2007
|
-
credentialRequests.delete(input.requestId);
|
|
2008
|
-
await auditCredentialChange('replace', request, input.actor);
|
|
2009
|
-
await bus.emit({
|
|
2010
|
-
type: 'credential.provided',
|
|
2011
|
-
sessionId: request.sessionId,
|
|
2012
|
-
agentId: request.agentId,
|
|
2013
|
-
requestId: input.requestId,
|
|
2014
|
-
name: request.name,
|
|
2015
|
-
scope: request.scope,
|
|
2016
|
-
replace: true,
|
|
2017
|
-
...(request.env !== undefined ? { env: request.env } : {}),
|
|
2018
|
-
...(input.actor !== undefined ? { actor: input.actor } : {}),
|
|
2019
|
-
});
|
|
2020
|
-
// The soul already lists it: a replace is only asked for a key held.
|
|
2021
|
-
return { ok: true, name: request.name, scope: request.scope, agentId: request.agentId, granted: false };
|
|
2022
|
-
}
|
|
2023
|
-
try {
|
|
2024
|
-
await addNamedCredential(env, {
|
|
2025
|
-
name: request.name,
|
|
2026
|
-
value,
|
|
2027
|
-
...store,
|
|
2028
|
-
});
|
|
2029
|
-
}
|
|
2030
|
-
catch (error) {
|
|
2031
|
-
// A name stored since the request was made (another form for the
|
|
2032
|
-
// same shared key, or the machine) is final under add-only: no value
|
|
2033
|
-
// typed into this form can ever land, so the request is retired
|
|
2034
|
-
// rather than left answering every submission with the same refusal.
|
|
2035
|
-
if (error instanceof NamedCredentialExistsError) {
|
|
2036
|
-
credentialRequests.delete(input.requestId);
|
|
2037
|
-
return { ok: false, retired: true, message: error.message };
|
|
2038
|
-
}
|
|
2039
|
-
// Otherwise refused, not failed: the request stays pending, so a
|
|
2040
|
-
// person who typed an empty value can try again from the same form.
|
|
2041
|
-
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
2042
|
-
}
|
|
2043
|
-
credentialRequests.delete(input.requestId);
|
|
2044
|
-
await auditCredentialChange('add', request, input.actor);
|
|
2045
|
-
// Stored first, granted second: a key nobody may use is recoverable by
|
|
2046
|
-
// editing the soul, while a soul naming a key that was never stored
|
|
2047
|
-
// fails every call that reaches for it. Announced either way, because
|
|
2048
|
-
// the store already changed and a shared key is already usable.
|
|
2049
|
-
//
|
|
2050
|
-
// The served path is checked again under the soul lock, which roster
|
|
2051
|
-
// reloads take too: a default repointed while the key was being
|
|
2052
|
-
// written, or while the grant queued behind another soul edit, would
|
|
2053
|
-
// otherwise be granted in a file the running agent no longer reads.
|
|
2054
|
-
let granted = false;
|
|
2055
|
-
let grantError;
|
|
2056
|
-
try {
|
|
2057
|
-
granted = await grantSoulCredential(request.soulPath, request.name, request.agentId, {
|
|
2058
|
-
check: () => {
|
|
2059
|
-
const servedNow = sources.get(request.agentId)?.soulPath;
|
|
2060
|
-
if (servedNow !== request.soulPath) {
|
|
2061
|
-
throw new Error(`${request.agentId} is now served from ${servedNow ?? 'no soul file'}, not ${request.soulPath}`);
|
|
2062
|
-
}
|
|
2063
|
-
},
|
|
2064
|
-
});
|
|
2065
|
-
}
|
|
2066
|
-
catch (error) {
|
|
2067
|
-
grantError = error instanceof Error ? error.message : String(error);
|
|
2068
|
-
}
|
|
2069
|
-
await bus.emit({
|
|
2070
|
-
type: 'credential.provided',
|
|
2071
|
-
sessionId: request.sessionId,
|
|
2072
|
-
agentId: request.agentId,
|
|
2073
|
-
requestId: input.requestId,
|
|
2074
|
-
name: request.name,
|
|
2075
|
-
scope: request.scope,
|
|
2076
|
-
...(request.env !== undefined ? { env: request.env } : {}),
|
|
2077
|
-
...(input.actor !== undefined ? { actor: input.actor } : {}),
|
|
2078
|
-
...(grantError !== undefined ? { grantError } : {}),
|
|
2079
|
-
});
|
|
2080
|
-
if (grantError !== undefined) {
|
|
2081
|
-
return {
|
|
2082
|
-
ok: false,
|
|
2083
|
-
retired: true,
|
|
2084
|
-
message: `${request.name} was stored, but could not be added to ${request.soulPath} `
|
|
2085
|
-
+ `(${grantError}). Add it to the soul's credentials list by hand.`,
|
|
2086
|
-
};
|
|
2087
|
-
}
|
|
2088
|
-
return { ok: true, name: request.name, scope: request.scope, agentId: request.agentId, granted };
|
|
2089
|
-
};
|
|
2090
|
-
tools.register(createDelegateTool({
|
|
2091
|
-
registry,
|
|
2092
|
-
dispatch: (input) => dispatchInternal({
|
|
2093
|
-
sessionId: input.sessionId,
|
|
2094
|
-
agentId: input.agent.id,
|
|
2095
|
-
userMessage: input.userMessage,
|
|
2096
|
-
metadata: input.metadata,
|
|
2097
|
-
// A cancelled parent turn cancels its delegated runs too.
|
|
2098
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
2099
|
-
}),
|
|
2100
|
-
}));
|
|
2101
|
-
// ---- sub-agents (roadmap 24) -------------------------------------------
|
|
2102
|
-
/**
|
|
2103
|
-
* Running sub-agents per parent session, and who waits for a slot. A
|
|
2104
|
-
* parent session runs one turn at a time, so this is per turn.
|
|
2105
|
-
*/
|
|
2106
|
-
const maxSubAgentsPerTurn = options.subAgents?.maxConcurrentPerTurn ?? DEFAULT_SUB_AGENTS_PER_TURN;
|
|
2107
|
-
// NaN, 0, a fraction or Infinity would queue every sub-agent forever or
|
|
2108
|
-
// defeat the cap, so refuse it here instead of failing later in a turn.
|
|
2109
|
-
if (!Number.isInteger(maxSubAgentsPerTurn) || maxSubAgentsPerTurn < 1) {
|
|
2110
|
-
throw new Error(`subAgents.maxConcurrentPerTurn must be a whole number of at least 1 (got ${String(maxSubAgentsPerTurn)}).`);
|
|
2111
|
-
}
|
|
2112
|
-
const subAgentSlots = new Map();
|
|
2113
|
-
let subAgentCount = 0;
|
|
2114
|
-
/**
|
|
2115
|
-
* The session object each in-flight tool call was handed, with that
|
|
2116
|
-
* call's turn signal. `subAgents.run` accepts only a parent found here:
|
|
2117
|
-
* the session a running tool was given, by identity, never an id, so a
|
|
2118
|
-
* plugin cannot start sub-agents of another turn (another agent's
|
|
2119
|
-
* included) just because that turn is running. The signal is how the
|
|
2120
|
-
* parent's cancel reaches its sub-agents whether or not the plugin
|
|
2121
|
-
* passes one.
|
|
2122
|
-
*/
|
|
2123
|
-
const toolCallsInFlight = new Map();
|
|
2124
|
-
const bindToolCalls = (inner) => ({
|
|
2125
|
-
async execute(call, tool, session, context) {
|
|
2126
|
-
if (!idAtFirstCall.has(session)) {
|
|
2127
|
-
idAtFirstCall.set(session, session.id);
|
|
2128
|
-
}
|
|
2129
|
-
const entry = toolCallsInFlight.get(session) ?? [];
|
|
2130
|
-
toolCallsInFlight.set(session, entry);
|
|
2131
|
-
const signal = context?.signal;
|
|
2132
|
-
entry.push(signal);
|
|
2133
|
-
try {
|
|
2134
|
-
return await inner.execute(call, tool, session, context);
|
|
2135
|
-
}
|
|
2136
|
-
finally {
|
|
2137
|
-
entry.splice(entry.indexOf(signal), 1);
|
|
2138
|
-
if (entry.length === 0) {
|
|
2139
|
-
toolCallsInFlight.delete(session);
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
},
|
|
2143
|
-
});
|
|
2144
|
-
const subAgentSlot = (parentId, signal) => new Promise((resolve, reject) => {
|
|
2145
|
-
const entry = subAgentSlots.get(parentId) ?? { running: 0, waiting: [] };
|
|
2146
|
-
subAgentSlots.set(parentId, entry);
|
|
2147
|
-
const release = () => {
|
|
2148
|
-
entry.running -= 1;
|
|
2149
|
-
const next = entry.waiting.shift();
|
|
2150
|
-
if (next) {
|
|
2151
|
-
next();
|
|
2152
|
-
}
|
|
2153
|
-
else if (entry.running === 0) {
|
|
2154
|
-
subAgentSlots.delete(parentId);
|
|
2155
|
-
}
|
|
2156
|
-
};
|
|
2157
|
-
const take = () => {
|
|
2158
|
-
entry.running += 1;
|
|
2159
|
-
resolve(release);
|
|
2160
|
-
};
|
|
2161
|
-
if (entry.running < maxSubAgentsPerTurn) {
|
|
2162
|
-
take();
|
|
2163
|
-
return;
|
|
2164
|
-
}
|
|
2165
|
-
const onAbort = () => {
|
|
2166
|
-
const at = entry.waiting.indexOf(waiter);
|
|
2167
|
-
if (at >= 0) {
|
|
2168
|
-
entry.waiting.splice(at, 1);
|
|
2169
|
-
}
|
|
2170
|
-
reject(abortErrorFor(signal));
|
|
2171
|
-
};
|
|
2172
|
-
const waiter = () => {
|
|
2173
|
-
signal?.removeEventListener('abort', onAbort);
|
|
2174
|
-
take();
|
|
2175
|
-
};
|
|
2176
|
-
entry.waiting.push(waiter);
|
|
2177
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
2178
|
-
});
|
|
2179
|
-
/** Why this slice cannot be a sub-agent's, or undefined when it can. */
|
|
2180
|
-
const subAgentSliceProblem = (parent, requested) => {
|
|
2181
|
-
if (!Array.isArray(requested) || requested.some((name) => typeof name !== 'string' || name.length === 0)) {
|
|
2182
|
-
return 'A sub-agent\'s tools are a list of tool names.';
|
|
2183
|
-
}
|
|
2184
|
-
// Read the way the runner reads it for the parent's own calls: the
|
|
2185
|
-
// session's copy, else the registered agent's.
|
|
2186
|
-
const allowlist = parent.agent.tools ?? registry.get(parent.agent.id)?.tools;
|
|
2187
|
-
for (const name of requested) {
|
|
2188
|
-
if (/[*?]/.test(name)) {
|
|
2189
|
-
return `A sub-agent's tools are exact names, and ${name} is a pattern.`;
|
|
2190
|
-
}
|
|
2191
|
-
if (subAgentNeverGets(name)) {
|
|
2192
|
-
return `${name} is never given to a sub-agent, even by an agent that holds it.`;
|
|
2193
|
-
}
|
|
2194
|
-
const tool = tools.get(name);
|
|
2195
|
-
if (!tool) {
|
|
2196
|
-
return `There is no tool named ${name}.`;
|
|
2197
|
-
}
|
|
2198
|
-
// The parent's allowlist as its running turn holds it: a slice can
|
|
2199
|
-
// only narrow what that turn may already do. With no list, that is
|
|
2200
|
-
// the tools registered as safe (every tool on a home an older build
|
|
2201
|
-
// set up), and the slice is held to the same rule: naming a gated
|
|
2202
|
-
// tool must not hand it to the sub-agent.
|
|
2203
|
-
if (allowlist === undefined) {
|
|
2204
|
-
if (unlistedTools() !== 'every' && resolveToolRisk(tool) !== 'safe') {
|
|
2205
|
-
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}.`;
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
else if (!matchesToolAllowlist(name, allowlist)) {
|
|
2209
|
-
return `${name} is not one of ${parent.agent.id}'s tools, so a sub-agent of it cannot have it.`;
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
|
-
return undefined;
|
|
2213
|
-
};
|
|
2214
|
-
const subAgents = {
|
|
2215
|
-
async run(request) {
|
|
2216
|
-
// Copied before the first await, and only the copy is read from here
|
|
2217
|
-
// on: the plugin keeps its own objects and can change them while this
|
|
2218
|
-
// request is checked or waits for a slot, and a slice changed after
|
|
2219
|
-
// it was checked must not be the slice the sub-agent runs with.
|
|
2220
|
-
const handed = request?.parent;
|
|
2221
|
-
const callSignals = handed ? toolCallsInFlight.get(handed) : undefined;
|
|
2222
|
-
if (!callSignals) {
|
|
2223
|
-
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".');
|
|
2224
|
-
}
|
|
2225
|
-
// The parent turn's own stop, and the caller's as an extra one: a
|
|
2226
|
-
// plugin that passes no signal still has its sub-agents cancelled
|
|
2227
|
-
// with the turn.
|
|
2228
|
-
const stops = [...callSignals, request.signal].filter((signal) => signal !== undefined);
|
|
2229
|
-
const input = {
|
|
2230
|
-
parent: { id: hostIdOf(handed) },
|
|
2231
|
-
instruction: request?.instruction,
|
|
2232
|
-
tools: Array.isArray(request?.tools) ? [...request.tools] : request?.tools,
|
|
2233
|
-
model: request?.model,
|
|
2234
|
-
signal: stops.length === 0 ? undefined : stops.length === 1 ? stops[0] : AbortSignal.any(stops),
|
|
2235
|
-
};
|
|
2236
|
-
const parentId = typeof input.parent.id === 'string' ? input.parent.id : '';
|
|
2237
|
-
if (!parentId) {
|
|
2238
|
-
throw new Error('subAgents.run needs the session of the turn asking.');
|
|
2239
|
-
}
|
|
2240
|
-
const instruction = typeof input.instruction === 'string' ? input.instruction.trim() : '';
|
|
2241
|
-
if (!instruction) {
|
|
2242
|
-
throw new Error('subAgents.run needs an instruction.');
|
|
2243
|
-
}
|
|
2244
|
-
if (input.model !== undefined) {
|
|
2245
|
-
throw new Error('A sub-agent\'s model cannot be chosen yet: it runs on its parent\'s. Leave "model" out.');
|
|
2246
|
-
}
|
|
2247
|
-
const parent = await store.get(parentId);
|
|
2248
|
-
if (!parent) {
|
|
2249
|
-
throw new Error(`There is no session ${parentId} to start a sub-agent from.`);
|
|
2250
|
-
}
|
|
2251
|
-
if (isHostSubAgent(handed) || isSubAgentSession(parent)) {
|
|
2252
|
-
throw new Error('A sub-agent cannot start sub-agents.');
|
|
2253
|
-
}
|
|
2254
|
-
if (parent.status !== 'running') {
|
|
2255
|
-
throw new Error(`Session ${parentId} is not running a turn, and sub-agents start only from one.`);
|
|
2256
|
-
}
|
|
2257
|
-
const depth = typeof parent.metadata?.[DELEGATION_DEPTH_METADATA_KEY] === 'number'
|
|
2258
|
-
? parent.metadata[DELEGATION_DEPTH_METADATA_KEY]
|
|
2259
|
-
: 0;
|
|
2260
|
-
if (depth >= DEFAULT_MAX_DELEGATION_DEPTH) {
|
|
2261
|
-
throw new Error(`Delegation depth limit reached (${DEFAULT_MAX_DELEGATION_DEPTH}).`);
|
|
2262
|
-
}
|
|
2263
|
-
const problem = subAgentSliceProblem(parent, input.tools);
|
|
2264
|
-
if (problem) {
|
|
2265
|
-
throw new Error(problem);
|
|
2266
|
-
}
|
|
2267
|
-
if (input.signal?.aborted) {
|
|
2268
|
-
throw abortErrorFor(input.signal);
|
|
2269
|
-
}
|
|
2270
|
-
const release = await subAgentSlot(parent.id, input.signal);
|
|
2271
|
-
subAgentCount += 1;
|
|
2272
|
-
const sessionId = `${parent.id}${SUB_AGENT_SESSION_ID_MARKER}${depth + 1}:${subAgentCount}-${randomUUID().slice(0, 8)}`;
|
|
2273
|
-
// The parent agent, narrowed: its id, provider and credentials, the
|
|
2274
|
-
// slice for tools, and nothing else — no instructions (the persona),
|
|
2275
|
-
// no skills, no delegates.
|
|
2276
|
-
const definition = {
|
|
2277
|
-
id: parent.agent.id,
|
|
2278
|
-
name: parent.agent.name,
|
|
2279
|
-
tools: [...new Set(input.tools)],
|
|
2280
|
-
skills: [],
|
|
2281
|
-
delegates: [],
|
|
2282
|
-
...(parent.agent.credentials !== undefined ? { credentials: [...parent.agent.credentials] } : {}),
|
|
2283
|
-
};
|
|
2284
|
-
let session;
|
|
2285
|
-
liveSubAgentIds.add(sessionId);
|
|
2286
|
-
try {
|
|
2287
|
-
session = await onSessionChain(sessionId, () => dispatchInternal({
|
|
2288
|
-
sessionId,
|
|
2289
|
-
agentId: parent.agent.id,
|
|
2290
|
-
userMessage: instruction,
|
|
2291
|
-
metadata: {
|
|
2292
|
-
[SUB_AGENT_METADATA_KEY]: true,
|
|
2293
|
-
[DELEGATION_DEPTH_METADATA_KEY]: depth + 1,
|
|
2294
|
-
[DELEGATED_BY_METADATA_KEY]: parent.agent.id,
|
|
2295
|
-
[ROOT_SESSION_ID_METADATA_KEY]: typeof parent.metadata?.[ROOT_SESSION_ID_METADATA_KEY] === 'string'
|
|
2296
|
-
? parent.metadata[ROOT_SESSION_ID_METADATA_KEY]
|
|
2297
|
-
: parent.id,
|
|
2298
|
-
// The instruction is the parent's text, at the parent's label:
|
|
2299
|
-
// a tainted parent must not hand a stranger's words to a
|
|
2300
|
-
// session that has seen nothing.
|
|
2301
|
-
[SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(parent),
|
|
2302
|
-
// On the runtime serving the parent's turn: a parent that has
|
|
2303
|
-
// switched to its sticky fallback must not have its helpers
|
|
2304
|
-
// retry the primary it left.
|
|
2305
|
-
...(parent.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true ? { [FALLBACK_ACTIVE_METADATA_KEY]: true } : {}),
|
|
2306
|
-
},
|
|
2307
|
-
...(input.signal ? { signal: input.signal } : {}),
|
|
2308
|
-
}, [], definition));
|
|
2309
|
-
}
|
|
2310
|
-
catch (error) {
|
|
2311
|
-
if (input.signal?.aborted) {
|
|
2312
|
-
throw error;
|
|
2313
|
-
}
|
|
2314
|
-
// The error text may be the provider quoting what it refused,
|
|
2315
|
-
// which is this sub-agent's context: labelled as nobody can vouch.
|
|
2316
|
-
return { status: 'error', error: error instanceof Error ? error.message : String(error), sessionId, trust: 'unknown' };
|
|
2317
|
-
}
|
|
2318
|
-
finally {
|
|
2319
|
-
liveSubAgentIds.delete(sessionId);
|
|
2320
|
-
release();
|
|
2321
|
-
}
|
|
2322
|
-
const trust = sessionTrustOf(session);
|
|
2323
|
-
if (session.status === 'failed') {
|
|
2324
|
-
return { status: 'error', error: session.lastError ?? 'The sub-agent failed.', sessionId, trust: 'unknown' };
|
|
2325
|
-
}
|
|
2326
|
-
const reply = [...session.messages]
|
|
2327
|
-
.reverse()
|
|
2328
|
-
.find((message) => message.role === 'assistant' && message.content.trim().length > 0);
|
|
2329
|
-
const outcome = reply
|
|
2330
|
-
? { status: 'result', text: reply.content, sessionId, trust }
|
|
2331
|
-
: { status: 'empty', sessionId, trust };
|
|
2332
|
-
return outcome;
|
|
2333
|
-
},
|
|
2334
|
-
};
|
|
2335
|
-
const runners = new Map();
|
|
2336
|
-
// Every provider-construction input participates in the key — model,
|
|
2337
|
-
// endpoint, credentials, system prompt, fallback config. A rotated
|
|
2338
|
-
// credential therefore resolves to a NEW runner on the next dispatch
|
|
2339
|
-
// instead of silently reusing a provider built with the old secret.
|
|
2340
|
-
// (Hashed so raw secrets never sit in a map key; the soul is excluded
|
|
2341
|
-
// because provider construction ignores it.)
|
|
2342
|
-
const runnerKeyFor = (config) => {
|
|
2343
|
-
const { soul: _soul, soulPath: _soulPath, fetch: _fetch, ...providerInputs } = config;
|
|
2344
|
-
return createHash('sha256').update(JSON.stringify(providerInputs)).digest('hex');
|
|
2345
|
-
};
|
|
2346
|
-
// A sub-agent's runner has no memory store, so no turn of it injects or
|
|
2347
|
-
// reads the agent's memory; no skill catalog, so its prompt is its
|
|
2348
|
-
// instruction and its tools; and grants nothing to every agent, since
|
|
2349
|
-
// the credential request asks a person.
|
|
2350
|
-
const runnerFor = (config, agentId, subAgent = false) => {
|
|
2351
|
-
// The budget is a provider-construction input too (the harness
|
|
2352
|
-
// runtimes take it as their inner limit), so it is part of the key:
|
|
2353
|
-
// two agents on one model with different budgets get their own runners.
|
|
2354
|
-
const maxTurns = options.maxTurnsFor?.(agentId) ?? options.maxTurns;
|
|
2355
|
-
const autoContinue = options.autoContinueFor?.(agentId);
|
|
2356
|
-
const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}${subAgent ? ':sub' : ''}`;
|
|
2357
|
-
const existing = runners.get(key);
|
|
2358
|
-
if (existing) {
|
|
2359
|
-
return existing;
|
|
2360
|
-
}
|
|
2361
|
-
// The Claude Code runtime executes kernel tools by calling back into
|
|
2362
|
-
// the runner built just below — late-bound because the runner needs
|
|
2363
|
-
// the provider first.
|
|
2364
|
-
let hostedRunner;
|
|
2365
|
-
const provider = createRuntimeProvider(config, (error) => warn(`default model failed (${error instanceof Error ? error.message : String(error)}); using the fallback model`), async (session, call, context) => {
|
|
2366
|
-
if (!hostedRunner) {
|
|
2367
|
-
throw new Error('The Stratus runtime is not ready to execute tools yet.');
|
|
2368
|
-
}
|
|
2369
|
-
return hostedRunner.executeHostedToolCall(session, call, context);
|
|
2370
|
-
}, maxTurns,
|
|
2371
|
-
// The sticky-fallback switch is durable the moment it happens, not
|
|
2372
|
-
// when the turn's next save lands — a daemon killed mid-fallback
|
|
2373
|
-
// must not retry the primary on restart.
|
|
2374
|
-
(session) => store.save(session), providerContributions);
|
|
2375
|
-
const runner = new AgentRunner({
|
|
2376
|
-
provider,
|
|
2377
|
-
tools,
|
|
2378
|
-
// Runners are built on dispatch, after the plugins loaded and the
|
|
2379
|
-
// selection below ran, so a contributed executor is in place for
|
|
2380
|
-
// the first turn.
|
|
2381
|
-
executor: bindToolCalls(selectedExecutor ?? createLocalCommandExecutor()),
|
|
2382
|
-
...(approvals ? { approvals } : {}),
|
|
2383
|
-
store,
|
|
2384
|
-
bus,
|
|
2385
|
-
agents: registry,
|
|
2386
|
-
// Asking a person for a key is harmless to grant: they decide
|
|
2387
|
-
// everything after the question, so no soul has to list it.
|
|
2388
|
-
grantedToEveryAgent: subAgent ? [] : [CREDENTIAL_REQUEST_TOOL_NAME],
|
|
2389
|
-
unlistedTools: unlistedTools(),
|
|
2390
|
-
...(subAgent ? {} : { skills: skillCatalog, memory }),
|
|
2391
|
-
streaming: true,
|
|
2392
|
-
...(maxTurns !== undefined ? { maxTurns } : {}),
|
|
2393
|
-
...(autoContinue !== undefined
|
|
2394
|
-
? { autoContinue, waitingInput: (sessionId) => (waitingDispatches.get(sessionId) ?? 0) > 0 }
|
|
2395
|
-
: {}),
|
|
2396
|
-
providerCallGuards,
|
|
2397
|
-
});
|
|
2398
|
-
hostedRunner = runner;
|
|
2399
|
-
runners.set(key, runner);
|
|
2400
|
-
return runner;
|
|
2401
|
-
};
|
|
2402
|
-
/**
|
|
2403
|
-
* The runner that hears and never speaks. `observe` runs no turn, so it
|
|
2404
|
-
* needs no provider — and resolving one anyway would refuse an overhear
|
|
2405
|
-
* whenever the agent's runtime cannot currently be built: credentials
|
|
2406
|
-
* missing after a restart, a sign-in that lapsed. That is precisely the
|
|
2407
|
-
* window in which the agent cannot answer and the thread carries on
|
|
2408
|
-
* without it, so it is the window in which hearing matters most, and
|
|
2409
|
-
* what it heard is there once the configuration is repaired.
|
|
2410
|
-
*
|
|
2411
|
-
* A runner rather than a store write, because the label a speaker
|
|
2412
|
-
* lowers the session to is the runner's rule and there is one
|
|
2413
|
-
* implementation of it. Same store and bus as every speaking runner, so
|
|
2414
|
-
* a message it appends is the message the next turn reads, and its
|
|
2415
|
-
* event goes where every other event 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.
|
|
2416
1152
|
*/
|
|
2417
1153
|
const observer = new AgentRunner({
|
|
2418
1154
|
provider: {
|
|
@@ -2942,720 +1678,60 @@ export const createGateway = (options = {}) => {
|
|
|
2942
1678
|
throw error;
|
|
2943
1679
|
}
|
|
2944
1680
|
};
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
const executorRecord = selectedExecutor && options.executor !== undefined ? options.executor : 'local-command';
|
|
2999
|
-
const metadata = {
|
|
3000
|
-
provider: config.provider,
|
|
3001
|
-
// A contributed provider may leave the model to its own default, in
|
|
3002
|
-
// which case there is none to record.
|
|
3003
|
-
...(config.provider !== 'demo' && config.model !== undefined ? { model: config.model } : {}),
|
|
3004
|
-
...input.metadata,
|
|
3005
|
-
// After the caller's metadata, and reserved besides: a dispatch that
|
|
3006
|
-
// could write it could make a transcript claim a sandbox ran what
|
|
3007
|
-
// the host ran.
|
|
3008
|
-
[EXECUTOR_METADATA_KEY]: executorRecord,
|
|
3009
|
-
};
|
|
3010
|
-
if (existing && existing.agent.id !== agent.id) {
|
|
3011
|
-
throw crossIdentityError(input.sessionId, existing.agent.id, agent.id);
|
|
3012
|
-
}
|
|
3013
|
-
// A sub-agent's session is new, but may start on its parent's fallback.
|
|
3014
|
-
const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(existing ?? (subAgent !== undefined ? { metadata } : undefined), config);
|
|
3015
|
-
// Recorded with a keyed message so a restart judges the turn by what it
|
|
3016
|
-
// could have run on, not by whatever the config resolves to by then.
|
|
3017
|
-
// The fallback counts: a turn can switch to it mid-run. Once a session
|
|
3018
|
-
// has switched for good, the primary is never called again, and only
|
|
3019
|
-
// the fallback counts.
|
|
3020
|
-
const hostedLoop = input.idempotencyKey !== undefined && reachesHarness(config, switchedToFallback);
|
|
3021
|
-
return settleLeftForNextStart(input, onTurnRuntime(input.sessionId, { source, config }, () => withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
|
|
3022
|
-
// The preflight above (agent refresh, config resolution, session
|
|
3023
|
-
// load) awaited filesystem work; the entry check has long passed.
|
|
3024
|
-
// Recheck before the runner touches durable state, or a dispatch
|
|
3025
|
-
// cancelled mid-preflight would still persist its user message.
|
|
3026
|
-
if (signal.aborted) {
|
|
3027
|
-
throw abortErrorFor(signal);
|
|
3028
|
-
}
|
|
3029
|
-
if (existing) {
|
|
3030
|
-
// Refresh the stored definition before resuming, so the turn runs
|
|
3031
|
-
// with the current instructions, tools, and credentials.
|
|
3032
|
-
existing.agent = agent;
|
|
3033
|
-
// A sub-session reached here is being continued from outside — a
|
|
3034
|
-
// delegation never resumes its own child; its ids are minted once.
|
|
3035
|
-
// The delegating turn is over, so the markers come off: what is
|
|
3036
|
-
// now an ordinary conversation must not be closed as an orphan if
|
|
3037
|
-
// a later turn of it parks and the daemon dies.
|
|
3038
|
-
if (isDelegatedSession(existing) && existing.metadata) {
|
|
3039
|
-
existing.metadata = withoutDelegation(existing.metadata);
|
|
3040
|
-
}
|
|
3041
|
-
// The same for a sub-agent's session, continued as the agent itself:
|
|
3042
|
-
// the marker that denies its approvals unasked comes off with the
|
|
3043
|
-
// delegation markers it carries.
|
|
3044
|
-
if (isSubAgentSession(existing) && existing.metadata) {
|
|
3045
|
-
const { [SUB_AGENT_METADATA_KEY]: _subAgent, ...rest } = withoutDelegation(existing.metadata);
|
|
3046
|
-
existing.metadata = rest;
|
|
3047
|
-
}
|
|
3048
|
-
// The executor record follows the daemon, not the session's first
|
|
3049
|
-
// turn: a restart that selected another executor runs this turn's
|
|
3050
|
-
// commands there, and a session from before the record existed
|
|
3051
|
-
// gets one now rather than never.
|
|
3052
|
-
if (existing.metadata?.[EXECUTOR_METADATA_KEY] !== executorRecord) {
|
|
3053
|
-
existing.metadata = { ...existing.metadata, [EXECUTOR_METADATA_KEY]: executorRecord };
|
|
3054
|
-
}
|
|
3055
|
-
// The room rides on the turn, but two readers have only the
|
|
3056
|
-
// session's: a turn parked on approval and recovered after a
|
|
3057
|
-
// restart, and a turn that brings no room of its own (a schedule
|
|
3058
|
-
// firing into the thread). Both get the latest room this way, not
|
|
3059
|
-
// the first turn's, which a channel made public since would make
|
|
3060
|
-
// read as more private than it is. A turn from a channel that
|
|
3061
|
-
// could not tell the room (its lookup failed) clears it instead:
|
|
3062
|
-
// no room is the generic line, and an old one is a claim.
|
|
3063
|
-
if (typeof input.metadata?.channel === 'string') {
|
|
3064
|
-
const room = input.metadata[CONVERSATION_METADATA_KEY];
|
|
3065
|
-
const { [CONVERSATION_METADATA_KEY]: _stale, ...rest } = existing.metadata ?? {};
|
|
3066
|
-
existing.metadata = room !== undefined ? { ...rest, [CONVERSATION_METADATA_KEY]: room } : rest;
|
|
3067
|
-
}
|
|
3068
|
-
await store.save(existing);
|
|
3069
|
-
// The turn's metadata rides along for the sender's trust: without
|
|
3070
|
-
// it a resumed turn has no way to say who sent it, and a stranger
|
|
3071
|
-
// mentioning the agent in a thread the operator opened would
|
|
3072
|
-
// inherit the operator's label.
|
|
3073
|
-
return runner.resume({
|
|
3074
|
-
sessionId: input.sessionId,
|
|
3075
|
-
userMessage: input.userMessage,
|
|
3076
|
-
...(input.images !== undefined ? { images: input.images } : {}),
|
|
3077
|
-
...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
|
|
3078
|
-
...(input.replyOptional === true ? { replyOptional: true } : {}),
|
|
3079
|
-
...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
3080
|
-
...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
|
|
3081
|
-
...(hostedLoop ? { hostedLoop } : {}),
|
|
3082
|
-
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
3083
|
-
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
|
|
3084
|
-
...(steer !== undefined ? { steer: () => steer(signal) } : {}),
|
|
3085
|
-
signal,
|
|
3086
|
-
});
|
|
3087
|
-
}
|
|
3088
|
-
return runner.run({
|
|
3089
|
-
sessionId: input.sessionId,
|
|
3090
|
-
agent,
|
|
3091
|
-
userMessage: input.userMessage,
|
|
3092
|
-
...(input.images !== undefined ? { images: input.images } : {}),
|
|
3093
|
-
...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
|
|
3094
|
-
...(input.replyOptional === true ? { replyOptional: true } : {}),
|
|
3095
|
-
...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
3096
|
-
...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
|
|
3097
|
-
...(hostedLoop ? { hostedLoop } : {}),
|
|
3098
|
-
...(input.earlier !== undefined && input.earlier.length > 0 ? { earlier: input.earlier } : {}),
|
|
3099
|
-
metadata,
|
|
3100
|
-
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
|
|
3101
|
-
...(steer !== undefined ? { steer: () => steer(signal) } : {}),
|
|
3102
|
-
signal,
|
|
3103
|
-
});
|
|
3104
|
-
})));
|
|
3105
|
-
};
|
|
3106
|
-
/**
|
|
3107
|
-
* Finishes the turns that were parked on a human when this daemon last
|
|
3108
|
-
* went down.
|
|
3109
|
-
*
|
|
3110
|
-
* Run after the channels are up, deliberately: recovery re-asks, and a
|
|
3111
|
-
* request emitted into a gateway with no live adapter would be declined
|
|
3112
|
-
* for want of anywhere to put it — turning "your approval survived the
|
|
3113
|
-
* restart" into "your approval was denied by the restart".
|
|
3114
|
-
*
|
|
3115
|
-
* Each session is recovered independently and failures are per-session:
|
|
3116
|
-
* one unrecoverable turn must not stop the daemon from serving, or from
|
|
3117
|
-
* recovering the others.
|
|
3118
|
-
*/
|
|
3119
|
-
const recoverParkedTurns = async (orphaned) => {
|
|
3120
|
-
if (!store.listIdsByStatus) {
|
|
3121
|
-
return;
|
|
3122
|
-
}
|
|
3123
|
-
const parked = await store.listIdsByStatus('pending_approval');
|
|
3124
|
-
if (parked.length === 0) {
|
|
3125
|
-
return;
|
|
3126
|
-
}
|
|
3127
|
-
log(`recovering ${parked.length} turn(s) parked on approval`);
|
|
3128
|
-
// Started together, not one after another. Each recovery re-asks and
|
|
3129
|
-
// then waits on a human, so awaiting them in sequence would mean the
|
|
3130
|
-
// second parked turn is not even *announced* until the first is
|
|
3131
|
-
// answered — fifteen minutes later by default, and never at all with a
|
|
3132
|
-
// zero timeout. They are independent turns; only same-session work is
|
|
3133
|
-
// ordered, and onSessionChain already guarantees that.
|
|
3134
|
-
await Promise.allSettled(parked.map((sessionId) => recoverOne(sessionId, orphaned.has(sessionId))));
|
|
3135
|
-
};
|
|
3136
|
-
/**
|
|
3137
|
-
* The parked sub-sessions whose delegating turn the last process was
|
|
3138
|
-
* still inside — read BEFORE the channels start, for the same reason the
|
|
3139
|
-
* abandoned list is.
|
|
3140
|
-
*
|
|
3141
|
-
* The proof is the parent's transcript: only the runner writes an
|
|
3142
|
-
* assistant `agent.delegate` call, and only a delegation in flight leaves
|
|
3143
|
-
* one with no result behind it. That evidence does not survive contact
|
|
3144
|
-
* with a channel, though — a message for the parent arriving once the
|
|
3145
|
-
* adapters are up resumes it, and the resume closes the dangling call as
|
|
3146
|
-
* interrupted. Read live from the sweep, the parent would then look as if
|
|
3147
|
-
* it awaited nothing and the child would be re-asked after all, which is
|
|
3148
|
-
* the bug this exists to stop. So it is read while nothing can dispatch,
|
|
3149
|
-
* and the sweep judges from the snapshot; a child whose parent is missing
|
|
3150
|
-
* or not inside such a call is left to ordinary recovery.
|
|
3151
|
-
*
|
|
3152
|
-
* Matched call by call, not parent by parent: the call that awaits a
|
|
3153
|
-
* child names it (see `outstandingDelegationFor`), and a call can await
|
|
3154
|
-
* only one. Two parked children claiming the same call — one of them a
|
|
3155
|
-
* sub-session continued from outside under a version that kept its
|
|
3156
|
-
* markers, parked again on something of its own — is an ambiguity this
|
|
3157
|
-
* cannot settle, and both are left to ordinary recovery rather than one
|
|
3158
|
-
* of them being failed for a delegation that was never its.
|
|
3159
|
-
*/
|
|
3160
|
-
const listOrphanedDelegations = async () => {
|
|
3161
|
-
const claimants = new Map();
|
|
3162
|
-
if (!store.listIdsByStatus) {
|
|
3163
|
-
return new Set();
|
|
3164
|
-
}
|
|
3165
|
-
for (const sessionId of await store.listIdsByStatus('pending_approval')) {
|
|
3166
|
-
const parentId = delegatingSessionIdOf(sessionId);
|
|
3167
|
-
if (parentId === undefined) {
|
|
3168
|
-
continue;
|
|
3169
|
-
}
|
|
3170
|
-
const session = await store.get(sessionId);
|
|
3171
|
-
if (!session || !isDelegatedSession(session)) {
|
|
3172
|
-
continue;
|
|
3173
|
-
}
|
|
3174
|
-
const parent = await store.get(parentId);
|
|
3175
|
-
const callId = parent ? outstandingDelegationFor(parent, session) : undefined;
|
|
3176
|
-
if (callId !== undefined) {
|
|
3177
|
-
const key = `${parentId}\u0000${callId}`;
|
|
3178
|
-
claimants.set(key, [...(claimants.get(key) ?? []), sessionId]);
|
|
3179
|
-
}
|
|
3180
|
-
}
|
|
3181
|
-
return new Set([...claimants.values()].filter((ids) => ids.length === 1).map(([id]) => id));
|
|
3182
|
-
};
|
|
3183
|
-
const recoverOne = (sessionId, orphaned) => onSessionChain(sessionId, () => recoverParked(sessionId, orphaned));
|
|
3184
|
-
/**
|
|
3185
|
-
* `recoverOne`'s work, for a caller already on the session's chain: a
|
|
3186
|
-
* repeated idempotency key that reached the chain before the parked sweep
|
|
3187
|
-
* did, and would otherwise wait on a turn nothing is running yet.
|
|
3188
|
-
*/
|
|
3189
|
-
const recoverParked = async (sessionId, orphaned) => {
|
|
3190
|
-
if (stopping) {
|
|
3191
|
-
return;
|
|
3192
|
-
}
|
|
3193
|
-
try {
|
|
3194
|
-
const session = await store.get(sessionId);
|
|
3195
|
-
if (!session) {
|
|
3196
|
-
return;
|
|
3197
|
-
}
|
|
3198
|
-
const record = readPendingApproval(session);
|
|
3199
|
-
// A delegated sub-session is only ever awaited by its parent's
|
|
3200
|
-
// `agent.delegate` call, and that parent was running when the last
|
|
3201
|
-
// process died — it is in the abandoned sweep, not in this one.
|
|
3202
|
-
// Nothing will read the reply, so the parked call is not re-asked:
|
|
3203
|
-
// a person would be approving a command that runs for no one.
|
|
3204
|
-
//
|
|
3205
|
-
// Judged on the re-read, on the chain: the channels are up before
|
|
3206
|
-
// this sweep starts, so a message for this id can have taken the
|
|
3207
|
-
// chain first and moved the session on — and a turn that finished
|
|
3208
|
-
// normally must not be rewritten as failed. The delegation metadata
|
|
3209
|
-
// is durable, so it proves what the session is, not what state it
|
|
3210
|
-
// is in; the status and the checkpoint say that.
|
|
3211
|
-
//
|
|
3212
|
-
// Whether it IS an orphan was decided before the channels started
|
|
3213
|
-
// (`listOrphanedDelegations`), from the parent's transcript, which
|
|
3214
|
-
// a message arriving since may already have rewritten.
|
|
3215
|
-
// A sub-agent never parks (nobody is asked for it), and if one ever
|
|
3216
|
-
// did, its parent's turn died with the last process: nothing would
|
|
3217
|
-
// read its answer, and recovering it would run it as the full agent.
|
|
3218
|
-
if (isSubAgentSession(session) && session.status === 'pending_approval') {
|
|
3219
|
-
log(`${sessionId}: a sub-agent parked when the last stratusd stopped; failing it`);
|
|
3220
|
-
await failParked(session, ABANDONED_TURN_ERROR);
|
|
3221
|
-
return;
|
|
3222
|
-
}
|
|
3223
|
-
if (orphaned && isDelegatedSession(session) && session.status === 'pending_approval' && record) {
|
|
3224
|
-
log(`${sessionId}: parked on ${record.call.toolName} for a delegating turn the last stratusd was still running; failing it`);
|
|
3225
|
-
await failOrphanedDelegation(session);
|
|
3226
|
-
return;
|
|
3227
|
-
}
|
|
3228
|
-
// A wait that outlived its window while the daemon was down is
|
|
3229
|
-
// honoured, not restarted: the request really did go unanswered for
|
|
3230
|
-
// the whole time it was configured to wait, and downtime is not a
|
|
3231
|
-
// reason to extend a security decision. Measured from when the turn
|
|
3232
|
-
// parked against this daemon's timeout — the transport's original
|
|
3233
|
-
// deadline was chosen after the checkpoint was written and is gone
|
|
3234
|
-
// with the process that chose it. Denying goes through the same
|
|
3235
|
-
// recovery path, so the queue behind it still drains.
|
|
3236
|
-
const parkedAt = record ? Date.parse(record.parkedAt) : Number.NaN;
|
|
3237
|
-
const expired = effectiveApprovalTimeoutMs > 0
|
|
3238
|
-
&& Number.isFinite(parkedAt)
|
|
3239
|
-
&& Date.now() - parkedAt >= effectiveApprovalTimeoutMs;
|
|
3240
|
-
if (expired) {
|
|
3241
|
-
log(`${sessionId}: the approval for ${record?.call.toolName} outlived its window while the daemon was down; denying it`);
|
|
3242
|
-
}
|
|
3243
|
-
// Resolved the way a dispatch for this agent would resolve it, so a
|
|
3244
|
-
// recovered turn finishes on the same provider, model, and
|
|
3245
|
-
// credentials it was parked on.
|
|
3246
|
-
const source = await refreshAgent(session.agent.id);
|
|
3247
|
-
// And on the CURRENT definition, saved before recovery reads the
|
|
3248
|
-
// session back. An allowlist is a permission boundary: a soul that
|
|
3249
|
-
// dropped a tool while the daemon was down must not have that tool
|
|
3250
|
-
// executed by the turn that outlived the change, and
|
|
3251
|
-
// `allowedToolsFor` reads the definition frozen into the session
|
|
3252
|
-
// ahead of the registry. Dispatch refreshes it the same way before
|
|
3253
|
-
// resuming.
|
|
3254
|
-
session.agent = source.definition;
|
|
3255
|
-
await store.save(session);
|
|
3256
|
-
const recoveredConfig = await runtimeForAgent(source);
|
|
3257
|
-
const runner = runnerFor(recoveredConfig, source.definition.id);
|
|
3258
|
-
// Tracked like a dispatched turn's controller: a recovered turn that
|
|
3259
|
-
// runs on past its approval is a turn like any other, so a restart's
|
|
3260
|
-
// window has to be able to cut it short with the same reason (or it
|
|
3261
|
-
// runs unseen to the second window and the restart reports
|
|
3262
|
-
// undrained), and a stop for the session has to find it. Only once
|
|
3263
|
-
// the checkpoint has been read on this chain, right before the runner
|
|
3264
|
-
// takes it: a stop counted then always ends in the turn's own
|
|
3265
|
-
// outcome. A stop sent during the reads above finds nothing running
|
|
3266
|
-
// and reaches the session as an ordinary message, queued behind it.
|
|
3267
|
-
const controller = new AbortController();
|
|
3268
|
-
if (record) {
|
|
3269
|
-
trackTurn(sessionId, controller);
|
|
3270
|
-
if (abortingWith !== undefined) {
|
|
3271
|
-
controller.abort(shutdownAbort());
|
|
3272
|
-
}
|
|
3273
|
-
}
|
|
3274
|
-
try {
|
|
3275
|
-
await onTurnRuntime(sessionId, { source, config: recoveredConfig }, () => runner.recoverPendingApproval(sessionId, {
|
|
3276
|
-
denyPending: expired,
|
|
3277
|
-
runtime: runtimeContextFor(source, recoveredConfig, session.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true, session.metadata),
|
|
3278
|
-
signal: controller.signal,
|
|
3279
|
-
}));
|
|
3280
|
-
}
|
|
3281
|
-
finally {
|
|
3282
|
-
untrackTurn(sessionId, controller);
|
|
3283
|
-
// A recovered firing's row outlived the process that would have
|
|
3284
|
-
// retired it, and the firing is over either way — a recovery that
|
|
3285
|
-
// failed has durably failed its session, which is as finished as
|
|
3286
|
-
// completing. The scheduler decides whether there is a row to
|
|
3287
|
-
// retire, and only this firing's own.
|
|
3288
|
-
const scheduleId = session.metadata?.[SCHEDULE_ID_METADATA_KEY];
|
|
3289
|
-
if (typeof scheduleId === 'string') {
|
|
3290
|
-
scheduler.retireSpentOneShot(scheduleId, sessionId);
|
|
3291
|
-
}
|
|
3292
|
-
}
|
|
3293
|
-
}
|
|
3294
|
-
catch (error) {
|
|
3295
|
-
warn(`could not recover parked session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3296
|
-
// A keyed item is a promise to its caller, and one whose recovery
|
|
3297
|
-
// cannot even start (its agent gone, its provider unresolvable) would
|
|
3298
|
-
// otherwise stay parked for every later start and redelivery to try
|
|
3299
|
-
// again. An unkeyed one keeps waiting for a sweep that can, as before.
|
|
3300
|
-
const after = await store.get(sessionId).catch(() => undefined);
|
|
3301
|
-
const key = after === undefined ? undefined : turnKeyOf(turnMessageOf(after));
|
|
3302
|
-
if (after !== undefined && key !== undefined && after.status === 'pending_approval' && workItemState(after, key) === 'unfinished') {
|
|
3303
|
-
await failParked(after, ABANDONED_TURN_ERROR).catch(() => { });
|
|
3304
|
-
}
|
|
3305
|
-
}
|
|
3306
|
-
};
|
|
3307
|
-
/**
|
|
3308
|
-
* Closes an orphaned sub-session the way the abandoned sweep closes its
|
|
3309
|
-
* parent: durably failed, with the checkpoint retired so no later sweep
|
|
3310
|
-
* can find a call to re-enter, and announced where surfaces can hear it.
|
|
3311
|
-
*/
|
|
3312
|
-
const failOrphanedDelegation = (session) => failParked(session, ORPHANED_DELEGATION_ERROR);
|
|
3313
|
-
const failParked = async (session, error) => {
|
|
3314
|
-
const metadata = { ...(session.metadata ?? {}) };
|
|
3315
|
-
delete metadata[PENDING_APPROVAL_METADATA_KEY];
|
|
3316
|
-
session.metadata = metadata;
|
|
3317
|
-
recordTurnFailure(session, error);
|
|
3318
|
-
await store.save(session);
|
|
3319
|
-
await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
|
|
3320
|
-
await bus.emit({ type: 'session.failed', sessionId: session.id, error });
|
|
3321
|
-
};
|
|
3322
|
-
/**
|
|
3323
|
-
* Turns the last process was still running when it stopped being able to
|
|
3324
|
-
* finish them.
|
|
3325
|
-
*
|
|
3326
|
-
* `pending_approval` is the one window a turn can be picked up from, and
|
|
3327
|
-
* it has its own sweep above. Everything else a turn does is saved as
|
|
3328
|
-
* `running`: the provider call, the executor, and — on a provider that
|
|
3329
|
-
* hosts its own loop — the approval wait too, since a hosted call is
|
|
3330
|
-
* deliberately not checkpointed (`executeHostedToolCall` passes
|
|
3331
|
-
* `recoverable: false`, because the SDK's inner loop is not something a
|
|
3332
|
-
* restart can rebuild). None of those can be re-entered where they
|
|
3333
|
-
* stopped, and nothing else sweeps them, so the record would go on
|
|
3334
|
-
* claiming the turn is running for as long as the session exists. A turn
|
|
3335
|
-
* whose caller gave it an idempotency key is continued from its
|
|
3336
|
-
* transcript instead, with its dangling calls closed as interrupted
|
|
3337
|
-
* (`settleAbandonedTurn`); the rest are failed.
|
|
3338
|
-
*
|
|
3339
|
-
* Note what this is *not* for. A graceful stop denies everything parked
|
|
3340
|
-
* and drains the turns those denials release, so an operator restarting
|
|
3341
|
-
* the daemon normally has nothing here. These are the turns a crash, a
|
|
3342
|
-
* SIGKILL, or a lost machine left behind.
|
|
3343
|
-
*/
|
|
3344
|
-
const listAbandonedTurns = async () => {
|
|
3345
|
-
if (!store.listIdsByStatus) {
|
|
3346
|
-
return [];
|
|
3347
|
-
}
|
|
3348
|
-
return store.listIdsByStatus('running');
|
|
3349
|
-
};
|
|
3350
|
-
/**
|
|
3351
|
-
* Settles each of them — continued if keyed, otherwise marked failed with
|
|
3352
|
-
* a reason that says what happened.
|
|
3353
|
-
*
|
|
3354
|
-
* Read BEFORE the channels start and applied after, which is the only
|
|
3355
|
-
* ordering that is both honest and safe. Reading first means the list is
|
|
3356
|
-
* exactly what the last process left, with no chance of a turn this
|
|
3357
|
-
* process started being in it. Applying after means the failure is
|
|
3358
|
-
* emitted into a gateway whose surfaces are listening, rather than into
|
|
3359
|
-
* one where nothing is.
|
|
3360
|
-
*
|
|
3361
|
-
* The gap between the two is real, though: an inbound message for one of
|
|
3362
|
-
* these sessions can arrive as soon as the channels are up. What makes
|
|
3363
|
-
* that safe is single-flight — every writer of a session goes through
|
|
3364
|
-
* `onSessionChain`, so this runs either wholly before that turn (it
|
|
3365
|
-
* fails, and `resume()` then clears `lastError` and starts fresh) or
|
|
3366
|
-
* wholly after it (the status has moved off `running`, and the re-read
|
|
3367
|
-
* below leaves it alone). There is no interleaving in which this
|
|
3368
|
-
* overwrites a live turn.
|
|
3369
|
-
*
|
|
3370
|
-
* What that invariant does NOT cover is a second daemon on the same
|
|
3371
|
-
* database, whose turns are running with no chain of ours in front of
|
|
3372
|
-
* them. Two processes sharing a session store is already unsupported —
|
|
3373
|
-
* the parked sweep above would re-ask that daemon's approvals for the
|
|
3374
|
-
* same reason — and no check here would make it supported.
|
|
3375
|
-
*/
|
|
3376
|
-
const failAbandonedTurns = async (abandoned) => {
|
|
3377
|
-
if (abandoned.length === 0) {
|
|
3378
|
-
return;
|
|
3379
|
-
}
|
|
3380
|
-
log(`settling ${abandoned.length} turn(s) left running by the last stratusd`);
|
|
3381
|
-
await Promise.allSettled(abandoned.map((id) => failAbandonedTurn(id)));
|
|
3382
|
-
};
|
|
3383
|
-
const failAbandonedTurn = (id) => onSessionChain(id, async () => {
|
|
3384
|
-
if (stopping) {
|
|
3385
|
-
return;
|
|
3386
|
-
}
|
|
3387
|
-
try {
|
|
3388
|
-
const session = await store.get(id);
|
|
3389
|
-
// Re-read on the chain, because the snapshot is from before the
|
|
3390
|
-
// channels came up: a message that arrived since has already taken
|
|
3391
|
-
// this session somewhere else, and that turn owns it now.
|
|
3392
|
-
if (!session || session.status !== 'running') {
|
|
3393
|
-
return;
|
|
3394
|
-
}
|
|
3395
|
-
await settleAbandonedTurn(session);
|
|
3396
|
-
}
|
|
3397
|
-
catch (error) {
|
|
3398
|
-
warn(`could not settle abandoned turn ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3399
|
-
}
|
|
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,
|
|
3400
1734
|
});
|
|
3401
|
-
/**
|
|
3402
|
-
* A turn left `running` with nothing running it, read on its chain.
|
|
3403
|
-
*
|
|
3404
|
-
* One whose message carries an idempotency key is a work item its caller
|
|
3405
|
-
* was promised would finish, so it is continued from the transcript
|
|
3406
|
-
* (`AgentRunner.continueTurn`) — once. A turn that already was, and is
|
|
3407
|
-
* here again, took the last process down with it, and continuing it again
|
|
3408
|
-
* would do the same on every start; it is failed like an unkeyed one,
|
|
3409
|
-
* whose caller was never promised anything and is told to send again.
|
|
3410
|
-
*/
|
|
3411
|
-
const settleAbandonedTurn = async (session) => {
|
|
3412
|
-
const message = turnMessageOf(session);
|
|
3413
|
-
if (message === undefined || turnKeyOf(message) === undefined) {
|
|
3414
|
-
return failAbandoned(session);
|
|
3415
|
-
}
|
|
3416
|
-
// An answer already in the transcript is completed whatever came before
|
|
3417
|
-
// — a second crash, a harness, an agent or provider gone since —
|
|
3418
|
-
// because completing it needs none of them and runs nothing again;
|
|
3419
|
-
// failing it would throw away a finished reply.
|
|
3420
|
-
if (lastResponseIsAnswer(session)) {
|
|
3421
|
-
log(`${session.id}: a keyed turn the last stratusd was running had saved its answer; completing it`);
|
|
3422
|
-
return completeAnsweredTurn(session, store, bus);
|
|
3423
|
-
}
|
|
3424
|
-
if (message.continuedAfterCrash === true) {
|
|
3425
|
-
return failAbandoned(session);
|
|
3426
|
-
}
|
|
3427
|
-
if (message.hostedLoop === true) {
|
|
3428
|
-
log(`${session.id}: a keyed turn the last stratusd was running could reach a harness, which cannot be continued; failing it`);
|
|
3429
|
-
return failAbandoned(session);
|
|
3430
|
-
}
|
|
3431
|
-
return continueAbandonedTurn(session);
|
|
3432
|
-
};
|
|
3433
|
-
const failAbandoned = async (session) => {
|
|
3434
|
-
recordTurnFailure(session, ABANDONED_TURN_ERROR);
|
|
3435
|
-
await store.save(session);
|
|
3436
|
-
await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
|
|
3437
|
-
await bus.emit({ type: 'session.failed', sessionId: session.id, error: ABANDONED_TURN_ERROR });
|
|
3438
|
-
return session;
|
|
3439
|
-
};
|
|
3440
|
-
/**
|
|
3441
|
-
* Continues an abandoned turn on the configuration a dispatch for its
|
|
3442
|
-
* agent would resolve now, and under the same watchdog — it is a provider
|
|
3443
|
-
* call like any turn's. The definition is refreshed and saved first, as
|
|
3444
|
-
* `recoverOne` does and for the same reason: an allowlist is a permission
|
|
3445
|
-
* boundary, and a tool dropped while the daemon was down must not run.
|
|
3446
|
-
*
|
|
3447
|
-
* Not on a harness (`runsOnHarness`) — neither one the turn could have
|
|
3448
|
-
* run on (`Message.hostedLoop`, recorded when it was accepted) nor one it
|
|
3449
|
-
* would continue on. A harness runs its own tool loop inside a single
|
|
3450
|
-
* provider call and keeps its own conversation, so the prompt the dead
|
|
3451
|
-
* turn sent may already have run tools there that the transcript never
|
|
3452
|
-
* saw, and continuing would send it again. That turn is failed as an
|
|
3453
|
-
* unkeyed one is — the same answer as before keys existed. (A turn whose
|
|
3454
|
-
* answer is already saved never gets here: `settleAbandonedTurn`
|
|
3455
|
-
* completes it first.)
|
|
3456
|
-
*/
|
|
3457
|
-
const continueAbandonedTurn = async (session) => {
|
|
3458
|
-
// Its parent's turn died with the last process, and continuing it here
|
|
3459
|
-
// would run it as the full agent. (Sub-agent turns carry no key today,
|
|
3460
|
-
// so this is a guard, not a path.)
|
|
3461
|
-
if (isSubAgentSession(session)) {
|
|
3462
|
-
return failAbandoned(session);
|
|
3463
|
-
}
|
|
3464
|
-
// Settled either way: a turn whose agent left the roster, or whose
|
|
3465
|
-
// provider no longer resolves, must not stay `running` for every later
|
|
3466
|
-
// start and every redelivery to find unfinished and trip over again.
|
|
3467
|
-
let source;
|
|
3468
|
-
let config;
|
|
3469
|
-
try {
|
|
3470
|
-
source = await refreshAgent(session.agent.id);
|
|
3471
|
-
config = await runtimeForAgent(source);
|
|
3472
|
-
}
|
|
3473
|
-
catch (error) {
|
|
3474
|
-
log(`${session.id}: a keyed turn the last stratusd was running cannot be continued (${error instanceof Error ? error.message : String(error)}); failing it`);
|
|
3475
|
-
return failAbandoned(session);
|
|
3476
|
-
}
|
|
3477
|
-
const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(session, config);
|
|
3478
|
-
// The message's own record covers the runtimes the turn could have run
|
|
3479
|
-
// on; this covers the ones it would continue on — a fallback included —
|
|
3480
|
-
// which a changed config may make a harness.
|
|
3481
|
-
if (reachesHarness(config, switchedToFallback)) {
|
|
3482
|
-
log(`${session.id}: a keyed turn the last stratusd was running would continue on a harness; failing it`);
|
|
3483
|
-
return failAbandoned(session);
|
|
3484
|
-
}
|
|
3485
|
-
log(`${session.id}: continuing a keyed turn the last stratusd was still running`);
|
|
3486
|
-
session.agent = source.definition;
|
|
3487
|
-
await store.save(session);
|
|
3488
|
-
const runner = runnerFor(config, source.definition.id);
|
|
3489
|
-
return onTurnRuntime(session.id, { source, config }, () => withWatchdog(session.id, undefined, effectiveStreams, fallbackStreams, async (signal) => await runner.continueTurn(session.id, {
|
|
3490
|
-
runtime: runtimeContextFor(source, config, switchedToFallback, session.metadata),
|
|
3491
|
-
signal,
|
|
3492
|
-
}) ?? session));
|
|
3493
|
-
};
|
|
3494
|
-
/**
|
|
3495
|
-
* A repeated idempotency key that reached the session's chain, so nothing
|
|
3496
|
-
* else is running on the session. `undefined` means the key is new here
|
|
3497
|
-
* and the dispatch runs as any other; otherwise this is the session to
|
|
3498
|
-
* resolve with, and no new turn starts.
|
|
3499
|
-
*
|
|
3500
|
-
* An unfinished item here has no live turn — the live map would have
|
|
3501
|
-
* answered first — so it is one the last process left, reached before
|
|
3502
|
-
* the start-up sweep that would have settled it: settled now, the way
|
|
3503
|
-
* that sweep would, which then finds it no longer needs anything.
|
|
3504
|
-
*/
|
|
3505
|
-
const settleRepeat = async (sessionId, idempotencyKey, agentId, turnId, onRepeat) => {
|
|
3506
|
-
const session = await store.get(sessionId);
|
|
3507
|
-
if (session === undefined) {
|
|
3508
|
-
return undefined;
|
|
3509
|
-
}
|
|
3510
|
-
// Before anything is returned: the turn a dispatch would run is refused
|
|
3511
|
-
// for naming another agent, and the one it would repeat is no less that
|
|
3512
|
-
// agent's — a repeat is not a way to read its transcript.
|
|
3513
|
-
if (agentId !== undefined && session.agent.id !== agentId) {
|
|
3514
|
-
throw crossIdentityError(sessionId, session.agent.id, agentId);
|
|
3515
|
-
}
|
|
3516
|
-
const state = workItemState(session, idempotencyKey);
|
|
3517
|
-
if (state === undefined) {
|
|
3518
|
-
const archived = await archivedRepeat(session, idempotencyKey);
|
|
3519
|
-
if (archived !== undefined) {
|
|
3520
|
-
onRepeat?.('finished');
|
|
3521
|
-
}
|
|
3522
|
-
return archived;
|
|
3523
|
-
}
|
|
3524
|
-
if (state === 'finished') {
|
|
3525
|
-
onRepeat?.('finished');
|
|
3526
|
-
return session;
|
|
3527
|
-
}
|
|
3528
|
-
// Run now, on this caller's behalf, so its events are this caller's
|
|
3529
|
-
// turn — the adapter waiting on them has no other way to claim them.
|
|
3530
|
-
if (turnId !== undefined) {
|
|
3531
|
-
activeTurns.set(sessionId, turnId);
|
|
3532
|
-
}
|
|
3533
|
-
try {
|
|
3534
|
-
return await settleUnfinished(session);
|
|
3535
|
-
}
|
|
3536
|
-
finally {
|
|
3537
|
-
if (turnId !== undefined) {
|
|
3538
|
-
activeTurns.delete(sessionId);
|
|
3539
|
-
}
|
|
3540
|
-
}
|
|
3541
|
-
};
|
|
3542
|
-
/**
|
|
3543
|
-
* The archived transcript holding a key the live row does not, when the
|
|
3544
|
-
* row is a rollover's fresh one made inside `ROLLOVER_REPEAT_WINDOW_MS`.
|
|
3545
|
-
* Only the one rollover back: a second inside the window is a redelivery
|
|
3546
|
-
* outliving two deliberate resets, and the chain is not walked for it.
|
|
3547
|
-
* Always finished — a rollover refuses a session with a turn in flight.
|
|
3548
|
-
*/
|
|
3549
|
-
const archivedRepeat = async (session, idempotencyKey) => {
|
|
3550
|
-
const archivedAs = session.metadata?.[ROLLED_OVER_FROM_METADATA_KEY];
|
|
3551
|
-
if (typeof archivedAs !== 'string' || Date.now() - Date.parse(session.createdAt) > ROLLOVER_REPEAT_WINDOW_MS) {
|
|
3552
|
-
return undefined;
|
|
3553
|
-
}
|
|
3554
|
-
const archived = await store.get(archivedAs);
|
|
3555
|
-
return archived !== undefined && workItemState(archived, idempotencyKey) !== undefined ? archived : undefined;
|
|
3556
|
-
};
|
|
3557
|
-
/** An unfinished item with no live turn, settled the way the start-up sweeps would. */
|
|
3558
|
-
const settleUnfinished = async (session) => {
|
|
3559
|
-
if (session.status === 'pending_approval') {
|
|
3560
|
-
await recoverParked(session.id, orphanedAtStart.has(session.id));
|
|
3561
|
-
return (await store.get(session.id)) ?? session;
|
|
3562
|
-
}
|
|
3563
|
-
return settleAbandonedTurn(session);
|
|
3564
|
-
};
|
|
3565
|
-
/**
|
|
3566
|
-
* What a running turn takes in at its next tool step (card 948): the
|
|
3567
|
-
* merged-turn batch waiting behind it, whole — leader and absorbed alike
|
|
3568
|
-
* — when it is for the same agent. The dispatches it takes join `into`,
|
|
3569
|
-
* which the running turn settles with its own outcome (`runSteerable`).
|
|
3570
|
-
*
|
|
3571
|
-
* A part cancelled while it waited is refused, as `runMerged` refuses it.
|
|
3572
|
-
* A part whose key this session (or the archive it rolled over from)
|
|
3573
|
-
* already holds is a repeat, and judging a repeat can mean settling a
|
|
3574
|
-
* turn — never something to do from inside one — so such a batch is not
|
|
3575
|
-
* taken: it waits and runs next, merged, where that is judged. Nor while
|
|
3576
|
-
* the gateway is stopping, when the batch is what the drain refuses.
|
|
3577
|
-
*/
|
|
3578
|
-
const takeSteered = async (sessionId, agentId, into,
|
|
3579
|
-
/**
|
|
3580
|
-
* Asked last, with nothing awaited after it: whether the running turn
|
|
3581
|
-
* still takes these callers, joining them to its cancellation if so. A
|
|
3582
|
-
* turn cancelled while the store was read takes nothing, and the batch
|
|
3583
|
-
* waits and runs next, as it would have.
|
|
3584
|
-
*/
|
|
3585
|
-
admit) => {
|
|
3586
|
-
const batch = openBatches.get(sessionId);
|
|
3587
|
-
if (stopping || batch === undefined || !batch.firstInLine || batch.agentId !== agentId) {
|
|
3588
|
-
return undefined;
|
|
3589
|
-
}
|
|
3590
|
-
// Until every key in it is checked with nothing awaited since: a message
|
|
3591
|
-
// can join while the store is read, and is checked on the next pass; a
|
|
3592
|
-
// batch closed meanwhile is no longer this turn's to take.
|
|
3593
|
-
const checked = new Set();
|
|
3594
|
-
let session;
|
|
3595
|
-
for (;;) {
|
|
3596
|
-
if (stopping || openBatches.get(sessionId) !== batch) {
|
|
3597
|
-
return undefined;
|
|
3598
|
-
}
|
|
3599
|
-
const unchecked = [batch.leader, ...batch.absorbed].flatMap((part) => (part.input.idempotencyKey !== undefined && !checked.has(part.input.idempotencyKey) ? [part.input.idempotencyKey] : []));
|
|
3600
|
-
if (unchecked.length === 0) {
|
|
3601
|
-
break;
|
|
3602
|
-
}
|
|
3603
|
-
session ??= await store.get(sessionId);
|
|
3604
|
-
for (const key of unchecked) {
|
|
3605
|
-
checked.add(key);
|
|
3606
|
-
if (session !== undefined && (workItemState(session, key) !== undefined || await archivedRepeat(session, key) !== undefined)) {
|
|
3607
|
-
return undefined;
|
|
3608
|
-
}
|
|
3609
|
-
}
|
|
3610
|
-
}
|
|
3611
|
-
const all = [batch.leader, ...batch.absorbed];
|
|
3612
|
-
if (!admit(all.filter((part) => part.input.signal?.aborted !== true))) {
|
|
3613
|
-
return undefined;
|
|
3614
|
-
}
|
|
3615
|
-
openBatches.delete(sessionId);
|
|
3616
|
-
batch.steered = true;
|
|
3617
|
-
// Its place on the chain runs nothing now, so it is not ahead of a batch
|
|
3618
|
-
// opening behind it, which can then be steered at a later step.
|
|
3619
|
-
releaseQueued(sessionId);
|
|
3620
|
-
// Not waiting any more; the leader's place on the chain does not count it again.
|
|
3621
|
-
const left = (waitingDispatches.get(sessionId) ?? all.length) - all.length;
|
|
3622
|
-
if (left > 0) {
|
|
3623
|
-
waitingDispatches.set(sessionId, left);
|
|
3624
|
-
}
|
|
3625
|
-
else {
|
|
3626
|
-
waitingDispatches.delete(sessionId);
|
|
3627
|
-
}
|
|
3628
|
-
const runnable = [];
|
|
3629
|
-
for (const part of all) {
|
|
3630
|
-
if (part.input.signal?.aborted) {
|
|
3631
|
-
part.reject(abortErrorFor(part.input.signal));
|
|
3632
|
-
continue;
|
|
3633
|
-
}
|
|
3634
|
-
runnable.push(part);
|
|
3635
|
-
}
|
|
3636
|
-
if (runnable.length === 0) {
|
|
3637
|
-
return undefined;
|
|
3638
|
-
}
|
|
3639
|
-
const keys = runnable.flatMap((part) => (part.input.idempotencyKey !== undefined ? [part.input.idempotencyKey] : []));
|
|
3640
|
-
const items = liveWorkItems.get(sessionId);
|
|
3641
|
-
for (const key of keys) {
|
|
3642
|
-
const item = items?.get(key);
|
|
3643
|
-
if (item !== undefined) {
|
|
3644
|
-
item.confirmed = true;
|
|
3645
|
-
}
|
|
3646
|
-
}
|
|
3647
|
-
into.push(...runnable);
|
|
3648
|
-
const labelled = runnable.some((part) => part.input.metadata !== undefined && SENDER_TRUST_METADATA_KEY in part.input.metadata);
|
|
3649
|
-
const lowest = leastTrusted(...runnable.map((part) => senderTrustOf(part.input.metadata)));
|
|
3650
|
-
const images = runnable.flatMap((part) => part.input.images ?? []);
|
|
3651
|
-
log(`${sessionId}: ${runnable.length} message(s) sent during the running turn reached it at its next step`);
|
|
3652
|
-
return {
|
|
3653
|
-
userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
|
|
3654
|
-
...(images.length > 0 ? { images } : {}),
|
|
3655
|
-
...(labelled ? { metadata: { [SENDER_TRUST_METADATA_KEY]: lowest } } : {}),
|
|
3656
|
-
...(keys.length > 0 ? { keys } : {}),
|
|
3657
|
-
};
|
|
3658
|
-
};
|
|
3659
1735
|
/**
|
|
3660
1736
|
* Settles a keyed item the last process left unfinished before any other
|
|
3661
1737
|
* message joins its session. Channels are up before the sweeps reach it,
|
|
@@ -3934,448 +2010,25 @@ export const createGateway = (options = {}) => {
|
|
|
3934
2010
|
return dispatchInternal(input);
|
|
3935
2011
|
});
|
|
3936
2012
|
};
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
}
|
|
3957
|
-
const { turnId, idempotencyKey } = input;
|
|
3958
|
-
if (idempotencyKey !== undefined && (idempotencyKey.length === 0 || idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH)) {
|
|
3959
|
-
throw new Error(`An idempotency key is 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} characters; this one is ${idempotencyKey.length}.`);
|
|
3960
|
-
}
|
|
3961
|
-
// A repeat of a dispatch still queued or running is that dispatch: it
|
|
3962
|
-
// resolves with it rather than queueing behind it, which would let
|
|
3963
|
-
// another caller's turn land in between and then find a finished item.
|
|
3964
|
-
// Attached only when it names no other agent than the live dispatch
|
|
3965
|
-
// did; otherwise it queues, and `settleRepeat` refuses it there.
|
|
3966
|
-
const live = idempotencyKey !== undefined ? liveWorkItems.get(input.sessionId)?.get(idempotencyKey) : undefined;
|
|
3967
|
-
// A claim naming an agent other than this delivery does, that the chain
|
|
3968
|
-
// has not yet confirmed — this delivery naming nobody, or someone else.
|
|
3969
|
-
// The claim may be refused for who it named, and then the item is still
|
|
3970
|
-
// unowned: inheriting the refusal would drop a valid message, and
|
|
3971
|
-
// queueing behind it would leave this delivery to find the turn that a
|
|
3972
|
-
// later valid delivery runs `finished`, and answer it twice. So it
|
|
3973
|
-
// waits for the claim to settle and decides then: a claim that stood
|
|
3974
|
-
// is repeated, by a delivery naming its owner or nobody, and refuses
|
|
3975
|
-
// anyone else; a claim that left the item unowned is dispatched again,
|
|
3976
|
-
// where the first delivery to get there runs it and the rest attach.
|
|
3977
|
-
if (live !== undefined && !live.confirmed && live.agentId !== undefined && input.agentId !== live.agentId) {
|
|
3978
|
-
const claim = live.turn;
|
|
3979
|
-
const claimedBy = input.agentId;
|
|
3980
|
-
const repeatOf = (owner) => {
|
|
3981
|
-
if (claimedBy !== undefined && claimedBy !== owner) {
|
|
3982
|
-
throw crossIdentityError(input.sessionId, owner, claimedBy);
|
|
3983
|
-
}
|
|
3984
|
-
input.onRepeat?.('live');
|
|
3985
|
-
};
|
|
3986
|
-
return claim.then((session) => {
|
|
3987
|
-
repeatOf(session.agent.id);
|
|
3988
|
-
return session;
|
|
3989
|
-
}, async (error) => {
|
|
3990
|
-
// Refused for who it named, or failed before its keyed message was
|
|
3991
|
-
// ever written (an agent gone, a provider it could not build): the
|
|
3992
|
-
// item is still unowned, and there is no turn to have repeated.
|
|
3993
|
-
const stored = error instanceof CrossIdentityError || idempotencyKey === undefined ? undefined : await store.get(input.sessionId);
|
|
3994
|
-
const owned = stored !== undefined && idempotencyKey !== undefined && workItemState(stored, idempotencyKey) !== undefined;
|
|
3995
|
-
if (owned) {
|
|
3996
|
-
repeatOf(stored.agent.id);
|
|
3997
|
-
throw error;
|
|
3998
|
-
}
|
|
3999
|
-
// Its own cleanup runs a tick later; the dispatch below must not
|
|
4000
|
-
// find the dead claim still standing and wait on it again.
|
|
4001
|
-
const items = liveWorkItems.get(input.sessionId);
|
|
4002
|
-
if (idempotencyKey !== undefined && items?.get(idempotencyKey)?.turn === claim) {
|
|
4003
|
-
items.delete(idempotencyKey);
|
|
4004
|
-
if (items.size === 0) {
|
|
4005
|
-
liveWorkItems.delete(input.sessionId);
|
|
4006
|
-
}
|
|
4007
|
-
}
|
|
4008
|
-
return dispatch(input);
|
|
4009
|
-
});
|
|
4010
|
-
}
|
|
4011
|
-
if (live !== undefined && (input.agentId === undefined || input.agentId === live.agentId)) {
|
|
4012
|
-
input.onRepeat?.('live');
|
|
4013
|
-
return live.turn;
|
|
4014
|
-
}
|
|
4015
|
-
// The one door every external dispatch comes through, so a pause holds
|
|
4016
|
-
// for every channel at once. Work already accepted is not behind this
|
|
4017
|
-
// check: a turn running, a redelivery of a message whose turn is live
|
|
4018
|
-
// (answered above), a message queued behind it, a delegation or
|
|
4019
|
-
// an approval resuming a parked turn all go on.
|
|
4020
|
-
if (intakeState.paused) {
|
|
4021
|
-
throw new IntakePausedError(intakeState.message ?? DEFAULT_PAUSED_MESSAGE);
|
|
4022
|
-
}
|
|
4023
|
-
if (heldState) {
|
|
4024
|
-
throw new IntakePausedError(HELD_MESSAGE);
|
|
4025
|
-
}
|
|
4026
|
-
// The live dispatch named no agent, so whose session it is is known
|
|
4027
|
-
// only once its turn resolves. Queueing instead would make this a
|
|
4028
|
-
// `finished` repeat of a turn the live caller is still rendering — two
|
|
4029
|
-
// answers — so it attaches, and is held to the agent it named then:
|
|
4030
|
-
// told it repeated only if that agent owns the session, refused if not.
|
|
4031
|
-
const claimed = input.agentId;
|
|
4032
|
-
if (live !== undefined && live.agentId === undefined && claimed !== undefined) {
|
|
4033
|
-
const shared = live.turn;
|
|
4034
|
-
// Whichever way the turn ends: a failed one is still that agent's,
|
|
4035
|
-
// its owner read from the store, and a repeat of it is still told so
|
|
4036
|
-
// — or refused, rather than handed another agent's error.
|
|
4037
|
-
const attach = async () => {
|
|
4038
|
-
let outcome;
|
|
4039
|
-
try {
|
|
4040
|
-
outcome = { session: await shared };
|
|
4041
|
-
}
|
|
4042
|
-
catch (error) {
|
|
4043
|
-
outcome = { error };
|
|
4044
|
-
}
|
|
4045
|
-
const owner = 'session' in outcome ? outcome.session.agent.id : (await store.get(input.sessionId))?.agent.id;
|
|
4046
|
-
if (owner !== undefined && owner !== claimed) {
|
|
4047
|
-
throw crossIdentityError(input.sessionId, owner, claimed);
|
|
4048
|
-
}
|
|
4049
|
-
// No owner means the dispatch failed before its session was ever
|
|
4050
|
-
// written: there is no turn to have repeated, only its error.
|
|
4051
|
-
if (owner !== undefined) {
|
|
4052
|
-
input.onRepeat?.('live');
|
|
4053
|
-
}
|
|
4054
|
-
if ('error' in outcome) {
|
|
4055
|
-
throw outcome.error;
|
|
4056
|
-
}
|
|
4057
|
-
return outcome.session;
|
|
4058
|
-
};
|
|
4059
|
-
return attach();
|
|
4060
|
-
}
|
|
4061
|
-
// Merged turns (card 423): an opted-in message behind a running turn
|
|
4062
|
-
// joins the batch already waiting, if there is one for the same agent.
|
|
4063
|
-
const mergeable = input.onAbsorbed !== undefined && input.addressed !== false;
|
|
4064
|
-
const open = openBatches.get(input.sessionId);
|
|
4065
|
-
if (mergeable &&
|
|
4066
|
-
open !== undefined &&
|
|
4067
|
-
open.agentId === input.agentId &&
|
|
4068
|
-
fitsReplayBudget([...open.images, ...(input.images ?? [])]) &&
|
|
4069
|
-
open.chars + MERGED_SEPARATOR.length + input.userMessage.length <= MERGED_TEXT_BUDGET_CHARS) {
|
|
4070
|
-
open.images.push(...(input.images ?? []));
|
|
4071
|
-
open.chars += MERGED_SEPARATOR.length + input.userMessage.length;
|
|
4072
|
-
waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
|
|
4073
|
-
const joined = new Promise((resolve, reject) => {
|
|
4074
|
-
open.absorbed.push({ input, resolve, reject });
|
|
4075
|
-
});
|
|
4076
|
-
if (idempotencyKey !== undefined) {
|
|
4077
|
-
// Provisional, like a queued dispatch's: a redelivery while it waits
|
|
4078
|
-
// attaches here; the merged turn confirms it once it owns the key.
|
|
4079
|
-
const items = liveWorkItems.get(input.sessionId) ?? new Map();
|
|
4080
|
-
if (!items.has(idempotencyKey)) {
|
|
4081
|
-
items.set(idempotencyKey, { turn: joined, agentId: input.agentId, confirmed: false, addressed: true });
|
|
4082
|
-
liveWorkItems.set(input.sessionId, items);
|
|
4083
|
-
}
|
|
4084
|
-
void joined.catch(() => { }).finally(() => {
|
|
4085
|
-
const current = liveWorkItems.get(input.sessionId);
|
|
4086
|
-
if (current?.get(idempotencyKey)?.turn === joined) {
|
|
4087
|
-
current.delete(idempotencyKey);
|
|
4088
|
-
if (current.size === 0) {
|
|
4089
|
-
liveWorkItems.delete(input.sessionId);
|
|
4090
|
-
}
|
|
4091
|
-
}
|
|
4092
|
-
});
|
|
4093
|
-
}
|
|
4094
|
-
return joined;
|
|
4095
|
-
}
|
|
4096
|
-
// It cannot join (or its images or text would overflow the batch's
|
|
4097
|
-
// budget), so the open batch takes no more: a message arriving
|
|
4098
|
-
// after this one must not be answered ahead of it.
|
|
4099
|
-
if (open !== undefined) {
|
|
4100
|
-
openBatches.delete(input.sessionId);
|
|
4101
|
-
}
|
|
4102
|
-
// Otherwise it leads a new batch, if it is waiting behind a turn at all:
|
|
4103
|
-
// a message to an idle session runs at once, alone.
|
|
4104
|
-
const batch = mergeable && sessionChains.has(input.sessionId)
|
|
4105
|
-
? {
|
|
4106
|
-
agentId: input.agentId,
|
|
4107
|
-
// Settled through `settleLeader`, read when called: it is assigned below.
|
|
4108
|
-
leader: { input, resolve: (session) => settleLeader({ session }), reject: (error) => settleLeader({ error }) },
|
|
4109
|
-
firstInLine: (queuedWork.get(input.sessionId) ?? 0) === 0,
|
|
4110
|
-
absorbed: [],
|
|
4111
|
-
steered: false,
|
|
4112
|
-
images: [...(input.images ?? [])],
|
|
4113
|
-
chars: input.userMessage.length,
|
|
4114
|
-
}
|
|
4115
|
-
: undefined;
|
|
4116
|
-
if (batch !== undefined) {
|
|
4117
|
-
openBatches.set(input.sessionId, batch);
|
|
4118
|
-
}
|
|
4119
|
-
// Reads `turn` only once it is assigned: the registration below runs
|
|
4120
|
-
// after it, and the chain's work runs in a later tick (`.then`).
|
|
4121
|
-
const claimLiveItem = (key, confirmed) => {
|
|
4122
|
-
const items = liveWorkItems.get(input.sessionId) ?? new Map();
|
|
4123
|
-
items.set(key, { turn, agentId: input.agentId, confirmed, addressed: input.addressed !== false });
|
|
4124
|
-
liveWorkItems.set(input.sessionId, items);
|
|
4125
|
-
};
|
|
4126
|
-
/**
|
|
4127
|
-
* This dispatch's turn with the messages it absorbed (card 423). Each
|
|
4128
|
-
* part is judged on its own first, as it would have been alone: one
|
|
4129
|
-
* cancelled while it waited is refused, one that repeats a turn the
|
|
4130
|
-
* session already holds is that turn. What is left runs as one turn.
|
|
4131
|
-
*/
|
|
4132
|
-
const runMerged = async (absorbed) => {
|
|
4133
|
-
const parts = [{ input }, ...absorbed.map((joiner) => ({ input: joiner.input, joiner }))];
|
|
4134
|
-
let leader;
|
|
4135
|
-
const settle = (part, outcome) => {
|
|
4136
|
-
if (part.joiner === undefined) {
|
|
4137
|
-
// Settled the moment it is known, not when the chain moves on: a
|
|
4138
|
-
// leader cancelled or repeated while it waited must leave its
|
|
4139
|
-
// caller's queue before the turn it handed over starts rendering.
|
|
4140
|
-
leader = outcome;
|
|
4141
|
-
settleLeader(outcome);
|
|
4142
|
-
}
|
|
4143
|
-
else if ('session' in outcome) {
|
|
4144
|
-
part.joiner.resolve(outcome.session);
|
|
4145
|
-
}
|
|
4146
|
-
else {
|
|
4147
|
-
part.joiner.reject(outcome.error);
|
|
4148
|
-
}
|
|
4149
|
-
};
|
|
4150
|
-
const runnable = [];
|
|
4151
|
-
for (const part of parts) {
|
|
4152
|
-
const own = part.input;
|
|
4153
|
-
if (own.signal?.aborted) {
|
|
4154
|
-
settle(part, { error: abortErrorFor(own.signal) });
|
|
4155
|
-
continue;
|
|
4156
|
-
}
|
|
4157
|
-
if (own.idempotencyKey !== undefined) {
|
|
4158
|
-
try {
|
|
4159
|
-
const repeated = await settleRepeat(input.sessionId, own.idempotencyKey, own.agentId, own.turnId, own.onRepeat);
|
|
4160
|
-
if (repeated !== undefined) {
|
|
4161
|
-
settle(part, { session: repeated });
|
|
4162
|
-
continue;
|
|
4163
|
-
}
|
|
4164
|
-
}
|
|
4165
|
-
catch (error) {
|
|
4166
|
-
settle(part, { error });
|
|
4167
|
-
continue;
|
|
4168
|
-
}
|
|
4169
|
-
}
|
|
4170
|
-
runnable.push(part);
|
|
4171
|
-
}
|
|
4172
|
-
const head = runnable[0];
|
|
4173
|
-
if (head === undefined) {
|
|
4174
|
-
return finish(leader);
|
|
4175
|
-
}
|
|
4176
|
-
// Every runnable key now belongs to this turn.
|
|
4177
|
-
const keys = runnable.flatMap((part) => (part.input.idempotencyKey !== undefined ? [part.input.idempotencyKey] : []));
|
|
4178
|
-
const items = liveWorkItems.get(input.sessionId);
|
|
4179
|
-
for (const key of keys) {
|
|
4180
|
-
const item = items?.get(key);
|
|
4181
|
-
if (item !== undefined) {
|
|
4182
|
-
item.confirmed = true;
|
|
4183
|
-
}
|
|
4184
|
-
}
|
|
4185
|
-
// The turn runs at the least trusted sender in it: everyone's words
|
|
4186
|
-
// are in its one message.
|
|
4187
|
-
const labelled = runnable.some((part) => part.input.metadata !== undefined && SENDER_TRUST_METADATA_KEY in part.input.metadata);
|
|
4188
|
-
const lowest = leastTrusted(...runnable.map((part) => senderTrustOf(part.input.metadata)));
|
|
4189
|
-
const newest = runnable[runnable.length - 1].input;
|
|
4190
|
-
const metadata = labelled ? { ...newest.metadata, [SENDER_TRUST_METADATA_KEY]: lowest } : newest.metadata;
|
|
4191
|
-
const images = runnable.flatMap((part) => part.input.images ?? []);
|
|
4192
|
-
const signals = runnable.map((part) => part.input.signal);
|
|
4193
|
-
// Cancelled only when every caller in it is: one caller giving up
|
|
4194
|
-
// must not take the others' answer with it.
|
|
4195
|
-
const signal = signals.every((candidate) => candidate !== undefined)
|
|
4196
|
-
? allAborted(signals)
|
|
4197
|
-
: undefined;
|
|
4198
|
-
const [primaryKey, ...absorbedKeys] = keys;
|
|
4199
|
-
const merged = {
|
|
4200
|
-
sessionId: input.sessionId,
|
|
4201
|
-
...(head.input.agentId !== undefined ? { agentId: head.input.agentId } : {}),
|
|
4202
|
-
userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
|
|
4203
|
-
...(images.length > 0 ? { images } : {}),
|
|
4204
|
-
...(runnable.every((part) => part.input.replyOptional === true) ? { replyOptional: true } : {}),
|
|
4205
|
-
...(head.input.earlier !== undefined ? { earlier: head.input.earlier } : {}),
|
|
4206
|
-
...(metadata !== undefined ? { metadata } : {}),
|
|
4207
|
-
...(signal !== undefined ? { signal } : {}),
|
|
4208
|
-
...(head.input.turnId !== undefined ? { turnId: head.input.turnId } : {}),
|
|
4209
|
-
...(primaryKey !== undefined ? { idempotencyKey: primaryKey } : {}),
|
|
4210
|
-
};
|
|
4211
|
-
await settleOrphanedItem(input.sessionId);
|
|
4212
|
-
let outcome;
|
|
4213
|
-
if (merged.turnId !== undefined) {
|
|
4214
|
-
activeTurns.set(input.sessionId, merged.turnId);
|
|
4215
|
-
}
|
|
4216
|
-
try {
|
|
4217
|
-
outcome = { session: await runSteerable(merged, absorbedKeys) };
|
|
4218
|
-
}
|
|
4219
|
-
catch (error) {
|
|
4220
|
-
outcome = { error };
|
|
4221
|
-
}
|
|
4222
|
-
finally {
|
|
4223
|
-
if (merged.turnId !== undefined) {
|
|
4224
|
-
activeTurns.delete(input.sessionId);
|
|
4225
|
-
}
|
|
4226
|
-
}
|
|
4227
|
-
for (const part of runnable) {
|
|
4228
|
-
if (part !== head) {
|
|
4229
|
-
part.input.onAbsorbed?.();
|
|
4230
|
-
}
|
|
4231
|
-
settle(part, outcome);
|
|
4232
|
-
}
|
|
4233
|
-
return finish(leader);
|
|
4234
|
-
};
|
|
4235
|
-
const finish = (outcome) => {
|
|
4236
|
-
if (outcome === undefined) {
|
|
4237
|
-
throw new Error('A merged turn settled without an outcome for its leader.');
|
|
4238
|
-
}
|
|
4239
|
-
if ('error' in outcome) {
|
|
4240
|
-
throw outcome.error;
|
|
4241
|
-
}
|
|
4242
|
-
return outcome.session;
|
|
4243
|
-
};
|
|
4244
|
-
// What this caller is handed. It follows the chain's work, except that a
|
|
4245
|
-
// batch leader settles it early (`settle` in `runMerged`).
|
|
4246
|
-
let settleLeader = () => { };
|
|
4247
|
-
const turn = new Promise((resolve, reject) => {
|
|
4248
|
-
settleLeader = (outcome) => ('error' in outcome ? reject(outcome.error) : resolve(outcome.session));
|
|
4249
|
-
});
|
|
4250
|
-
waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
|
|
4251
|
-
/**
|
|
4252
|
-
* Runs this dispatch's turn, handing it whatever queues behind it at
|
|
4253
|
-
* each tool step (`takeSteered`), then settles every dispatch it took
|
|
4254
|
-
* with the turn's outcome — the outcome they joined. Only for a caller
|
|
4255
|
-
* that opted into merged turns: it is the one that renders an answer
|
|
4256
|
-
* covering messages other than its own.
|
|
4257
|
-
*/
|
|
4258
|
-
const runSteerable = async (turnInput, absorbedKeys = []) => {
|
|
4259
|
-
const steered = [];
|
|
4260
|
-
// Cancelled only when every caller in it is, as a merged turn is, the
|
|
4261
|
-
// ones steered in later included: one caller giving up must not take
|
|
4262
|
-
// the others' answer with it. A turn with a caller who cannot cancel
|
|
4263
|
-
// has no signal to join.
|
|
4264
|
-
const joint = turnInput.signal !== undefined ? joinableSignal([turnInput.signal]) : undefined;
|
|
4265
|
-
// Admitted on the turn's own signal, not only the callers' joint one:
|
|
4266
|
-
// the watchdog, `abortTurns` and a restart's window stop the turn
|
|
4267
|
-
// without touching its callers, and a batch taken into a turn stopped
|
|
4268
|
-
// while the store was read would share its failure instead of running
|
|
4269
|
-
// next (card 959). The turn's signal follows the joint one, so this
|
|
4270
|
-
// covers every caller giving up too.
|
|
4271
|
-
const steer = mergeable
|
|
4272
|
-
? (turnSignal) => takeSteered(input.sessionId, input.agentId, steered, (parts) => {
|
|
4273
|
-
if (turnSignal.aborted) {
|
|
4274
|
-
return false;
|
|
4275
|
-
}
|
|
4276
|
-
for (const part of parts) {
|
|
4277
|
-
joint?.join(part.input.signal);
|
|
4278
|
-
}
|
|
4279
|
-
return true;
|
|
4280
|
-
})
|
|
4281
|
-
: undefined;
|
|
4282
|
-
const running = joint !== undefined ? { ...turnInput, signal: joint.signal } : turnInput;
|
|
4283
|
-
let outcome;
|
|
4284
|
-
try {
|
|
4285
|
-
outcome = { session: await dispatchInternal(running, absorbedKeys, undefined, steer) };
|
|
4286
|
-
}
|
|
4287
|
-
catch (error) {
|
|
4288
|
-
outcome = { error };
|
|
4289
|
-
}
|
|
4290
|
-
for (const part of steered) {
|
|
4291
|
-
part.input.onAbsorbed?.();
|
|
4292
|
-
if ('session' in outcome) {
|
|
4293
|
-
part.resolve(outcome.session);
|
|
4294
|
-
}
|
|
4295
|
-
else {
|
|
4296
|
-
part.reject(outcome.error);
|
|
4297
|
-
}
|
|
4298
|
-
}
|
|
4299
|
-
if ('error' in outcome) {
|
|
4300
|
-
throw outcome.error;
|
|
4301
|
-
}
|
|
4302
|
-
return outcome.session;
|
|
4303
|
-
};
|
|
4304
|
-
const chained = onSessionChain(input.sessionId, async () => {
|
|
4305
|
-
// Steered into the turn that ran ahead of it, which has answered and
|
|
4306
|
-
// settled everyone in the batch already — this caller included.
|
|
4307
|
-
if (batch?.steered === true) {
|
|
4308
|
-
return turn;
|
|
4309
|
-
}
|
|
4310
|
-
// The batch closes here: anything arriving from now on waits behind
|
|
4311
|
-
// this turn and leads the next one.
|
|
4312
|
-
if (batch !== undefined && openBatches.get(input.sessionId) === batch) {
|
|
4313
|
-
openBatches.delete(input.sessionId);
|
|
4314
|
-
}
|
|
4315
|
-
const absorbed = batch?.absorbed ?? [];
|
|
4316
|
-
// No longer waiting once it reaches the head of the chain — nor is
|
|
4317
|
-
// anything it absorbed.
|
|
4318
|
-
const left = (waitingDispatches.get(input.sessionId) ?? 1) - 1 - absorbed.length;
|
|
4319
|
-
if (left > 0) {
|
|
4320
|
-
waitingDispatches.set(input.sessionId, left);
|
|
4321
|
-
}
|
|
4322
|
-
else {
|
|
4323
|
-
waitingDispatches.delete(input.sessionId);
|
|
4324
|
-
}
|
|
4325
|
-
if (absorbed.length > 0) {
|
|
4326
|
-
return runMerged(absorbed);
|
|
4327
|
-
}
|
|
4328
|
-
// Before `activeTurns` is touched: a repeat that runs nothing must
|
|
4329
|
-
// not claim the session's turn id, even for the instant it takes.
|
|
4330
|
-
const repeated = idempotencyKey !== undefined ? await settleRepeat(input.sessionId, idempotencyKey, input.agentId, turnId, input.onRepeat) : undefined;
|
|
4331
|
-
if (repeated !== undefined) {
|
|
4332
|
-
return repeated;
|
|
4333
|
-
}
|
|
4334
|
-
// Past the identity check with a key the session has not seen, so this
|
|
4335
|
-
// dispatch owns the item: it becomes the live entry even if a claimant
|
|
4336
|
-
// that was then refused registered first, or a repeat arriving while
|
|
4337
|
-
// this turn runs would find nothing to attach to, queue, and read the
|
|
4338
|
-
// turn as finished — a second answer beside this caller's.
|
|
4339
|
-
if (idempotencyKey !== undefined) {
|
|
4340
|
-
claimLiveItem(idempotencyKey, true);
|
|
4341
|
-
}
|
|
4342
|
-
await settleOrphanedItem(input.sessionId);
|
|
4343
|
-
if (turnId === undefined) {
|
|
4344
|
-
return runSteerable(input);
|
|
4345
|
-
}
|
|
4346
|
-
activeTurns.set(input.sessionId, turnId);
|
|
4347
|
-
try {
|
|
4348
|
-
return await runSteerable(input);
|
|
4349
|
-
}
|
|
4350
|
-
finally {
|
|
4351
|
-
// Unconditional, and safe to be: the chain guarantees no other turn
|
|
4352
|
-
// for this session ran between the set above and here, so this can
|
|
4353
|
-
// only ever be clearing its own entry.
|
|
4354
|
-
activeTurns.delete(input.sessionId);
|
|
4355
|
-
}
|
|
4356
|
-
}, () => batch?.steered === true);
|
|
4357
|
-
// A second settle (a leader already settled early) is a no-op.
|
|
4358
|
-
chained.then((session) => settleLeader({ session }), (error) => settleLeader({ error }));
|
|
4359
|
-
if (idempotencyKey !== undefined) {
|
|
4360
|
-
// A repeat that queued instead of attaching — it named another agent,
|
|
4361
|
-
// and is about to be refused — must not displace the turn it repeats.
|
|
4362
|
-
// The first registration is provisional; the chain confirms it
|
|
4363
|
-
// (`claimLiveItem` above) once the dispatch is known to own the item.
|
|
4364
|
-
if (liveWorkItems.get(input.sessionId)?.has(idempotencyKey) !== true) {
|
|
4365
|
-
claimLiveItem(idempotencyKey, false);
|
|
4366
|
-
}
|
|
4367
|
-
void turn.catch(() => { }).finally(() => {
|
|
4368
|
-
const current = liveWorkItems.get(input.sessionId);
|
|
4369
|
-
if (current?.get(idempotencyKey)?.turn === turn) {
|
|
4370
|
-
current.delete(idempotencyKey);
|
|
4371
|
-
if (current.size === 0) {
|
|
4372
|
-
liveWorkItems.delete(input.sessionId);
|
|
4373
|
-
}
|
|
4374
|
-
}
|
|
4375
|
-
});
|
|
4376
|
-
}
|
|
4377
|
-
return turn;
|
|
4378
|
-
};
|
|
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
|
+
});
|
|
4379
2032
|
const observe = async (input) => {
|
|
4380
2033
|
if (stopping) {
|
|
4381
2034
|
throw refusal();
|
|
@@ -4857,7 +2510,6 @@ export const createGateway = (options = {}) => {
|
|
|
4857
2510
|
loadedPlugins = [];
|
|
4858
2511
|
return released;
|
|
4859
2512
|
};
|
|
4860
|
-
const startedChannels = [];
|
|
4861
2513
|
/** Set once a shutdown has stopped the started channels; a start that finishes after it stops its own. */
|
|
4862
2514
|
let channelsClosed = false;
|
|
4863
2515
|
/** Resolves with `true` when `work` settles before `ms` elapse. */
|
|
@@ -5544,4 +3196,8 @@ export const createGateway = (options = {}) => {
|
|
|
5544
3196
|
};
|
|
5545
3197
|
return gateway;
|
|
5546
3198
|
};
|
|
3199
|
+
// Restart, stop and pause, in their own module (see the top of lifecycle.ts).
|
|
3200
|
+
export { DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS, DEFAULT_PAUSED_MESSAGE, DEFAULT_RESTART_DRAIN_TIMEOUT_MS, DEFAULT_STOP_DRAIN_TIMEOUT_MS, GatewayStoppingError, HELD_MESSAGE, IntakePausedError, MAX_PAUSED_MESSAGE_LENGTH, PauseMessageError, RESTARTING_TURN_ERROR, RestartUnsupportedError, STOPPING_TURN_ERROR, } from "./lifecycle.js";
|
|
3201
|
+
// Reserved session keys and markers, in their own module (see the top of session-keys.ts).
|
|
3202
|
+
export { ABANDONED_TURN_ERROR, DEFAULT_SUB_AGENTS_PER_TURN, EXECUTOR_METADATA_KEY, isSubAgentSession, ORPHANED_DELEGATION_ERROR, RESERVED_SESSION_METADATA_KEYS, reservedSessionMetadataKey, ROLLED_OVER_FROM_METADATA_KEY, ROLLED_OVER_SESSION_ID_MARKER, ROLLED_OVER_TO_METADATA_KEY, SUB_AGENT_METADATA_KEY, SUB_AGENT_SESSION_ID_MARKER, } from "./session-keys.js";
|
|
5547
3203
|
//# sourceMappingURL=index.js.map
|