@stratusagent/gateway 0.11.9 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +77 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +339 -18
- package/dist/index.js.map +1 -1
- package/dist/schedules.d.ts +16 -0
- package/dist/schedules.d.ts.map +1 -1
- package/dist/schedules.js +31 -0
- package/dist/schedules.js.map +1 -1
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -6,9 +6,11 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
9
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
10
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { filePathsOf, isUnaddressedTurn, workItemState, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, matchesToolAllowlist, } from '@stratusagent/core';
|
|
13
|
+
import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
|
|
12
14
|
import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
|
|
13
15
|
import { ShardedSessionStore } from "./sessions.js";
|
|
14
16
|
export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
|
|
@@ -16,7 +18,7 @@ export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTa
|
|
|
16
18
|
export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
|
|
17
19
|
import { createLocalCommandExecutor } from '@stratusagent/executor-local';
|
|
18
20
|
import { loadPlugins, } from '@stratusagent/plugins';
|
|
19
|
-
import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, stratusHomePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, } from '@stratusagent/state';
|
|
21
|
+
import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, stratusHomePath, intakeStatePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, } from '@stratusagent/state';
|
|
20
22
|
const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
|
21
23
|
/**
|
|
22
24
|
* How long a parked call waits for a person. Long enough to survive a
|
|
@@ -48,6 +50,92 @@ export const RESTARTING_TURN_ERROR = 'Run aborted: stratusd is restarting';
|
|
|
48
50
|
* operator can hit agree.
|
|
49
51
|
*/
|
|
50
52
|
export const DEFAULT_RESTART_DRAIN_TIMEOUT_MS = 30_000;
|
|
53
|
+
/** What a paused daemon says when nobody gave it a sentence of its own. */
|
|
54
|
+
export const DEFAULT_PAUSED_MESSAGE = "I'm paused right now and not taking new work. Please try again later.";
|
|
55
|
+
/** What a held gateway answers a dispatch with, before it is promoted. */
|
|
56
|
+
export const HELD_MESSAGE = "I'm starting up and not taking work yet. Please try again in a moment.";
|
|
57
|
+
/** The longest pause message, so a reply to a refused message stays one. */
|
|
58
|
+
export const MAX_PAUSED_MESSAGE_LENGTH = 500;
|
|
59
|
+
/** A pause message the gateway will not take: empty, or too long. */
|
|
60
|
+
export class PauseMessageError extends Error {
|
|
61
|
+
constructor(message) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = 'PauseMessageError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A dispatch refused because intake is paused. Its message is the sentence
|
|
68
|
+
* to show whoever sent the work, as it is: a channel posts it as the reply,
|
|
69
|
+
* not as a failure.
|
|
70
|
+
*/
|
|
71
|
+
export class IntakePausedError extends Error {
|
|
72
|
+
constructor(message) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = 'IntakePausedError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The intake state a previous process left, or open when there is none.
|
|
79
|
+
*
|
|
80
|
+
* Fails closed: a file that exists but cannot be read or parsed starts the
|
|
81
|
+
* daemon paused, with a warning naming it. A pause exists to stop work (a
|
|
82
|
+
* spending cap, a maintenance window), and a file nobody can read is no
|
|
83
|
+
* evidence that it was lifted.
|
|
84
|
+
*/
|
|
85
|
+
const readIntakeState = async (env, warn) => {
|
|
86
|
+
const file = intakeStatePath(env);
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
raw = await readFile(file, 'utf8');
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code === 'ENOENT') {
|
|
93
|
+
return { paused: false };
|
|
94
|
+
}
|
|
95
|
+
warn(`intake: ${file} could not be read (${error.message}); starting paused. Resume intake to clear it.`);
|
|
96
|
+
return { paused: true, since: new Date().toISOString() };
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(raw);
|
|
100
|
+
// Only a pause is ever written here (resuming removes the file), so
|
|
101
|
+
// anything else in it is not a record this build wrote, and the same
|
|
102
|
+
// rule as an unreadable file applies.
|
|
103
|
+
if (typeof parsed !== 'object' || parsed === null || parsed.paused !== true) {
|
|
104
|
+
warn(`intake: ${file} does not hold a pause record; starting paused. Resume intake to clear it.`);
|
|
105
|
+
return { paused: true, since: new Date().toISOString() };
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
paused: true,
|
|
109
|
+
...(typeof parsed.message === 'string' && parsed.message.trim().length > 0 ? { message: parsed.message } : {}),
|
|
110
|
+
since: typeof parsed.since === 'string' ? parsed.since : new Date().toISOString(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
warn(`intake: ${file} is not valid JSON; starting paused. Resume intake to clear it.`);
|
|
115
|
+
return { paused: true, since: new Date().toISOString() };
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Record a pause durably (0600, replaced in one atomic step), or remove the
|
|
120
|
+
* record when intake reopens: open is the default, so nothing on disk
|
|
121
|
+
* means open.
|
|
122
|
+
*/
|
|
123
|
+
const writeIntakeState = async (env, state) => {
|
|
124
|
+
const file = intakeStatePath(env);
|
|
125
|
+
if (!state.paused) {
|
|
126
|
+
await rm(file, { force: true });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
130
|
+
const staging = `${file}.${randomBytes(8).toString('hex')}`;
|
|
131
|
+
try {
|
|
132
|
+
await writeFile(staging, `${JSON.stringify(state)}\n`, { flag: 'wx', mode: 0o600 });
|
|
133
|
+
await rename(staging, file);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await rm(staging, { force: true });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
51
139
|
/**
|
|
52
140
|
* `restart()` on a gateway whose host gave it no way back. Exported so the
|
|
53
141
|
* control API can answer it as "not supported here" rather than as a
|
|
@@ -256,7 +344,7 @@ export const createGateway = (options = {}) => {
|
|
|
256
344
|
* "cannot address" rather than a stale reference — and rejecting is how
|
|
257
345
|
* both callers (schedule creation, `message.send`) hear why.
|
|
258
346
|
*/
|
|
259
|
-
const outboundFor = async (agentId, destination) => {
|
|
347
|
+
const outboundFor = async (agentId, destination, thread) => {
|
|
260
348
|
const { adapter, carriesOthers } = channelCarrying(destination.channel, agentId, (candidate) => candidate.resolveOutbound !== undefined);
|
|
261
349
|
if (!adapter?.resolveOutbound && carriesOthers) {
|
|
262
350
|
throw new Error(`No running '${destination.channel}' channel carries agent ${agentId} — `
|
|
@@ -268,7 +356,7 @@ export const createGateway = (options = {}) => {
|
|
|
268
356
|
? 'no channel adapters are running.'
|
|
269
357
|
: `running channels: ${startedChannels.map((candidate) => candidate.name).join(', ')}.`));
|
|
270
358
|
}
|
|
271
|
-
return adapter.resolveOutbound({ agentId, to: destination.to });
|
|
359
|
+
return adapter.resolveOutbound({ agentId, to: destination.to, ...(thread !== undefined ? { thread } : {}) });
|
|
272
360
|
};
|
|
273
361
|
const scheduler = createSchedulerRuntime({
|
|
274
362
|
store: scheduleStore,
|
|
@@ -286,6 +374,7 @@ export const createGateway = (options = {}) => {
|
|
|
286
374
|
// The same stamp check every turn runs, before a slot is claimed — see
|
|
287
375
|
// `SchedulerRuntimeOptions.ready`.
|
|
288
376
|
ready: () => assertStateCompatible(env),
|
|
377
|
+
paused: () => intakeState.paused,
|
|
289
378
|
log,
|
|
290
379
|
warn,
|
|
291
380
|
});
|
|
@@ -329,7 +418,39 @@ export const createGateway = (options = {}) => {
|
|
|
329
418
|
* so a click racing the timeout — or arriving after an abort — is refused
|
|
330
419
|
* rather than executing a tool for a turn that has already moved on.
|
|
331
420
|
*/
|
|
332
|
-
|
|
421
|
+
/**
|
|
422
|
+
* The conversation a delegated sub-session's work was asked from, read
|
|
423
|
+
* from the store rather than from anything the caller wrote: the root
|
|
424
|
+
* session `agent.delegate` recorded, which is where the person waiting
|
|
425
|
+
* on the answer is. Undefined for an ordinary session, or a root that no
|
|
426
|
+
* longer exists — the request is then asked the way it always was.
|
|
427
|
+
*/
|
|
428
|
+
const delegationOriginOf = async (session) => {
|
|
429
|
+
if (!isDelegatedSession(session)) {
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
const rootId = session.metadata?.[ROOT_SESSION_ID_METADATA_KEY];
|
|
433
|
+
if (typeof rootId !== 'string' || rootId === session.id) {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
let root;
|
|
437
|
+
try {
|
|
438
|
+
root = await store.get(rootId);
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
warn(`${session.id}: could not read the delegating session ${rootId} to route an approval (${error instanceof Error ? error.message : String(error)})`);
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
if (!root) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
return { agentId: root.agent.id, sessionId: root.id, ...(root.metadata ? { metadata: root.metadata } : {}) };
|
|
448
|
+
};
|
|
449
|
+
const requestApproval = async (request) => {
|
|
450
|
+
const delegatedFrom = await delegationOriginOf(request.session);
|
|
451
|
+
return requestApprovalFrom(request, delegatedFrom);
|
|
452
|
+
};
|
|
453
|
+
const requestApprovalFrom = (request, delegatedFrom) => new Promise((resolve) => {
|
|
333
454
|
// A shutdown denies what is parked once, at the top of stop(). Turns
|
|
334
455
|
// already running keep going through the drain, though, and one of them
|
|
335
456
|
// can reach a gated tool AFTER that snapshot — finishing a provider
|
|
@@ -369,7 +490,17 @@ export const createGateway = (options = {}) => {
|
|
|
369
490
|
* all and its denial should not wait on an emission that never runs.
|
|
370
491
|
*/
|
|
371
492
|
let announced = Promise.resolve();
|
|
372
|
-
|
|
493
|
+
// Work one agent does for another is allowed one call at a time. The
|
|
494
|
+
// people asked are the delegating conversation's, and a standing grant
|
|
495
|
+
// would widen what the other agent may do on its own work for good —
|
|
496
|
+
// so nothing offers **Always allow** here, and an `always` that
|
|
497
|
+
// arrives anyway (the control API takes all three answers) runs the
|
|
498
|
+
// call once, recorded as exactly that.
|
|
499
|
+
const forAnotherAgent = delegatedFrom !== undefined && delegatedFrom.agentId !== request.session.agent.id;
|
|
500
|
+
const oneShot = request.oneShot === true || forAnotherAgent;
|
|
501
|
+
const always = forAnotherAgent ? undefined : request.always;
|
|
502
|
+
const settle = (given, reason, actor) => {
|
|
503
|
+
const answer = forAnotherAgent && given === 'always' ? 'once' : given;
|
|
373
504
|
// Identity, not presence: a request that already settled is gone from
|
|
374
505
|
// the registry, and comparing against this exact function is what
|
|
375
506
|
// makes every ending — click, timeout, abort, shutdown — idempotent.
|
|
@@ -414,11 +545,12 @@ export const createGateway = (options = {}) => {
|
|
|
414
545
|
call: request.call,
|
|
415
546
|
risk: request.risk,
|
|
416
547
|
...(request.origin !== undefined ? { origin: request.origin } : {}),
|
|
417
|
-
...(
|
|
418
|
-
...(
|
|
548
|
+
...(oneShot ? { oneShot: true } : {}),
|
|
549
|
+
...(always !== undefined ? { always } : {}),
|
|
419
550
|
parkedAt: request.parkedAt ?? new Date().toISOString(),
|
|
420
551
|
...(expiresAt ? { expiresAt } : {}),
|
|
421
552
|
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
553
|
+
...(delegatedFrom ? { delegatedFrom } : {}),
|
|
422
554
|
},
|
|
423
555
|
});
|
|
424
556
|
if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
|
|
@@ -462,9 +594,10 @@ export const createGateway = (options = {}) => {
|
|
|
462
594
|
call: request.call,
|
|
463
595
|
risk: request.risk,
|
|
464
596
|
...(request.origin !== undefined ? { origin: request.origin } : {}),
|
|
465
|
-
...(
|
|
466
|
-
...(
|
|
597
|
+
...(oneShot ? { oneShot: true } : {}),
|
|
598
|
+
...(always !== undefined ? { always } : {}),
|
|
467
599
|
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
600
|
+
...(delegatedFrom ? { delegatedFrom } : {}),
|
|
468
601
|
...(expiresAt ? { expiresAt } : {}),
|
|
469
602
|
});
|
|
470
603
|
// Drained at shutdown alongside the resolutions: a channel that has not
|
|
@@ -993,10 +1126,15 @@ export const createGateway = (options = {}) => {
|
|
|
993
1126
|
for (const scheduleTool of createScheduleTools(scheduler.handle)) {
|
|
994
1127
|
tools.register(scheduleTool);
|
|
995
1128
|
}
|
|
996
|
-
tools.register(createMessageSendTool(async ({ agentId, destination, text }) => {
|
|
997
|
-
const connection = await outboundFor(agentId, destination);
|
|
998
|
-
await connection.post(text);
|
|
999
|
-
|
|
1129
|
+
tools.register(createMessageSendTool(async ({ agentId, destination, text, thread }) => {
|
|
1130
|
+
const connection = await outboundFor(agentId, destination, thread);
|
|
1131
|
+
const posted = await connection.post(text);
|
|
1132
|
+
// The channel contract's `OutboundMessageRef`, read structurally: the
|
|
1133
|
+
// gateway's mirror of the adapter types `post` loosely, and an adapter
|
|
1134
|
+
// that reports no id simply has none to hand back.
|
|
1135
|
+
const ts = typeof posted === 'object' && posted !== null ? posted.ts : undefined;
|
|
1136
|
+
return typeof ts === 'string' && ts.length > 0 ? { id: ts } : {};
|
|
1137
|
+
}, { defaultThread: (session, destination) => scheduler.reportThreadFor(session, destination) }));
|
|
1000
1138
|
tools.register(createMessageReadTool(async ({ agentId, source, ...window }) => {
|
|
1001
1139
|
// The same carrying rule as outbound: a read goes through the app of
|
|
1002
1140
|
// the agent asking, never whichever adapter of that kind is running,
|
|
@@ -1056,6 +1194,12 @@ export const createGateway = (options = {}) => {
|
|
|
1056
1194
|
...(session.metadata ? { metadata: session.metadata } : {}),
|
|
1057
1195
|
});
|
|
1058
1196
|
};
|
|
1197
|
+
// A link only for an agent whose soul lists the tool. Every agent may
|
|
1198
|
+
// ask through a form, because a form goes to an approver; a link is a
|
|
1199
|
+
// bearer credential the model is handed, and whoever it reaches can
|
|
1200
|
+
// store a key and amend the soul. That is the operator's to allow.
|
|
1201
|
+
const allowlist = session.agent.tools ?? registry.get(agentId)?.tools;
|
|
1202
|
+
const linkAllowed = allowlist === undefined || matchesToolAllowlist(CREDENTIAL_REQUEST_TOOL_NAME, allowlist);
|
|
1059
1203
|
// Why no form went up, said to the agent beside the link that replaces
|
|
1060
1204
|
// it, so it can tell the person why they are getting a link instead.
|
|
1061
1205
|
let formUnavailable;
|
|
@@ -1099,9 +1243,13 @@ export const createGateway = (options = {}) => {
|
|
|
1099
1243
|
}
|
|
1100
1244
|
}
|
|
1101
1245
|
if (request.via === 'form') {
|
|
1102
|
-
throw new Error(`${formUnavailable} Nothing is pending. Ask for a link instead (via: "link"), or ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
|
|
1246
|
+
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.`);
|
|
1103
1247
|
}
|
|
1104
1248
|
}
|
|
1249
|
+
if (!linkAllowed) {
|
|
1250
|
+
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. `
|
|
1251
|
+
+ `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.`);
|
|
1252
|
+
}
|
|
1105
1253
|
// A link where no form can go, from whichever adapter issues them: the
|
|
1106
1254
|
// control API, which is not tied to any one agent's transport.
|
|
1107
1255
|
const linker = startedChannels.find((candidate) => candidate.requestCredentialLink !== undefined);
|
|
@@ -1289,7 +1437,8 @@ export const createGateway = (options = {}) => {
|
|
|
1289
1437
|
// runtimes take it as their inner limit), so it is part of the key:
|
|
1290
1438
|
// two agents on one model with different budgets get their own runners.
|
|
1291
1439
|
const maxTurns = options.maxTurnsFor?.(agentId) ?? options.maxTurns;
|
|
1292
|
-
const
|
|
1440
|
+
const autoContinue = options.autoContinueFor?.(agentId);
|
|
1441
|
+
const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}`;
|
|
1293
1442
|
const existing = runners.get(key);
|
|
1294
1443
|
if (existing) {
|
|
1295
1444
|
return existing;
|
|
@@ -1319,10 +1468,16 @@ export const createGateway = (options = {}) => {
|
|
|
1319
1468
|
store,
|
|
1320
1469
|
bus,
|
|
1321
1470
|
agents: registry,
|
|
1471
|
+
// Asking a person for a key is harmless to grant: they decide
|
|
1472
|
+
// everything after the question, so no soul has to list it.
|
|
1473
|
+
grantedToEveryAgent: [CREDENTIAL_REQUEST_TOOL_NAME],
|
|
1322
1474
|
skills: skillCatalog,
|
|
1323
1475
|
memory,
|
|
1324
1476
|
streaming: true,
|
|
1325
1477
|
...(maxTurns !== undefined ? { maxTurns } : {}),
|
|
1478
|
+
...(autoContinue !== undefined
|
|
1479
|
+
? { autoContinue, waitingInput: (sessionId) => (waitingDispatches.get(sessionId) ?? 0) > 0 }
|
|
1480
|
+
: {}),
|
|
1326
1481
|
});
|
|
1327
1482
|
hostedRunner = runner;
|
|
1328
1483
|
runners.set(key, runner);
|
|
@@ -1585,8 +1740,30 @@ export const createGateway = (options = {}) => {
|
|
|
1585
1740
|
// behind the in-flight turn instead of interleaving with it. Sessions
|
|
1586
1741
|
// are independent — different conversations run concurrently.
|
|
1587
1742
|
const sessionChains = new Map();
|
|
1743
|
+
/**
|
|
1744
|
+
* Dispatches queued behind a session's running turn, per session: what
|
|
1745
|
+
* tells an auto-continuing turn that somebody wrote meanwhile, so it
|
|
1746
|
+
* wraps up and lets them in rather than keep the session to itself.
|
|
1747
|
+
*/
|
|
1748
|
+
const waitingDispatches = new Map();
|
|
1588
1749
|
const inflight = new Set();
|
|
1589
1750
|
let stopping = false;
|
|
1751
|
+
// Open until `startServing` reads what the last process left, which it
|
|
1752
|
+
// does before any channel or the scheduler can deliver work.
|
|
1753
|
+
let intakeState = { paused: false };
|
|
1754
|
+
let heldState = options.held === true;
|
|
1755
|
+
/** What a held start left for `promote()`: the channels it did not start, and the sweeps it did not run. */
|
|
1756
|
+
let deferredStart;
|
|
1757
|
+
// Pause and resume run one at a time, each writing the file and then the
|
|
1758
|
+
// state, so the two always agree: overlapping, a pause's write could land
|
|
1759
|
+
// after a resume removed the file, leaving the daemon paused in memory
|
|
1760
|
+
// and open on disk — open again after the next restart.
|
|
1761
|
+
let intakeChain = Promise.resolve();
|
|
1762
|
+
const intakeTransition = (work) => {
|
|
1763
|
+
const run = intakeChain.then(work);
|
|
1764
|
+
intakeChain = run.catch(() => undefined);
|
|
1765
|
+
return run;
|
|
1766
|
+
};
|
|
1590
1767
|
/**
|
|
1591
1768
|
* The abort handle of every turn running right now. A restart's drain
|
|
1592
1769
|
* window ends by aborting these — the same signal the watchdog fires —
|
|
@@ -2461,6 +2638,17 @@ export const createGateway = (options = {}) => {
|
|
|
2461
2638
|
input.onRepeat?.('live');
|
|
2462
2639
|
return live.turn;
|
|
2463
2640
|
}
|
|
2641
|
+
// The one door every external dispatch comes through, so a pause holds
|
|
2642
|
+
// for every channel at once. Work already accepted is not behind this
|
|
2643
|
+
// check: a turn running, a redelivery of a message whose turn is live
|
|
2644
|
+
// (answered above), a message queued behind it, a delegation or
|
|
2645
|
+
// an approval resuming a parked turn all go on.
|
|
2646
|
+
if (intakeState.paused) {
|
|
2647
|
+
throw new IntakePausedError(intakeState.message ?? DEFAULT_PAUSED_MESSAGE);
|
|
2648
|
+
}
|
|
2649
|
+
if (heldState) {
|
|
2650
|
+
throw new IntakePausedError(HELD_MESSAGE);
|
|
2651
|
+
}
|
|
2464
2652
|
// The live dispatch named no agent, so whose session it is is known
|
|
2465
2653
|
// only once its turn resolves. Queueing instead would make this a
|
|
2466
2654
|
// `finished` repeat of a turn the live caller is still rendering — two
|
|
@@ -2503,7 +2691,16 @@ export const createGateway = (options = {}) => {
|
|
|
2503
2691
|
items.set(key, { turn, agentId: input.agentId, confirmed, addressed: input.addressed !== false });
|
|
2504
2692
|
liveWorkItems.set(input.sessionId, items);
|
|
2505
2693
|
};
|
|
2694
|
+
waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
|
|
2506
2695
|
const turn = onSessionChain(input.sessionId, async () => {
|
|
2696
|
+
// No longer waiting once it reaches the head of the chain.
|
|
2697
|
+
const left = (waitingDispatches.get(input.sessionId) ?? 1) - 1;
|
|
2698
|
+
if (left > 0) {
|
|
2699
|
+
waitingDispatches.set(input.sessionId, left);
|
|
2700
|
+
}
|
|
2701
|
+
else {
|
|
2702
|
+
waitingDispatches.delete(input.sessionId);
|
|
2703
|
+
}
|
|
2507
2704
|
// Before `activeTurns` is touched: a repeat that runs nothing must
|
|
2508
2705
|
// not claim the session's turn id, even for the instant it takes.
|
|
2509
2706
|
const repeated = idempotencyKey !== undefined ? await settleRepeat(input.sessionId, idempotencyKey, input.agentId, turnId, input.onRepeat) : undefined;
|
|
@@ -2767,6 +2964,10 @@ export const createGateway = (options = {}) => {
|
|
|
2767
2964
|
+ `${reconciled.released.length} claim(s) released`);
|
|
2768
2965
|
}
|
|
2769
2966
|
await loadRoster();
|
|
2967
|
+
intakeState = await readIntakeState(env, warn);
|
|
2968
|
+
if (intakeState.paused) {
|
|
2969
|
+
log(`intake is paused (since ${intakeState.since ?? 'unknown'}); new work will be refused until it resumes`);
|
|
2970
|
+
}
|
|
2770
2971
|
const named = registry.list().map((agent) => agent.name).join(', ');
|
|
2771
2972
|
log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
|
|
2772
2973
|
// Read before anything can dispatch, so this is exactly what the
|
|
@@ -2788,7 +2989,25 @@ export const createGateway = (options = {}) => {
|
|
|
2788
2989
|
...(options.channels ?? []),
|
|
2789
2990
|
...channelContributions.list().map((contribution) => contribution.adapter),
|
|
2790
2991
|
];
|
|
2992
|
+
if (heldState) {
|
|
2993
|
+
// Held: only the operator's surface comes up, and everything that
|
|
2994
|
+
// would touch work waits for promote(), in the same order.
|
|
2995
|
+
await startChannels(adapters.filter((adapter) => adapter.servesWhileHeld === true));
|
|
2996
|
+
deferredStart = { adapters: adapters.filter((adapter) => adapter.servesWhileHeld !== true), orphaned, abandoned };
|
|
2997
|
+
log('stratusd is held: answering health only, until promoted');
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
await startChannels(adapters);
|
|
3001
|
+
await beginWork(orphaned, abandoned);
|
|
3002
|
+
};
|
|
3003
|
+
const startChannels = async (adapters) => {
|
|
2791
3004
|
for (const adapter of adapters) {
|
|
3005
|
+
// A stop that began while an earlier channel was starting stops what
|
|
3006
|
+
// is up; starting more after it would leave one it never sees, or
|
|
3007
|
+
// one whose start never returns holding the stop open.
|
|
3008
|
+
if (stopping) {
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
2792
3011
|
try {
|
|
2793
3012
|
await adapter.start(gateway);
|
|
2794
3013
|
startedChannels.push(adapter);
|
|
@@ -2820,6 +3039,8 @@ export const createGateway = (options = {}) => {
|
|
|
2820
3039
|
}
|
|
2821
3040
|
}
|
|
2822
3041
|
}
|
|
3042
|
+
};
|
|
3043
|
+
const beginWork = async (orphaned, abandoned) => {
|
|
2823
3044
|
// After the channels, so a catch-up firing has somewhere to report and
|
|
2824
3045
|
// the missed-window log lines land in a gateway whose surfaces are
|
|
2825
3046
|
// listening. The sweep inside is quick (store reads); the firings it
|
|
@@ -2839,6 +3060,59 @@ export const createGateway = (options = {}) => {
|
|
|
2839
3060
|
warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2840
3061
|
});
|
|
2841
3062
|
};
|
|
3063
|
+
let promoting;
|
|
3064
|
+
/**
|
|
3065
|
+
* Why the work of a promotion could not begin, once it could not. Final:
|
|
3066
|
+
* the scheduler stops for good when its readiness check fails (a newer
|
|
3067
|
+
* build stamped the home), so a retry would report success with every
|
|
3068
|
+
* schedule silently off. The daemon stays held and says why until it is
|
|
3069
|
+
* restarted.
|
|
3070
|
+
*/
|
|
3071
|
+
let promotionFailure;
|
|
3072
|
+
const promote = async () => {
|
|
3073
|
+
if (!heldState) {
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
if (stopping) {
|
|
3077
|
+
throw refusal();
|
|
3078
|
+
}
|
|
3079
|
+
// One promotion at a time: a second call while the first is connecting
|
|
3080
|
+
// channels waits for it rather than starting them twice.
|
|
3081
|
+
if (promotionFailure !== undefined) {
|
|
3082
|
+
throw promotionFailure;
|
|
3083
|
+
}
|
|
3084
|
+
promoting ??= (async () => {
|
|
3085
|
+
const pending = deferredStart;
|
|
3086
|
+
if (pending === undefined) {
|
|
3087
|
+
throw new Error('The gateway is held but has not finished starting; promote it once health answers.');
|
|
3088
|
+
}
|
|
3089
|
+
// Skipping any a failed earlier promotion already started, so a retry
|
|
3090
|
+
// never starts one twice.
|
|
3091
|
+
await startChannels(pending.adapters.filter((adapter) => !startedChannels.includes(adapter)));
|
|
3092
|
+
// A stop that began while the channels were connecting has waited
|
|
3093
|
+
// for this (see shutdown), and now stops them all. Nothing past here
|
|
3094
|
+
// may start once it has.
|
|
3095
|
+
if (stopping) {
|
|
3096
|
+
throw refusal();
|
|
3097
|
+
}
|
|
3098
|
+
// Held until the work has actually begun: a scheduler that refuses to
|
|
3099
|
+
// start (a newer build stamped the home meanwhile) must leave the
|
|
3100
|
+
// daemon held, reporting so, and a retry able to try again.
|
|
3101
|
+
try {
|
|
3102
|
+
await beginWork(pending.orphaned, pending.abandoned);
|
|
3103
|
+
}
|
|
3104
|
+
catch (error) {
|
|
3105
|
+
promotionFailure = new Error(`This daemon could not begin its work and stays held until it is restarted: ${error instanceof Error ? error.message : String(error)}`);
|
|
3106
|
+
throw promotionFailure;
|
|
3107
|
+
}
|
|
3108
|
+
deferredStart = undefined;
|
|
3109
|
+
heldState = false;
|
|
3110
|
+
log('stratusd promoted: channels connected, schedules running');
|
|
3111
|
+
})().finally(() => {
|
|
3112
|
+
promoting = undefined;
|
|
3113
|
+
});
|
|
3114
|
+
return promoting;
|
|
3115
|
+
};
|
|
2842
3116
|
/**
|
|
2843
3117
|
* Both stores, once. Reached from `stop()` and from a `start()` that
|
|
2844
3118
|
* failed, and a host may call the one after the other; node:sqlite
|
|
@@ -2926,6 +3200,10 @@ export const createGateway = (options = {}) => {
|
|
|
2926
3200
|
// drained; a plain stop goes on closing either way.
|
|
2927
3201
|
let channelsReleased = true;
|
|
2928
3202
|
const drain = (async () => {
|
|
3203
|
+
// A promotion still connecting channels finishes first, so every
|
|
3204
|
+
// channel it started is in the list stopped below; seeing `stopping`,
|
|
3205
|
+
// it starts nothing after that.
|
|
3206
|
+
await promoting?.catch(() => undefined);
|
|
2929
3207
|
await Promise.allSettled([...approvalEmissions]);
|
|
2930
3208
|
const stopping = [...startedChannels];
|
|
2931
3209
|
const stops = await Promise.allSettled(stopping.map((adapter) => adapter.stop()));
|
|
@@ -3395,6 +3673,49 @@ export const createGateway = (options = {}) => {
|
|
|
3395
3673
|
})).then((entries) => entries.filter((entry) => entry !== undefined));
|
|
3396
3674
|
},
|
|
3397
3675
|
resolveApproval,
|
|
3676
|
+
intake() {
|
|
3677
|
+
return { ...intakeState };
|
|
3678
|
+
},
|
|
3679
|
+
async pauseIntake(input = {}) {
|
|
3680
|
+
let message;
|
|
3681
|
+
if (input.message !== undefined) {
|
|
3682
|
+
const trimmed = input.message.trim();
|
|
3683
|
+
if (trimmed.length === 0) {
|
|
3684
|
+
throw new PauseMessageError('A pause message cannot be empty; leave it out to use the default.');
|
|
3685
|
+
}
|
|
3686
|
+
if (trimmed.length > MAX_PAUSED_MESSAGE_LENGTH) {
|
|
3687
|
+
throw new PauseMessageError(`A pause message is at most ${MAX_PAUSED_MESSAGE_LENGTH} characters; this one is ${trimmed.length}.`);
|
|
3688
|
+
}
|
|
3689
|
+
message = trimmed;
|
|
3690
|
+
}
|
|
3691
|
+
return intakeTransition(async () => {
|
|
3692
|
+
const next = {
|
|
3693
|
+
paused: true,
|
|
3694
|
+
...(message !== undefined ? { message } : {}),
|
|
3695
|
+
since: intakeState.paused && intakeState.since ? intakeState.since : new Date().toISOString(),
|
|
3696
|
+
};
|
|
3697
|
+
// Durable first: a pause the process reports but would forget at
|
|
3698
|
+
// the next crash is the gap the file exists to close.
|
|
3699
|
+
await writeIntakeState(env, next);
|
|
3700
|
+
intakeState = next;
|
|
3701
|
+
log('intake paused: new work will be refused until it resumes');
|
|
3702
|
+
return { ...intakeState };
|
|
3703
|
+
});
|
|
3704
|
+
},
|
|
3705
|
+
held() {
|
|
3706
|
+
return heldState;
|
|
3707
|
+
},
|
|
3708
|
+
promote,
|
|
3709
|
+
async resumeIntake() {
|
|
3710
|
+
return intakeTransition(async () => {
|
|
3711
|
+
await writeIntakeState(env, { paused: false });
|
|
3712
|
+
if (intakeState.paused) {
|
|
3713
|
+
log('intake resumed');
|
|
3714
|
+
}
|
|
3715
|
+
intakeState = { paused: false };
|
|
3716
|
+
return { ...intakeState };
|
|
3717
|
+
});
|
|
3718
|
+
},
|
|
3398
3719
|
provideCredential,
|
|
3399
3720
|
};
|
|
3400
3721
|
return gateway;
|