@stratusagent/gateway 0.11.6 → 0.11.7
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/README.md +3 -2
- package/dist/index.d.ts +163 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +848 -60
- package/dist/index.js.map +1 -1
- package/dist/lock.d.ts +8 -21
- package/dist/lock.d.ts.map +1 -1
- package/dist/lock.js +16 -99
- package/dist/lock.js.map +1 -1
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -7,8 +7,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
9
|
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
-
import { filePathsOf, isUnaddressedTurn, 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, } from '@stratusagent/core';
|
|
11
|
-
import { createDelegateTool, createForgetTool, createPinTool, createMessageSendTool, 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, } from '@stratusagent/agents';
|
|
10
|
+
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, } from '@stratusagent/core';
|
|
11
|
+
import { createCredentialRequestTool, createDelegateTool, createForgetTool, createPinTool, createMessageSendTool, 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
12
|
import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
|
|
13
13
|
import { ShardedSessionStore } from "./sessions.js";
|
|
14
14
|
export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
|
|
@@ -16,7 +16,7 @@ export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTa
|
|
|
16
16
|
export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
|
|
17
17
|
import { createLocalCommandExecutor } from '@stratusagent/executor-local';
|
|
18
18
|
import { loadPlugins, } from '@stratusagent/plugins';
|
|
19
|
-
import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, applySoulPins, assertStateCompatible, stratusHomePath, fleetDbIn, createAgentWorkspaces, foldedAgentId, } from '@stratusagent/state';
|
|
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, foldedAgentId, } from '@stratusagent/state';
|
|
20
20
|
const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
|
21
21
|
/**
|
|
22
22
|
* How long a parked call waits for a person. Long enough to survive a
|
|
@@ -70,12 +70,17 @@ export class RestartUnsupportedError extends Error {
|
|
|
70
70
|
* its own guess at the wording.
|
|
71
71
|
*/
|
|
72
72
|
export const ABANDONED_TURN_ERROR = 'stratusd stopped while this turn was still running; it was not resumed. Send the message again.';
|
|
73
|
+
/**
|
|
74
|
+
* The longest `DispatchInput.idempotencyKey` accepted. A platform message id
|
|
75
|
+
* is far shorter; the bound is so a key cannot be used to grow every row it
|
|
76
|
+
* is stored on.
|
|
77
|
+
*/
|
|
73
78
|
/**
|
|
74
79
|
* Recorded on a delegated sub-session that was parked on a human when the
|
|
75
80
|
* previous process died.
|
|
76
81
|
*
|
|
77
82
|
* Its parent — the turn whose `agent.delegate` call was awaiting the reply
|
|
78
|
-
* — was running, so the restart
|
|
83
|
+
* — was running, so the restart settles it as abandoned (above). Recovering
|
|
79
84
|
* the child anyway would re-ask a person to approve a call whose result no
|
|
80
85
|
* turn will ever read, and then run it: a command executed, tokens spent,
|
|
81
86
|
* and a reply delivered to nobody. Distinguishable from the parent's error
|
|
@@ -106,6 +111,29 @@ export const ROLLED_OVER_FROM_METADATA_KEY = 'rolledOverFrom';
|
|
|
106
111
|
export const ROLLED_OVER_TO_METADATA_KEY = 'rolledOverTo';
|
|
107
112
|
/** The segment a rollover mints into the archived transcript's id. */
|
|
108
113
|
export const ROLLED_OVER_SESSION_ID_MARKER = ':rolledover:';
|
|
114
|
+
/**
|
|
115
|
+
* How long after a rollover a repeated key is still looked for in the
|
|
116
|
+
* archived transcript. A rollover moves every keyed message out of the
|
|
117
|
+
* live row, so a redelivery arriving after one would otherwise read as a
|
|
118
|
+
* new message and run again. Platforms redeliver within minutes — Slack
|
|
119
|
+
* gives up after about five — and past the window the archive is not
|
|
120
|
+
* loaded at all, since every new message to the fresh row would pay for a
|
|
121
|
+
* read that can no longer find anything.
|
|
122
|
+
*/
|
|
123
|
+
const ROLLOVER_REPEAT_WINDOW_MS = 60 * 60 * 1000;
|
|
124
|
+
/**
|
|
125
|
+
* The one refusal for a dispatch naming an agent other than its session's.
|
|
126
|
+
* Its own type so a repeat attached to a claimant can tell the claimant was
|
|
127
|
+
* refused for who it named — the item then still has no owner — from the
|
|
128
|
+
* claimant's turn failing. Internal: callers read the message.
|
|
129
|
+
*/
|
|
130
|
+
class CrossIdentityError extends Error {
|
|
131
|
+
constructor(sessionId, owner, claimed) {
|
|
132
|
+
super(`Session ${sessionId} belongs to agent ${owner}, not ${claimed} — sessions never cross agent identities.`);
|
|
133
|
+
this.name = 'CrossIdentityError';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const crossIdentityError = (sessionId, owner, claimed) => new CrossIdentityError(sessionId, owner, claimed);
|
|
109
137
|
/** Where the session's tool calls ran: `local-command`, or a contributed executor's registered name. */
|
|
110
138
|
export const EXECUTOR_METADATA_KEY = 'executor';
|
|
111
139
|
export const RESERVED_SESSION_METADATA_KEYS = [
|
|
@@ -191,26 +219,26 @@ export const createGateway = (options = {}) => {
|
|
|
191
219
|
// schedule store's own connection (see its docs for why).
|
|
192
220
|
const scheduleStore = new SqliteScheduleStore(fleetDbIn(stateDir), { stateHome: stateDir });
|
|
193
221
|
/**
|
|
194
|
-
* The
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
222
|
+
* The started adapter of one kind that speaks for an agent and has the
|
|
223
|
+
* capability asked for. `carriesOthers` says there were capable adapters
|
|
224
|
+
* of that kind, just none carrying this agent, which callers word
|
|
225
|
+
* differently from there being none at all.
|
|
226
|
+
*
|
|
227
|
+
* One kind may be served by several adapters carrying disjoint
|
|
228
|
+
* agents — two plugins, or the host's adapter beside a plugin's — and
|
|
229
|
+
* the one that speaks for this agent is the one whose claims include
|
|
230
|
+
* it: a contribution's agent list, or the host's declared claims for
|
|
231
|
+
* an adapter no plugin contributed. A host that declared no claims for
|
|
232
|
+
* the kind left its adapter unclaimed, and an unclaimed adapter speaks
|
|
233
|
+
* for anyone, as the single host adapter always did. A claim miss is
|
|
234
|
+
* a refusal, never the first adapter that happens to be running: that
|
|
235
|
+
* adapter posts under another agent's transport identity, and it is
|
|
236
|
+
* exactly what a failed start of the right adapter would leave behind.
|
|
199
237
|
*/
|
|
200
|
-
const
|
|
201
|
-
const candidates = startedChannels.filter((candidate) => candidate.name ===
|
|
202
|
-
// One kind may be served by several adapters carrying disjoint
|
|
203
|
-
// agents — two plugins, or the host's adapter beside a plugin's — and
|
|
204
|
-
// the one that speaks for this agent is the one whose claims include
|
|
205
|
-
// it: a contribution's agent list, or the host's declared claims for
|
|
206
|
-
// an adapter no plugin contributed. A host that declared no claims for
|
|
207
|
-
// the kind left its adapter unclaimed, and an unclaimed adapter speaks
|
|
208
|
-
// for anyone, as the single host adapter always did. A claim miss is
|
|
209
|
-
// a refusal, never the first adapter that happens to be running: that
|
|
210
|
-
// adapter posts under another agent's transport identity, and it is
|
|
211
|
-
// exactly what a failed start of the right adapter would leave behind.
|
|
238
|
+
const channelCarrying = (kind, agentId, capable) => {
|
|
239
|
+
const candidates = startedChannels.filter((candidate) => candidate.name === kind && capable(candidate));
|
|
212
240
|
const contributed = channelContributions.list();
|
|
213
|
-
const hostClaims = (options.hostChannelClaims ?? []).filter((claim) => claim.kind ===
|
|
241
|
+
const hostClaims = (options.hostChannelClaims ?? []).filter((claim) => claim.kind === kind);
|
|
214
242
|
const carries = (candidate) => {
|
|
215
243
|
const entry = contributed.find((contribution) => contribution.adapter === candidate);
|
|
216
244
|
if (entry) {
|
|
@@ -219,7 +247,18 @@ export const createGateway = (options = {}) => {
|
|
|
219
247
|
return hostClaims.length === 0 || hostClaims.some((claim) => claim.agents.includes(agentId));
|
|
220
248
|
};
|
|
221
249
|
const adapter = candidates.find(carries);
|
|
222
|
-
|
|
250
|
+
return adapter ? { adapter, carriesOthers: false } : { carriesOthers: candidates.length > 0 };
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* The write side of an addressable destination, through whichever
|
|
254
|
+
* started channel serves its kind. Resolution happens per call against
|
|
255
|
+
* the live adapter list, so a channel that failed to start is honestly
|
|
256
|
+
* "cannot address" rather than a stale reference — and rejecting is how
|
|
257
|
+
* both callers (schedule creation, `message.send`) hear why.
|
|
258
|
+
*/
|
|
259
|
+
const outboundFor = async (agentId, destination) => {
|
|
260
|
+
const { adapter, carriesOthers } = channelCarrying(destination.channel, agentId, (candidate) => candidate.resolveOutbound !== undefined);
|
|
261
|
+
if (!adapter?.resolveOutbound && carriesOthers) {
|
|
223
262
|
throw new Error(`No running '${destination.channel}' channel carries agent ${agentId} — `
|
|
224
263
|
+ `the running ${destination.channel} adapters carry other agents, so a message from ${agentId} has no transport identity to post under.`);
|
|
225
264
|
}
|
|
@@ -765,6 +804,7 @@ export const createGateway = (options = {}) => {
|
|
|
765
804
|
*/
|
|
766
805
|
const loadSkills = async () => {
|
|
767
806
|
const next = new SkillRegistry();
|
|
807
|
+
await registerBuiltinSkills(next, true);
|
|
768
808
|
const operator = await loadOperatorSkills(env, next, warn, { strict: true });
|
|
769
809
|
for (const plugin of loadedPlugins) {
|
|
770
810
|
for (const record of plugin.skills) {
|
|
@@ -862,6 +902,36 @@ export const createGateway = (options = {}) => {
|
|
|
862
902
|
}
|
|
863
903
|
return source;
|
|
864
904
|
};
|
|
905
|
+
const agentWorkspaces = createAgentWorkspaces(env);
|
|
906
|
+
/**
|
|
907
|
+
* What an agent is told about how it runs, from the source this dispatch
|
|
908
|
+
* refreshed and never from `sources` again. The persona the turn runs on
|
|
909
|
+
* came from that snapshot, and a repointed default soul can replace the
|
|
910
|
+
* registered source while the turn is still in preflight: read afresh,
|
|
911
|
+
* the agent would be told a file it is not running on is its soul.
|
|
912
|
+
*
|
|
913
|
+
* `soulReloads` is true because `refreshAgent` re-reads the file on every
|
|
914
|
+
* dispatch. A refresh that failed serves from cache and carries no path,
|
|
915
|
+
* so the agent is told nothing about a soul file for that turn rather
|
|
916
|
+
* than something the daemon could not just read. The built-in agent has
|
|
917
|
+
* no file, so it is told only where its workspace is.
|
|
918
|
+
*
|
|
919
|
+
* The language and the model come from the config this same dispatch
|
|
920
|
+
* resolved for that source, for the same reason: the turn runs on it.
|
|
921
|
+
*/
|
|
922
|
+
const runtimeContextFor = (source, config, onFallback,
|
|
923
|
+
// The turn's own metadata, where a channel records who can read the
|
|
924
|
+
// conversation now; a recovered turn has none and passes the session's.
|
|
925
|
+
metadata) => {
|
|
926
|
+
const conversation = conversationContextFrom(metadata);
|
|
927
|
+
return {
|
|
928
|
+
...(source.soulPath !== undefined ? { soulPath: source.soulPath, soulReloads: true } : {}),
|
|
929
|
+
workspace: agentWorkspaces.forAgent(source.definition.id),
|
|
930
|
+
...(config.language !== undefined ? { language: config.language } : {}),
|
|
931
|
+
model: describeServingModel(config, onFallback),
|
|
932
|
+
...(conversation !== undefined ? { conversation } : {}),
|
|
933
|
+
};
|
|
934
|
+
};
|
|
865
935
|
// ---- runner pool --------------------------------------------------------
|
|
866
936
|
const tools = new ToolRegistry();
|
|
867
937
|
// Which package each tool came from. Kernel tools are absent from this
|
|
@@ -883,6 +953,23 @@ export const createGateway = (options = {}) => {
|
|
|
883
953
|
let pluginFailures = [];
|
|
884
954
|
const skillCatalog = new SkillRegistry();
|
|
885
955
|
let operatorSkills = [];
|
|
956
|
+
/**
|
|
957
|
+
* The skills that ship with Stratus, first into every catalog, so the id
|
|
958
|
+
* is theirs before an operator directory or a plugin can claim it. A file
|
|
959
|
+
* missing from a broken install is said once and costs the agents their
|
|
960
|
+
* reference, never the daemon its start.
|
|
961
|
+
*/
|
|
962
|
+
const registerBuiltinSkills = async (registry, reloading = false) => {
|
|
963
|
+
try {
|
|
964
|
+
registry.register(await loadStratusSkill());
|
|
965
|
+
}
|
|
966
|
+
catch (error) {
|
|
967
|
+
// On a reload the swap keeps the copy already serving
|
|
968
|
+
// (`SkillRegistry.replaceWith`), so only a start goes without it.
|
|
969
|
+
const consequence = reloading ? 'the copy loaded at start keeps serving' : 'agents will not have it';
|
|
970
|
+
warn(`the built-in stratus skill did not load (${error instanceof Error ? error.message : String(error)}); ${consequence}. Reinstall Stratus.`);
|
|
971
|
+
}
|
|
972
|
+
};
|
|
886
973
|
tools.register(createDemoTool());
|
|
887
974
|
tools.register(createRememberTool(memory));
|
|
888
975
|
tools.register(createRecallTool(memory));
|
|
@@ -910,6 +997,259 @@ export const createGateway = (options = {}) => {
|
|
|
910
997
|
const connection = await outboundFor(agentId, destination);
|
|
911
998
|
await connection.post(text);
|
|
912
999
|
}));
|
|
1000
|
+
/**
|
|
1001
|
+
* Credential requests waiting on a person, by request id.
|
|
1002
|
+
*
|
|
1003
|
+
* In memory on purpose: a request is a question somebody is being asked
|
|
1004
|
+
* now, and a restart that forgot it only means the agent asks again. What
|
|
1005
|
+
* matters is that the entry carries everything the answer needs, so a
|
|
1006
|
+
* channel quoting the id back decides nothing about where the key goes.
|
|
1007
|
+
*/
|
|
1008
|
+
const credentialRequests = new Map();
|
|
1009
|
+
tools.register(createCredentialRequestTool(async (request, session) => {
|
|
1010
|
+
const agentId = session.agent.id;
|
|
1011
|
+
const soulPath = sources.get(agentId)?.soulPath;
|
|
1012
|
+
if (soulPath === undefined) {
|
|
1013
|
+
throw new Error(`${session.agent.name} is the built-in agent and has no soul file to grant a credential in. `
|
|
1014
|
+
+ `Your operator can store one on the machine with \`stratus credential set ${request.name}\`.`);
|
|
1015
|
+
}
|
|
1016
|
+
if (!CREDENTIAL_NAME_PATTERN.test(request.name)) {
|
|
1017
|
+
throw new Error(`${JSON.stringify(request.name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
|
|
1018
|
+
+ 'starting with a letter, the way the tool that needs it spells it: search.apiKey, github.token.');
|
|
1019
|
+
}
|
|
1020
|
+
// A key already stored is not something to ask a person for again: the
|
|
1021
|
+
// form only adds, so it could only fail. Say what is actually missing.
|
|
1022
|
+
const source = await namedCredentialSource(env, agentId, request.name);
|
|
1023
|
+
const stored = source !== undefined;
|
|
1024
|
+
const granted = session.agent.credentials?.includes(request.name) === true;
|
|
1025
|
+
if (stored && granted) {
|
|
1026
|
+
throw new Error(`You already hold ${request.name}; the tools that need it use it for you. There is nothing to ask for.`);
|
|
1027
|
+
}
|
|
1028
|
+
if (stored) {
|
|
1029
|
+
throw new Error(`${request.name} is already ${source === 'environment' ? "supplied by the daemon's environment" : 'stored'} but not granted to you. `
|
|
1030
|
+
+ 'Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.');
|
|
1031
|
+
}
|
|
1032
|
+
const onMachine = `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``;
|
|
1033
|
+
const record = { agentId, sessionId: session.id, name: request.name, scope: request.scope, soulPath };
|
|
1034
|
+
const announce = async (requestId, via) => {
|
|
1035
|
+
await bus.emit({
|
|
1036
|
+
type: 'credential.requested',
|
|
1037
|
+
sessionId: session.id,
|
|
1038
|
+
agentId,
|
|
1039
|
+
requestId,
|
|
1040
|
+
name: request.name,
|
|
1041
|
+
scope: request.scope,
|
|
1042
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1043
|
+
via,
|
|
1044
|
+
...(session.metadata ? { metadata: session.metadata } : {}),
|
|
1045
|
+
});
|
|
1046
|
+
};
|
|
1047
|
+
// Why no form went up, said to the agent beside the link that replaces
|
|
1048
|
+
// it, so it can tell the person why they are getting a link instead.
|
|
1049
|
+
let formUnavailable;
|
|
1050
|
+
if (request.via !== 'link') {
|
|
1051
|
+
// Only a conversation a channel started has anywhere to show a form,
|
|
1052
|
+
// and only a channel that renders one can show it: a scheduled or
|
|
1053
|
+
// HTTP turn, or a channel with no form, would record a request nobody
|
|
1054
|
+
// sees while the agent tells its user the operator was asked.
|
|
1055
|
+
const kind = session.metadata?.channel;
|
|
1056
|
+
const { adapter } = typeof kind === 'string'
|
|
1057
|
+
? channelCarrying(kind, agentId, (candidate) => candidate.requestCredential !== undefined)
|
|
1058
|
+
: { adapter: undefined };
|
|
1059
|
+
if (!adapter?.requestCredential || !session.metadata) {
|
|
1060
|
+
formUnavailable = typeof kind === 'string'
|
|
1061
|
+
? `This conversation's channel (${kind}) cannot show your operator a credential form here.`
|
|
1062
|
+
: 'This conversation is not in a channel that can show your operator a form.';
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
const requestId = randomUUID();
|
|
1066
|
+
credentialRequests.set(requestId, record);
|
|
1067
|
+
// Delivered before it is announced, and dropped if delivery fails: a
|
|
1068
|
+
// request whose post never landed has no button to answer it, so
|
|
1069
|
+
// keeping it would leave it pending for good behind a tool result
|
|
1070
|
+
// saying the operator was asked.
|
|
1071
|
+
try {
|
|
1072
|
+
await adapter.requestCredential({
|
|
1073
|
+
sessionId: session.id,
|
|
1074
|
+
agentId,
|
|
1075
|
+
requestId,
|
|
1076
|
+
name: request.name,
|
|
1077
|
+
scope: request.scope,
|
|
1078
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1079
|
+
metadata: session.metadata,
|
|
1080
|
+
});
|
|
1081
|
+
await announce(requestId, 'form');
|
|
1082
|
+
return { requestId, via: 'form' };
|
|
1083
|
+
}
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
credentialRequests.delete(requestId);
|
|
1086
|
+
formUnavailable = `The form could not be shown here: ${error instanceof Error ? error.message : String(error)}`;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (request.via === 'form') {
|
|
1090
|
+
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.`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
// A link where no form can go, from whichever adapter issues them: the
|
|
1094
|
+
// control API, which is not tied to any one agent's transport.
|
|
1095
|
+
const linker = startedChannels.find((candidate) => candidate.requestCredentialLink !== undefined);
|
|
1096
|
+
if (!linker?.requestCredentialLink) {
|
|
1097
|
+
throw new Error(`${formUnavailable !== undefined ? `${formUnavailable} ` : ''}No control API is running to issue a link either. `
|
|
1098
|
+
+ `Ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
|
|
1099
|
+
}
|
|
1100
|
+
const requestId = randomUUID();
|
|
1101
|
+
credentialRequests.set(requestId, record);
|
|
1102
|
+
let link;
|
|
1103
|
+
try {
|
|
1104
|
+
link = await linker.requestCredentialLink({
|
|
1105
|
+
sessionId: session.id,
|
|
1106
|
+
agentId,
|
|
1107
|
+
agentName: session.agent.name,
|
|
1108
|
+
requestId,
|
|
1109
|
+
name: request.name,
|
|
1110
|
+
scope: request.scope,
|
|
1111
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
catch (error) {
|
|
1115
|
+
credentialRequests.delete(requestId);
|
|
1116
|
+
throw new Error(`A link for ${request.name} could not be issued: ${error instanceof Error ? error.message : String(error)} `
|
|
1117
|
+
+ `Nothing is pending. Ask your operator to store it on the machine with ${onMachine}.`);
|
|
1118
|
+
}
|
|
1119
|
+
await announce(requestId, 'link');
|
|
1120
|
+
return {
|
|
1121
|
+
requestId,
|
|
1122
|
+
via: 'link',
|
|
1123
|
+
url: link.url,
|
|
1124
|
+
expiresAt: link.expiresAt,
|
|
1125
|
+
...(link.localOnly === true ? { localOnly: true } : {}),
|
|
1126
|
+
...(formUnavailable !== undefined ? { formUnavailable } : {}),
|
|
1127
|
+
};
|
|
1128
|
+
}));
|
|
1129
|
+
/**
|
|
1130
|
+
* Requests an answer is being stored for now. Claimed before the first
|
|
1131
|
+
* await: two approvers submitting one form at once would otherwise both
|
|
1132
|
+
* start storing, and the second's add-only conflict would retire a
|
|
1133
|
+
* request the first was about to settle as added.
|
|
1134
|
+
*/
|
|
1135
|
+
const answering = new Set();
|
|
1136
|
+
const provideCredential = async (input) => {
|
|
1137
|
+
const request = credentialRequests.get(input.requestId);
|
|
1138
|
+
if (!request) {
|
|
1139
|
+
return { ok: false, retired: true, message: 'That credential request is no longer pending. Ask the agent to request it again.' };
|
|
1140
|
+
}
|
|
1141
|
+
if (answering.has(input.requestId)) {
|
|
1142
|
+
return { ok: false, message: 'Someone else\'s answer to this request is being stored right now. Wait a moment before trying again.' };
|
|
1143
|
+
}
|
|
1144
|
+
answering.add(input.requestId);
|
|
1145
|
+
try {
|
|
1146
|
+
return await answerCredentialRequest(input, request);
|
|
1147
|
+
}
|
|
1148
|
+
finally {
|
|
1149
|
+
answering.delete(input.requestId);
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
const answerCredentialRequest = async (input, request) => {
|
|
1153
|
+
// The agent must still be served from the file the request captured: a
|
|
1154
|
+
// repointed default soul (same id, another file) replaces the source,
|
|
1155
|
+
// and a grant landing in the old file would report a key the agent now
|
|
1156
|
+
// running cannot use.
|
|
1157
|
+
const served = sources.get(request.agentId)?.soulPath;
|
|
1158
|
+
if (served !== request.soulPath) {
|
|
1159
|
+
credentialRequests.delete(input.requestId);
|
|
1160
|
+
return {
|
|
1161
|
+
ok: false,
|
|
1162
|
+
retired: true,
|
|
1163
|
+
message: `${request.agentId} is no longer served from ${request.soulPath}, so nothing was stored. Ask ${request.agentId} to request it again.`,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
// The soul the grant will land in must still be the requester's, and is
|
|
1167
|
+
// checked before anything is stored: a file reassigned to another agent
|
|
1168
|
+
// while the request waited would otherwise receive a key nobody meant
|
|
1169
|
+
// to give it. `grantSoulCredential` checks again as it writes.
|
|
1170
|
+
let owner;
|
|
1171
|
+
try {
|
|
1172
|
+
owner = (await loadSoulFile(request.soulPath)).agent.id;
|
|
1173
|
+
}
|
|
1174
|
+
catch (error) {
|
|
1175
|
+
return {
|
|
1176
|
+
ok: false,
|
|
1177
|
+
message: `${request.agentId}'s soul could not be read (${error instanceof Error ? error.message : String(error)}), so nothing was stored.`,
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
if (owner !== request.agentId) {
|
|
1181
|
+
credentialRequests.delete(input.requestId);
|
|
1182
|
+
return {
|
|
1183
|
+
ok: false,
|
|
1184
|
+
retired: true,
|
|
1185
|
+
message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
await addNamedCredential(env, {
|
|
1190
|
+
name: request.name,
|
|
1191
|
+
value: input.value,
|
|
1192
|
+
...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
catch (error) {
|
|
1196
|
+
// A name stored since the request was made (another form for the
|
|
1197
|
+
// same shared key, or the machine) is final under add-only: no value
|
|
1198
|
+
// typed into this form can ever land, so the request is retired
|
|
1199
|
+
// rather than left answering every submission with the same refusal.
|
|
1200
|
+
if (error instanceof NamedCredentialExistsError) {
|
|
1201
|
+
credentialRequests.delete(input.requestId);
|
|
1202
|
+
return { ok: false, retired: true, message: error.message };
|
|
1203
|
+
}
|
|
1204
|
+
// Otherwise refused, not failed: the request stays pending, so a
|
|
1205
|
+
// person who typed an empty value can try again from the same form.
|
|
1206
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
1207
|
+
}
|
|
1208
|
+
credentialRequests.delete(input.requestId);
|
|
1209
|
+
// Stored first, granted second: a key nobody may use is recoverable by
|
|
1210
|
+
// editing the soul, while a soul naming a key that was never stored
|
|
1211
|
+
// fails every call that reaches for it. Announced either way, because
|
|
1212
|
+
// the store already changed and a shared key is already usable.
|
|
1213
|
+
//
|
|
1214
|
+
// The served path is checked again under the soul lock, which roster
|
|
1215
|
+
// reloads take too: a default repointed while the key was being
|
|
1216
|
+
// written, or while the grant queued behind another soul edit, would
|
|
1217
|
+
// otherwise be granted in a file the running agent no longer reads.
|
|
1218
|
+
let granted = false;
|
|
1219
|
+
let grantError;
|
|
1220
|
+
try {
|
|
1221
|
+
granted = await grantSoulCredential(request.soulPath, request.name, request.agentId, {
|
|
1222
|
+
check: () => {
|
|
1223
|
+
const servedNow = sources.get(request.agentId)?.soulPath;
|
|
1224
|
+
if (servedNow !== request.soulPath) {
|
|
1225
|
+
throw new Error(`${request.agentId} is now served from ${servedNow ?? 'no soul file'}, not ${request.soulPath}`);
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
catch (error) {
|
|
1231
|
+
grantError = error instanceof Error ? error.message : String(error);
|
|
1232
|
+
}
|
|
1233
|
+
await bus.emit({
|
|
1234
|
+
type: 'credential.provided',
|
|
1235
|
+
sessionId: request.sessionId,
|
|
1236
|
+
agentId: request.agentId,
|
|
1237
|
+
requestId: input.requestId,
|
|
1238
|
+
name: request.name,
|
|
1239
|
+
scope: request.scope,
|
|
1240
|
+
...(input.actor !== undefined ? { actor: input.actor } : {}),
|
|
1241
|
+
...(grantError !== undefined ? { grantError } : {}),
|
|
1242
|
+
});
|
|
1243
|
+
if (grantError !== undefined) {
|
|
1244
|
+
return {
|
|
1245
|
+
ok: false,
|
|
1246
|
+
retired: true,
|
|
1247
|
+
message: `${request.name} was stored, but could not be added to ${request.soulPath} `
|
|
1248
|
+
+ `(${grantError}). Add it to the soul's credentials list by hand.`,
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
return { ok: true, name: request.name, scope: request.scope, agentId: request.agentId, granted };
|
|
1252
|
+
};
|
|
913
1253
|
tools.register(createDelegateTool({
|
|
914
1254
|
registry,
|
|
915
1255
|
dispatch: (input) => dispatchInternal({
|
|
@@ -1095,6 +1435,16 @@ export const createGateway = (options = {}) => {
|
|
|
1095
1435
|
// Set at a text-only provider.response: the turn is wrapping up
|
|
1096
1436
|
// (saves, completion events); nothing left for the timer to guard.
|
|
1097
1437
|
let turnSettling = false;
|
|
1438
|
+
/**
|
|
1439
|
+
* What the watchdog last heard, for the warning it logs when it fires.
|
|
1440
|
+
*
|
|
1441
|
+
* "No activity for 120000ms" alone could not tell a provider that went
|
|
1442
|
+
* quiet mid-stream from one whose progress never reached the bus at
|
|
1443
|
+
* all, and that is the first question every abort raised. Event and
|
|
1444
|
+
* delta types only, never content: this lands in the daemon log.
|
|
1445
|
+
*/
|
|
1446
|
+
let lastHeard = 'nothing since the turn began';
|
|
1447
|
+
let deltasHeard = 0;
|
|
1098
1448
|
const suspendTimer = () => {
|
|
1099
1449
|
if (timer) {
|
|
1100
1450
|
clearTimeout(timer);
|
|
@@ -1109,7 +1459,8 @@ export const createGateway = (options = {}) => {
|
|
|
1109
1459
|
// Deliberately not unref'd: while a provider await is in flight, this
|
|
1110
1460
|
// timer is what guarantees the process can always make progress on it.
|
|
1111
1461
|
timer = setTimeout(() => {
|
|
1112
|
-
warn(`watchdog: no activity on session ${sessionId} for ${effectiveIdleMs}ms; aborting the turn`
|
|
1462
|
+
warn(`watchdog: no activity on session ${sessionId} for ${effectiveIdleMs}ms; aborting the turn `
|
|
1463
|
+
+ `(last heard: ${lastHeard}; ${deltasHeard} provider deltas this turn)`);
|
|
1113
1464
|
// The reason rides on the signal, because the runner is what fails
|
|
1114
1465
|
// the session: it writes `lastError` and emits `session.failed`
|
|
1115
1466
|
// before this wrapper ever sees the rejection. Rethrowing a better
|
|
@@ -1132,6 +1483,13 @@ export const createGateway = (options = {}) => {
|
|
|
1132
1483
|
if (!('sessionId' in event) || event.sessionId !== sessionId) {
|
|
1133
1484
|
return;
|
|
1134
1485
|
}
|
|
1486
|
+
if (event.type === 'provider.delta') {
|
|
1487
|
+
deltasHeard += 1;
|
|
1488
|
+
lastHeard = `provider.delta ${event.delta.type}`;
|
|
1489
|
+
}
|
|
1490
|
+
else {
|
|
1491
|
+
lastHeard = event.type;
|
|
1492
|
+
}
|
|
1135
1493
|
switch (event.type) {
|
|
1136
1494
|
case 'provider.delta':
|
|
1137
1495
|
// Only a *fallback* reset marks the mid-turn, session-sticky
|
|
@@ -1254,6 +1612,22 @@ export const createGateway = (options = {}) => {
|
|
|
1254
1612
|
* session sufficient.
|
|
1255
1613
|
*/
|
|
1256
1614
|
const activeTurns = new Map();
|
|
1615
|
+
/**
|
|
1616
|
+
* Keyed dispatches not yet settled, by session and key — what a repeat
|
|
1617
|
+
* attaches to. In memory on purpose: it only has to cover the turns this
|
|
1618
|
+
* process is running, and the transcript covers the rest.
|
|
1619
|
+
*/
|
|
1620
|
+
// Nested rather than one joined string: both halves are the caller's, and
|
|
1621
|
+
// no separator is one a caller cannot also put inside a key. The agent
|
|
1622
|
+
// the dispatch named rides along, so a repeat naming another is not
|
|
1623
|
+
// handed a session that is not its agent's.
|
|
1624
|
+
// `confirmed` once the chain has seen the dispatch past the identity
|
|
1625
|
+
// check with a key the session had not seen (`claimLiveItem`); until
|
|
1626
|
+
// then the entry is a claim that may yet be refused. `addressed` is false
|
|
1627
|
+
// for a turn nobody asked for, which `holdsMessage` does not count.
|
|
1628
|
+
const liveWorkItems = new Map();
|
|
1629
|
+
/** The start-up snapshot `recoverParkedTurns` judges orphans from, for a repeat that gets there first. */
|
|
1630
|
+
let orphanedAtStart = new Set();
|
|
1257
1631
|
/**
|
|
1258
1632
|
* The runtime one agent would run on right now — config snapshot, soul
|
|
1259
1633
|
* pins, degradation and all.
|
|
@@ -1324,6 +1698,33 @@ export const createGateway = (options = {}) => {
|
|
|
1324
1698
|
// failure from here is a credential or provider problem and surfaces.
|
|
1325
1699
|
return resolveRuntimeConfig(selection, resolveEnv);
|
|
1326
1700
|
};
|
|
1701
|
+
/**
|
|
1702
|
+
* Whether the turn about to run streams deltas, which arms the idle
|
|
1703
|
+
* watchdog — on the sticky fallback when the session already switched to
|
|
1704
|
+
* it, since that is the provider the turn will be served by.
|
|
1705
|
+
*/
|
|
1706
|
+
const watchdogStreamsFor = (session, config) => {
|
|
1707
|
+
const switchedToFallback = session?.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true;
|
|
1708
|
+
const fallbackStreams = config.provider !== 'demo' && config.fallback
|
|
1709
|
+
? fallbackStreamsDeltas(config.fallback)
|
|
1710
|
+
: false;
|
|
1711
|
+
const effectiveStreams = switchedToFallback && config.provider !== 'demo' && config.fallback
|
|
1712
|
+
? fallbackStreamsDeltas(config.fallback)
|
|
1713
|
+
: streamsDeltas(config);
|
|
1714
|
+
return { switchedToFallback, effectiveStreams, fallbackStreams };
|
|
1715
|
+
};
|
|
1716
|
+
/**
|
|
1717
|
+
* Whether a turn on this config could be served by a harness
|
|
1718
|
+
* (`runsOnHarness`): the primary or, since a turn can switch mid-run, the
|
|
1719
|
+
* fallback. Once a session has switched for good the primary is never
|
|
1720
|
+
* called again, and only the fallback counts.
|
|
1721
|
+
*/
|
|
1722
|
+
const reachesHarness = (config, switchedToFallback) => {
|
|
1723
|
+
const fallback = config.provider !== 'demo' ? config.fallback : undefined;
|
|
1724
|
+
return switchedToFallback && fallback !== undefined
|
|
1725
|
+
? runsOnHarness(fallback)
|
|
1726
|
+
: runsOnHarness(config) || (fallback !== undefined && runsOnHarness(fallback));
|
|
1727
|
+
};
|
|
1327
1728
|
const dispatchInternal = async (input) => {
|
|
1328
1729
|
// Re-read on every turn, not once at start: a newer build that stamps
|
|
1329
1730
|
// the home while this daemon is serving has formats this build does not
|
|
@@ -1374,15 +1775,15 @@ export const createGateway = (options = {}) => {
|
|
|
1374
1775
|
[EXECUTOR_METADATA_KEY]: executorRecord,
|
|
1375
1776
|
};
|
|
1376
1777
|
if (existing && existing.agent.id !== agent.id) {
|
|
1377
|
-
throw
|
|
1378
|
-
}
|
|
1379
|
-
const switchedToFallback = existing
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1778
|
+
throw crossIdentityError(input.sessionId, existing.agent.id, agent.id);
|
|
1779
|
+
}
|
|
1780
|
+
const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(existing, config);
|
|
1781
|
+
// Recorded with a keyed message so a restart judges the turn by what it
|
|
1782
|
+
// could have run on, not by whatever the config resolves to by then.
|
|
1783
|
+
// The fallback counts: a turn can switch to it mid-run. Once a session
|
|
1784
|
+
// has switched for good, the primary is never called again, and only
|
|
1785
|
+
// the fallback counts.
|
|
1786
|
+
const hostedLoop = input.idempotencyKey !== undefined && reachesHarness(config, switchedToFallback);
|
|
1386
1787
|
return withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
|
|
1387
1788
|
// The preflight above (agent refresh, config resolution, session
|
|
1388
1789
|
// load) awaited filesystem work; the entry check has long passed.
|
|
@@ -1410,6 +1811,19 @@ export const createGateway = (options = {}) => {
|
|
|
1410
1811
|
if (existing.metadata?.[EXECUTOR_METADATA_KEY] !== executorRecord) {
|
|
1411
1812
|
existing.metadata = { ...existing.metadata, [EXECUTOR_METADATA_KEY]: executorRecord };
|
|
1412
1813
|
}
|
|
1814
|
+
// The room rides on the turn, but two readers have only the
|
|
1815
|
+
// session's: a turn parked on approval and recovered after a
|
|
1816
|
+
// restart, and a turn that brings no room of its own (a schedule
|
|
1817
|
+
// firing into the thread). Both get the latest room this way, not
|
|
1818
|
+
// the first turn's, which a channel made public since would make
|
|
1819
|
+
// read as more private than it is. A turn from a channel that
|
|
1820
|
+
// could not tell the room (its lookup failed) clears it instead:
|
|
1821
|
+
// no room is the generic line, and an old one is a claim.
|
|
1822
|
+
if (typeof input.metadata?.channel === 'string') {
|
|
1823
|
+
const room = input.metadata[CONVERSATION_METADATA_KEY];
|
|
1824
|
+
const { [CONVERSATION_METADATA_KEY]: _stale, ...rest } = existing.metadata ?? {};
|
|
1825
|
+
existing.metadata = room !== undefined ? { ...rest, [CONVERSATION_METADATA_KEY]: room } : rest;
|
|
1826
|
+
}
|
|
1413
1827
|
await store.save(existing);
|
|
1414
1828
|
// The turn's metadata rides along for the sender's trust: without
|
|
1415
1829
|
// it a resumed turn has no way to say who sent it, and a stranger
|
|
@@ -1420,7 +1834,10 @@ export const createGateway = (options = {}) => {
|
|
|
1420
1834
|
userMessage: input.userMessage,
|
|
1421
1835
|
...(input.images !== undefined ? { images: input.images } : {}),
|
|
1422
1836
|
...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
|
|
1837
|
+
...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
1838
|
+
...(hostedLoop ? { hostedLoop } : {}),
|
|
1423
1839
|
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
1840
|
+
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
|
|
1424
1841
|
signal,
|
|
1425
1842
|
});
|
|
1426
1843
|
}
|
|
@@ -1430,7 +1847,10 @@ export const createGateway = (options = {}) => {
|
|
|
1430
1847
|
userMessage: input.userMessage,
|
|
1431
1848
|
...(input.images !== undefined ? { images: input.images } : {}),
|
|
1432
1849
|
...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
|
|
1850
|
+
...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
1851
|
+
...(hostedLoop ? { hostedLoop } : {}),
|
|
1433
1852
|
metadata,
|
|
1853
|
+
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
|
|
1434
1854
|
signal,
|
|
1435
1855
|
});
|
|
1436
1856
|
});
|
|
@@ -1512,7 +1932,13 @@ export const createGateway = (options = {}) => {
|
|
|
1512
1932
|
}
|
|
1513
1933
|
return new Set([...claimants.values()].filter((ids) => ids.length === 1).map(([id]) => id));
|
|
1514
1934
|
};
|
|
1515
|
-
const recoverOne = (sessionId, orphaned) => onSessionChain(sessionId,
|
|
1935
|
+
const recoverOne = (sessionId, orphaned) => onSessionChain(sessionId, () => recoverParked(sessionId, orphaned));
|
|
1936
|
+
/**
|
|
1937
|
+
* `recoverOne`'s work, for a caller already on the session's chain: a
|
|
1938
|
+
* repeated idempotency key that reached the chain before the parked sweep
|
|
1939
|
+
* did, and would otherwise wait on a turn nothing is running yet.
|
|
1940
|
+
*/
|
|
1941
|
+
const recoverParked = async (sessionId, orphaned) => {
|
|
1516
1942
|
if (stopping) {
|
|
1517
1943
|
return;
|
|
1518
1944
|
}
|
|
@@ -1571,7 +1997,8 @@ export const createGateway = (options = {}) => {
|
|
|
1571
1997
|
// resuming.
|
|
1572
1998
|
session.agent = source.definition;
|
|
1573
1999
|
await store.save(session);
|
|
1574
|
-
const
|
|
2000
|
+
const recoveredConfig = await runtimeForAgent(source);
|
|
2001
|
+
const runner = runnerFor(recoveredConfig);
|
|
1575
2002
|
// Tracked like a dispatched turn's controller: a recovered turn
|
|
1576
2003
|
// that runs on past its approval is a turn like any other, and a
|
|
1577
2004
|
// restart's window has to be able to cut it short with the same
|
|
@@ -1583,7 +2010,11 @@ export const createGateway = (options = {}) => {
|
|
|
1583
2010
|
controller.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
|
|
1584
2011
|
}
|
|
1585
2012
|
try {
|
|
1586
|
-
await runner.recoverPendingApproval(sessionId, {
|
|
2013
|
+
await runner.recoverPendingApproval(sessionId, {
|
|
2014
|
+
denyPending: expired,
|
|
2015
|
+
runtime: runtimeContextFor(source, recoveredConfig, session.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true, session.metadata),
|
|
2016
|
+
signal: controller.signal,
|
|
2017
|
+
});
|
|
1587
2018
|
}
|
|
1588
2019
|
finally {
|
|
1589
2020
|
turnControllers.delete(controller);
|
|
@@ -1600,22 +2031,31 @@ export const createGateway = (options = {}) => {
|
|
|
1600
2031
|
}
|
|
1601
2032
|
catch (error) {
|
|
1602
2033
|
warn(`could not recover parked session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2034
|
+
// A keyed item is a promise to its caller, and one whose recovery
|
|
2035
|
+
// cannot even start (its agent gone, its provider unresolvable) would
|
|
2036
|
+
// otherwise stay parked for every later start and redelivery to try
|
|
2037
|
+
// again. An unkeyed one keeps waiting for a sweep that can, as before.
|
|
2038
|
+
const after = await store.get(sessionId).catch(() => undefined);
|
|
2039
|
+
const key = after?.messages.findLast((candidate) => candidate.role === 'user')?.idempotencyKey;
|
|
2040
|
+
if (after !== undefined && key !== undefined && after.status === 'pending_approval' && workItemState(after, key) === 'unfinished') {
|
|
2041
|
+
await failParked(after, ABANDONED_TURN_ERROR).catch(() => { });
|
|
2042
|
+
}
|
|
1603
2043
|
}
|
|
1604
|
-
}
|
|
2044
|
+
};
|
|
1605
2045
|
/**
|
|
1606
2046
|
* Closes an orphaned sub-session the way the abandoned sweep closes its
|
|
1607
2047
|
* parent: durably failed, with the checkpoint retired so no later sweep
|
|
1608
2048
|
* can find a call to re-enter, and announced where surfaces can hear it.
|
|
1609
2049
|
*/
|
|
1610
|
-
const failOrphanedDelegation =
|
|
2050
|
+
const failOrphanedDelegation = (session) => failParked(session, ORPHANED_DELEGATION_ERROR);
|
|
2051
|
+
const failParked = async (session, error) => {
|
|
1611
2052
|
const metadata = { ...(session.metadata ?? {}) };
|
|
1612
2053
|
delete metadata[PENDING_APPROVAL_METADATA_KEY];
|
|
1613
2054
|
session.metadata = metadata;
|
|
1614
|
-
session
|
|
1615
|
-
session.lastError = ORPHANED_DELEGATION_ERROR;
|
|
2055
|
+
recordTurnFailure(session, error);
|
|
1616
2056
|
await store.save(session);
|
|
1617
2057
|
await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
|
|
1618
|
-
await bus.emit({ type: 'session.failed', sessionId: session.id, error
|
|
2058
|
+
await bus.emit({ type: 'session.failed', sessionId: session.id, error });
|
|
1619
2059
|
};
|
|
1620
2060
|
/**
|
|
1621
2061
|
* Turns the last process was still running when it stopped being able to
|
|
@@ -1627,9 +2067,12 @@ export const createGateway = (options = {}) => {
|
|
|
1627
2067
|
* hosts its own loop — the approval wait too, since a hosted call is
|
|
1628
2068
|
* deliberately not checkpointed (`executeHostedToolCall` passes
|
|
1629
2069
|
* `recoverable: false`, because the SDK's inner loop is not something a
|
|
1630
|
-
* restart can rebuild). None of those can be
|
|
1631
|
-
* sweeps them, so the record
|
|
1632
|
-
* long as the session exists.
|
|
2070
|
+
* restart can rebuild). None of those can be re-entered where they
|
|
2071
|
+
* stopped, and nothing else sweeps them, so the record would go on
|
|
2072
|
+
* claiming the turn is running for as long as the session exists. A turn
|
|
2073
|
+
* whose caller gave it an idempotency key is continued from its
|
|
2074
|
+
* transcript instead, with its dangling calls closed as interrupted
|
|
2075
|
+
* (`settleAbandonedTurn`); the rest are failed.
|
|
1633
2076
|
*
|
|
1634
2077
|
* Note what this is *not* for. A graceful stop denies everything parked
|
|
1635
2078
|
* and drains the turns those denials release, so an operator restarting
|
|
@@ -1643,7 +2086,8 @@ export const createGateway = (options = {}) => {
|
|
|
1643
2086
|
return store.listIdsByStatus('running');
|
|
1644
2087
|
};
|
|
1645
2088
|
/**
|
|
1646
|
-
*
|
|
2089
|
+
* Settles each of them — continued if keyed, otherwise marked failed with
|
|
2090
|
+
* a reason that says what happened.
|
|
1647
2091
|
*
|
|
1648
2092
|
* Read BEFORE the channels start and applied after, which is the only
|
|
1649
2093
|
* ordering that is both honest and safe. Reading first means the list is
|
|
@@ -1671,7 +2115,7 @@ export const createGateway = (options = {}) => {
|
|
|
1671
2115
|
if (abandoned.length === 0) {
|
|
1672
2116
|
return;
|
|
1673
2117
|
}
|
|
1674
|
-
log(`
|
|
2118
|
+
log(`settling ${abandoned.length} turn(s) left running by the last stratusd`);
|
|
1675
2119
|
await Promise.allSettled(abandoned.map((id) => failAbandonedTurn(id)));
|
|
1676
2120
|
};
|
|
1677
2121
|
const failAbandonedTurn = (id) => onSessionChain(id, async () => {
|
|
@@ -1686,16 +2130,200 @@ export const createGateway = (options = {}) => {
|
|
|
1686
2130
|
if (!session || session.status !== 'running') {
|
|
1687
2131
|
return;
|
|
1688
2132
|
}
|
|
1689
|
-
session
|
|
1690
|
-
session.lastError = ABANDONED_TURN_ERROR;
|
|
1691
|
-
await store.save(session);
|
|
1692
|
-
await bus.emit({ type: 'session.updated', sessionId: id, status: 'failed' });
|
|
1693
|
-
await bus.emit({ type: 'session.failed', sessionId: id, error: ABANDONED_TURN_ERROR });
|
|
2133
|
+
await settleAbandonedTurn(session);
|
|
1694
2134
|
}
|
|
1695
2135
|
catch (error) {
|
|
1696
|
-
warn(`could not
|
|
2136
|
+
warn(`could not settle abandoned turn ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1697
2137
|
}
|
|
1698
2138
|
});
|
|
2139
|
+
/**
|
|
2140
|
+
* A turn left `running` with nothing running it, read on its chain.
|
|
2141
|
+
*
|
|
2142
|
+
* One whose message carries an idempotency key is a work item its caller
|
|
2143
|
+
* was promised would finish, so it is continued from the transcript
|
|
2144
|
+
* (`AgentRunner.continueTurn`) — once. A turn that already was, and is
|
|
2145
|
+
* here again, took the last process down with it, and continuing it again
|
|
2146
|
+
* would do the same on every start; it is failed like an unkeyed one,
|
|
2147
|
+
* whose caller was never promised anything and is told to send again.
|
|
2148
|
+
*/
|
|
2149
|
+
const settleAbandonedTurn = async (session) => {
|
|
2150
|
+
const message = session.messages.findLast((candidate) => candidate.role === 'user');
|
|
2151
|
+
if (message?.idempotencyKey === undefined) {
|
|
2152
|
+
return failAbandoned(session);
|
|
2153
|
+
}
|
|
2154
|
+
// An answer already in the transcript is completed whatever came before
|
|
2155
|
+
// — a second crash, a harness, an agent or provider gone since —
|
|
2156
|
+
// because completing it needs none of them and runs nothing again;
|
|
2157
|
+
// failing it would throw away a finished reply.
|
|
2158
|
+
if (lastResponseIsAnswer(session)) {
|
|
2159
|
+
log(`${session.id}: a keyed turn the last stratusd was running had saved its answer; completing it`);
|
|
2160
|
+
return completeAnsweredTurn(session, store, bus);
|
|
2161
|
+
}
|
|
2162
|
+
if (message.continuedAfterCrash === true) {
|
|
2163
|
+
return failAbandoned(session);
|
|
2164
|
+
}
|
|
2165
|
+
if (message.hostedLoop === true) {
|
|
2166
|
+
log(`${session.id}: a keyed turn the last stratusd was running could reach a harness, which cannot be continued; failing it`);
|
|
2167
|
+
return failAbandoned(session);
|
|
2168
|
+
}
|
|
2169
|
+
return continueAbandonedTurn(session);
|
|
2170
|
+
};
|
|
2171
|
+
const failAbandoned = async (session) => {
|
|
2172
|
+
recordTurnFailure(session, ABANDONED_TURN_ERROR);
|
|
2173
|
+
await store.save(session);
|
|
2174
|
+
await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
|
|
2175
|
+
await bus.emit({ type: 'session.failed', sessionId: session.id, error: ABANDONED_TURN_ERROR });
|
|
2176
|
+
return session;
|
|
2177
|
+
};
|
|
2178
|
+
/**
|
|
2179
|
+
* Continues an abandoned turn on the configuration a dispatch for its
|
|
2180
|
+
* agent would resolve now, and under the same watchdog — it is a provider
|
|
2181
|
+
* call like any turn's. The definition is refreshed and saved first, as
|
|
2182
|
+
* `recoverOne` does and for the same reason: an allowlist is a permission
|
|
2183
|
+
* boundary, and a tool dropped while the daemon was down must not run.
|
|
2184
|
+
*
|
|
2185
|
+
* Not on a harness (`runsOnHarness`) — neither one the turn could have
|
|
2186
|
+
* run on (`Message.hostedLoop`, recorded when it was accepted) nor one it
|
|
2187
|
+
* would continue on. A harness runs its own tool loop inside a single
|
|
2188
|
+
* provider call and keeps its own conversation, so the prompt the dead
|
|
2189
|
+
* turn sent may already have run tools there that the transcript never
|
|
2190
|
+
* saw, and continuing would send it again. That turn is failed as an
|
|
2191
|
+
* unkeyed one is — the same answer as before keys existed. (A turn whose
|
|
2192
|
+
* answer is already saved never gets here: `settleAbandonedTurn`
|
|
2193
|
+
* completes it first.)
|
|
2194
|
+
*/
|
|
2195
|
+
const continueAbandonedTurn = async (session) => {
|
|
2196
|
+
// Settled either way: a turn whose agent left the roster, or whose
|
|
2197
|
+
// provider no longer resolves, must not stay `running` for every later
|
|
2198
|
+
// start and every redelivery to find unfinished and trip over again.
|
|
2199
|
+
let source;
|
|
2200
|
+
let config;
|
|
2201
|
+
try {
|
|
2202
|
+
source = await refreshAgent(session.agent.id);
|
|
2203
|
+
config = await runtimeForAgent(source);
|
|
2204
|
+
}
|
|
2205
|
+
catch (error) {
|
|
2206
|
+
log(`${session.id}: a keyed turn the last stratusd was running cannot be continued (${error instanceof Error ? error.message : String(error)}); failing it`);
|
|
2207
|
+
return failAbandoned(session);
|
|
2208
|
+
}
|
|
2209
|
+
const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(session, config);
|
|
2210
|
+
// The message's own record covers the runtimes the turn could have run
|
|
2211
|
+
// on; this covers the ones it would continue on — a fallback included —
|
|
2212
|
+
// which a changed config may make a harness.
|
|
2213
|
+
if (reachesHarness(config, switchedToFallback)) {
|
|
2214
|
+
log(`${session.id}: a keyed turn the last stratusd was running would continue on a harness; failing it`);
|
|
2215
|
+
return failAbandoned(session);
|
|
2216
|
+
}
|
|
2217
|
+
log(`${session.id}: continuing a keyed turn the last stratusd was still running`);
|
|
2218
|
+
session.agent = source.definition;
|
|
2219
|
+
await store.save(session);
|
|
2220
|
+
const runner = runnerFor(config);
|
|
2221
|
+
return withWatchdog(session.id, undefined, effectiveStreams, fallbackStreams, async (signal) => await runner.continueTurn(session.id, {
|
|
2222
|
+
runtime: runtimeContextFor(source, config, switchedToFallback, session.metadata),
|
|
2223
|
+
signal,
|
|
2224
|
+
}) ?? session);
|
|
2225
|
+
};
|
|
2226
|
+
/**
|
|
2227
|
+
* A repeated idempotency key that reached the session's chain, so nothing
|
|
2228
|
+
* else is running on the session. `undefined` means the key is new here
|
|
2229
|
+
* and the dispatch runs as any other; otherwise this is the session to
|
|
2230
|
+
* resolve with, and no new turn starts.
|
|
2231
|
+
*
|
|
2232
|
+
* An unfinished item here has no live turn — the live map would have
|
|
2233
|
+
* answered first — so it is one the last process left, reached before
|
|
2234
|
+
* the start-up sweep that would have settled it: settled now, the way
|
|
2235
|
+
* that sweep would, which then finds it no longer needs anything.
|
|
2236
|
+
*/
|
|
2237
|
+
const settleRepeat = async (sessionId, idempotencyKey, agentId, turnId, onRepeat) => {
|
|
2238
|
+
const session = await store.get(sessionId);
|
|
2239
|
+
if (session === undefined) {
|
|
2240
|
+
return undefined;
|
|
2241
|
+
}
|
|
2242
|
+
// Before anything is returned: the turn a dispatch would run is refused
|
|
2243
|
+
// for naming another agent, and the one it would repeat is no less that
|
|
2244
|
+
// agent's — a repeat is not a way to read its transcript.
|
|
2245
|
+
if (agentId !== undefined && session.agent.id !== agentId) {
|
|
2246
|
+
throw crossIdentityError(sessionId, session.agent.id, agentId);
|
|
2247
|
+
}
|
|
2248
|
+
const state = workItemState(session, idempotencyKey);
|
|
2249
|
+
if (state === undefined) {
|
|
2250
|
+
const archived = await archivedRepeat(session, idempotencyKey);
|
|
2251
|
+
if (archived !== undefined) {
|
|
2252
|
+
onRepeat?.('finished');
|
|
2253
|
+
}
|
|
2254
|
+
return archived;
|
|
2255
|
+
}
|
|
2256
|
+
if (state === 'finished') {
|
|
2257
|
+
onRepeat?.('finished');
|
|
2258
|
+
return session;
|
|
2259
|
+
}
|
|
2260
|
+
// Run now, on this caller's behalf, so its events are this caller's
|
|
2261
|
+
// turn — the adapter waiting on them has no other way to claim them.
|
|
2262
|
+
if (turnId !== undefined) {
|
|
2263
|
+
activeTurns.set(sessionId, turnId);
|
|
2264
|
+
}
|
|
2265
|
+
try {
|
|
2266
|
+
return await settleUnfinished(session);
|
|
2267
|
+
}
|
|
2268
|
+
finally {
|
|
2269
|
+
if (turnId !== undefined) {
|
|
2270
|
+
activeTurns.delete(sessionId);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
};
|
|
2274
|
+
/**
|
|
2275
|
+
* The archived transcript holding a key the live row does not, when the
|
|
2276
|
+
* row is a rollover's fresh one made inside `ROLLOVER_REPEAT_WINDOW_MS`.
|
|
2277
|
+
* Only the one rollover back: a second inside the window is a redelivery
|
|
2278
|
+
* outliving two deliberate resets, and the chain is not walked for it.
|
|
2279
|
+
* Always finished — a rollover refuses a session with a turn in flight.
|
|
2280
|
+
*/
|
|
2281
|
+
const archivedRepeat = async (session, idempotencyKey) => {
|
|
2282
|
+
const archivedAs = session.metadata?.[ROLLED_OVER_FROM_METADATA_KEY];
|
|
2283
|
+
if (typeof archivedAs !== 'string' || Date.now() - Date.parse(session.createdAt) > ROLLOVER_REPEAT_WINDOW_MS) {
|
|
2284
|
+
return undefined;
|
|
2285
|
+
}
|
|
2286
|
+
const archived = await store.get(archivedAs);
|
|
2287
|
+
return archived !== undefined && workItemState(archived, idempotencyKey) !== undefined ? archived : undefined;
|
|
2288
|
+
};
|
|
2289
|
+
/** An unfinished item with no live turn, settled the way the start-up sweeps would. */
|
|
2290
|
+
const settleUnfinished = async (session) => {
|
|
2291
|
+
if (session.status === 'pending_approval') {
|
|
2292
|
+
await recoverParked(session.id, orphanedAtStart.has(session.id));
|
|
2293
|
+
return (await store.get(session.id)) ?? session;
|
|
2294
|
+
}
|
|
2295
|
+
return settleAbandonedTurn(session);
|
|
2296
|
+
};
|
|
2297
|
+
/**
|
|
2298
|
+
* Settles a keyed item the last process left unfinished before any other
|
|
2299
|
+
* message joins its session. Channels are up before the sweeps reach it,
|
|
2300
|
+
* and a different message resumed onto it would become the session's
|
|
2301
|
+
* newest, so `workItemState` would call the old item finished: its
|
|
2302
|
+
* redelivery would then run nothing although its turn never finished.
|
|
2303
|
+
*
|
|
2304
|
+
* Keyed items only. An unkeyed turn's caller was promised nothing, and a
|
|
2305
|
+
* message resuming it is the long-standing way such a session recovers.
|
|
2306
|
+
*/
|
|
2307
|
+
const settleOrphanedItem = async (sessionId) => {
|
|
2308
|
+
const session = await store.get(sessionId);
|
|
2309
|
+
const key = session?.messages.findLast((candidate) => candidate.role === 'user')?.idempotencyKey;
|
|
2310
|
+
if (session !== undefined && key !== undefined && workItemState(session, key) === 'unfinished') {
|
|
2311
|
+
// A continuation that fails has failed its session, which is settled
|
|
2312
|
+
// too; the message waiting behind it runs either way.
|
|
2313
|
+
await settleUnfinished(session).catch((error) => {
|
|
2314
|
+
warn(`could not settle ${sessionId}'s unfinished turn before the next message: ${error instanceof Error ? error.message : String(error)}`);
|
|
2315
|
+
});
|
|
2316
|
+
// Settling can leave it unfinished — a shutdown that began after this
|
|
2317
|
+
// message was accepted skips recovery — and the message must not run
|
|
2318
|
+
// over it then, or the old key reads finished with its turn never run.
|
|
2319
|
+
const after = await store.get(sessionId);
|
|
2320
|
+
if (after !== undefined && workItemState(after, key) === 'unfinished') {
|
|
2321
|
+
throw stopping
|
|
2322
|
+
? refusal()
|
|
2323
|
+
: new Error(`Session ${sessionId} has an unfinished turn that could not be settled, so this message was not added behind it. Send it again.`);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
};
|
|
1699
2327
|
/**
|
|
1700
2328
|
* Runs `work` as the session's next turn: queued behind whatever that
|
|
1701
2329
|
* session is already doing, and tracked so shutdown drains it.
|
|
@@ -1758,11 +2386,125 @@ export const createGateway = (options = {}) => {
|
|
|
1758
2386
|
if (reserved !== undefined) {
|
|
1759
2387
|
throw new Error(`Session metadata key "${reserved}" is reserved for the daemon's own records and cannot be supplied by a dispatch. Reserved keys: ${RESERVED_SESSION_METADATA_KEYS.join(', ')}.`);
|
|
1760
2388
|
}
|
|
1761
|
-
const { turnId } = input;
|
|
1762
|
-
if (
|
|
1763
|
-
|
|
2389
|
+
const { turnId, idempotencyKey } = input;
|
|
2390
|
+
if (idempotencyKey !== undefined && (idempotencyKey.length === 0 || idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH)) {
|
|
2391
|
+
throw new Error(`An idempotency key is 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} characters; this one is ${idempotencyKey.length}.`);
|
|
2392
|
+
}
|
|
2393
|
+
// A repeat of a dispatch still queued or running is that dispatch: it
|
|
2394
|
+
// resolves with it rather than queueing behind it, which would let
|
|
2395
|
+
// another caller's turn land in between and then find a finished item.
|
|
2396
|
+
// Attached only when it names no other agent than the live dispatch
|
|
2397
|
+
// did; otherwise it queues, and `settleRepeat` refuses it there.
|
|
2398
|
+
const live = idempotencyKey !== undefined ? liveWorkItems.get(input.sessionId)?.get(idempotencyKey) : undefined;
|
|
2399
|
+
// A claim naming an agent other than this delivery does, that the chain
|
|
2400
|
+
// has not yet confirmed — this delivery naming nobody, or someone else.
|
|
2401
|
+
// The claim may be refused for who it named, and then the item is still
|
|
2402
|
+
// unowned: inheriting the refusal would drop a valid message, and
|
|
2403
|
+
// queueing behind it would leave this delivery to find the turn that a
|
|
2404
|
+
// later valid delivery runs `finished`, and answer it twice. So it
|
|
2405
|
+
// waits for the claim to settle and decides then: a claim that stood
|
|
2406
|
+
// is repeated, by a delivery naming its owner or nobody, and refuses
|
|
2407
|
+
// anyone else; a claim that left the item unowned is dispatched again,
|
|
2408
|
+
// where the first delivery to get there runs it and the rest attach.
|
|
2409
|
+
if (live !== undefined && !live.confirmed && live.agentId !== undefined && input.agentId !== live.agentId) {
|
|
2410
|
+
const claim = live.turn;
|
|
2411
|
+
const claimedBy = input.agentId;
|
|
2412
|
+
const repeatOf = (owner) => {
|
|
2413
|
+
if (claimedBy !== undefined && claimedBy !== owner) {
|
|
2414
|
+
throw crossIdentityError(input.sessionId, owner, claimedBy);
|
|
2415
|
+
}
|
|
2416
|
+
input.onRepeat?.('live');
|
|
2417
|
+
};
|
|
2418
|
+
return claim.then((session) => {
|
|
2419
|
+
repeatOf(session.agent.id);
|
|
2420
|
+
return session;
|
|
2421
|
+
}, async (error) => {
|
|
2422
|
+
// Refused for who it named, or failed before its keyed message was
|
|
2423
|
+
// ever written (an agent gone, a provider it could not build): the
|
|
2424
|
+
// item is still unowned, and there is no turn to have repeated.
|
|
2425
|
+
const stored = error instanceof CrossIdentityError || idempotencyKey === undefined ? undefined : await store.get(input.sessionId);
|
|
2426
|
+
const owned = stored !== undefined && idempotencyKey !== undefined && workItemState(stored, idempotencyKey) !== undefined;
|
|
2427
|
+
if (owned) {
|
|
2428
|
+
repeatOf(stored.agent.id);
|
|
2429
|
+
throw error;
|
|
2430
|
+
}
|
|
2431
|
+
// Its own cleanup runs a tick later; the dispatch below must not
|
|
2432
|
+
// find the dead claim still standing and wait on it again.
|
|
2433
|
+
const items = liveWorkItems.get(input.sessionId);
|
|
2434
|
+
if (idempotencyKey !== undefined && items?.get(idempotencyKey)?.turn === claim) {
|
|
2435
|
+
items.delete(idempotencyKey);
|
|
2436
|
+
if (items.size === 0) {
|
|
2437
|
+
liveWorkItems.delete(input.sessionId);
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
return dispatch(input);
|
|
2441
|
+
});
|
|
1764
2442
|
}
|
|
1765
|
-
|
|
2443
|
+
if (live !== undefined && (input.agentId === undefined || input.agentId === live.agentId)) {
|
|
2444
|
+
input.onRepeat?.('live');
|
|
2445
|
+
return live.turn;
|
|
2446
|
+
}
|
|
2447
|
+
// The live dispatch named no agent, so whose session it is is known
|
|
2448
|
+
// only once its turn resolves. Queueing instead would make this a
|
|
2449
|
+
// `finished` repeat of a turn the live caller is still rendering — two
|
|
2450
|
+
// answers — so it attaches, and is held to the agent it named then:
|
|
2451
|
+
// told it repeated only if that agent owns the session, refused if not.
|
|
2452
|
+
const claimed = input.agentId;
|
|
2453
|
+
if (live !== undefined && live.agentId === undefined && claimed !== undefined) {
|
|
2454
|
+
const shared = live.turn;
|
|
2455
|
+
// Whichever way the turn ends: a failed one is still that agent's,
|
|
2456
|
+
// its owner read from the store, and a repeat of it is still told so
|
|
2457
|
+
// — or refused, rather than handed another agent's error.
|
|
2458
|
+
const attach = async () => {
|
|
2459
|
+
let outcome;
|
|
2460
|
+
try {
|
|
2461
|
+
outcome = { session: await shared };
|
|
2462
|
+
}
|
|
2463
|
+
catch (error) {
|
|
2464
|
+
outcome = { error };
|
|
2465
|
+
}
|
|
2466
|
+
const owner = 'session' in outcome ? outcome.session.agent.id : (await store.get(input.sessionId))?.agent.id;
|
|
2467
|
+
if (owner !== undefined && owner !== claimed) {
|
|
2468
|
+
throw crossIdentityError(input.sessionId, owner, claimed);
|
|
2469
|
+
}
|
|
2470
|
+
// No owner means the dispatch failed before its session was ever
|
|
2471
|
+
// written: there is no turn to have repeated, only its error.
|
|
2472
|
+
if (owner !== undefined) {
|
|
2473
|
+
input.onRepeat?.('live');
|
|
2474
|
+
}
|
|
2475
|
+
if ('error' in outcome) {
|
|
2476
|
+
throw outcome.error;
|
|
2477
|
+
}
|
|
2478
|
+
return outcome.session;
|
|
2479
|
+
};
|
|
2480
|
+
return attach();
|
|
2481
|
+
}
|
|
2482
|
+
// Reads `turn` only once it is assigned: the registration below runs
|
|
2483
|
+
// after it, and the chain's work runs in a later tick (`.then`).
|
|
2484
|
+
const claimLiveItem = (key, confirmed) => {
|
|
2485
|
+
const items = liveWorkItems.get(input.sessionId) ?? new Map();
|
|
2486
|
+
items.set(key, { turn, agentId: input.agentId, confirmed, addressed: input.addressed !== false });
|
|
2487
|
+
liveWorkItems.set(input.sessionId, items);
|
|
2488
|
+
};
|
|
2489
|
+
const turn = onSessionChain(input.sessionId, async () => {
|
|
2490
|
+
// Before `activeTurns` is touched: a repeat that runs nothing must
|
|
2491
|
+
// not claim the session's turn id, even for the instant it takes.
|
|
2492
|
+
const repeated = idempotencyKey !== undefined ? await settleRepeat(input.sessionId, idempotencyKey, input.agentId, turnId, input.onRepeat) : undefined;
|
|
2493
|
+
if (repeated !== undefined) {
|
|
2494
|
+
return repeated;
|
|
2495
|
+
}
|
|
2496
|
+
// Past the identity check with a key the session has not seen, so this
|
|
2497
|
+
// dispatch owns the item: it becomes the live entry even if a claimant
|
|
2498
|
+
// that was then refused registered first, or a repeat arriving while
|
|
2499
|
+
// this turn runs would find nothing to attach to, queue, and read the
|
|
2500
|
+
// turn as finished — a second answer beside this caller's.
|
|
2501
|
+
if (idempotencyKey !== undefined) {
|
|
2502
|
+
claimLiveItem(idempotencyKey, true);
|
|
2503
|
+
}
|
|
2504
|
+
await settleOrphanedItem(input.sessionId);
|
|
2505
|
+
if (turnId === undefined) {
|
|
2506
|
+
return dispatchInternal(input);
|
|
2507
|
+
}
|
|
1766
2508
|
activeTurns.set(input.sessionId, turnId);
|
|
1767
2509
|
try {
|
|
1768
2510
|
return await dispatchInternal(input);
|
|
@@ -1774,6 +2516,25 @@ export const createGateway = (options = {}) => {
|
|
|
1774
2516
|
activeTurns.delete(input.sessionId);
|
|
1775
2517
|
}
|
|
1776
2518
|
});
|
|
2519
|
+
if (idempotencyKey !== undefined) {
|
|
2520
|
+
// A repeat that queued instead of attaching — it named another agent,
|
|
2521
|
+
// and is about to be refused — must not displace the turn it repeats.
|
|
2522
|
+
// The first registration is provisional; the chain confirms it
|
|
2523
|
+
// (`claimLiveItem` above) once the dispatch is known to own the item.
|
|
2524
|
+
if (liveWorkItems.get(input.sessionId)?.has(idempotencyKey) !== true) {
|
|
2525
|
+
claimLiveItem(idempotencyKey, false);
|
|
2526
|
+
}
|
|
2527
|
+
void turn.catch(() => { }).finally(() => {
|
|
2528
|
+
const current = liveWorkItems.get(input.sessionId);
|
|
2529
|
+
if (current?.get(idempotencyKey)?.turn === turn) {
|
|
2530
|
+
current.delete(idempotencyKey);
|
|
2531
|
+
if (current.size === 0) {
|
|
2532
|
+
liveWorkItems.delete(input.sessionId);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
return turn;
|
|
1777
2538
|
};
|
|
1778
2539
|
const observe = async (input) => {
|
|
1779
2540
|
if (stopping) {
|
|
@@ -1802,7 +2563,7 @@ export const createGateway = (options = {}) => {
|
|
|
1802
2563
|
return undefined;
|
|
1803
2564
|
}
|
|
1804
2565
|
if (input.agentId !== undefined && existing.agent.id !== input.agentId) {
|
|
1805
|
-
throw
|
|
2566
|
+
throw crossIdentityError(input.sessionId, existing.agent.id, input.agentId);
|
|
1806
2567
|
}
|
|
1807
2568
|
const continuedAs = existing.metadata?.[ROLLED_OVER_TO_METADATA_KEY];
|
|
1808
2569
|
if (typeof continuedAs === 'string') {
|
|
@@ -1852,7 +2613,7 @@ export const createGateway = (options = {}) => {
|
|
|
1852
2613
|
// *calling* agent's — its allowlist checked, its own entry before the
|
|
1853
2614
|
// fleet's shared one.
|
|
1854
2615
|
credentials: createFileCredentialResolver(env),
|
|
1855
|
-
workspaces:
|
|
2616
|
+
workspaces: agentWorkspaces,
|
|
1856
2617
|
// The structured log, so a plugin's lifecycle lines — an MCP server
|
|
1857
2618
|
// that dropped, a reconnect that failed — are in `stratus logs` and
|
|
1858
2619
|
// not only on a stderr the service manager owns.
|
|
@@ -1920,6 +2681,7 @@ export const createGateway = (options = {}) => {
|
|
|
1920
2681
|
* qualified `<package>:<skill>` form.
|
|
1921
2682
|
*/
|
|
1922
2683
|
const startSkills = async () => {
|
|
2684
|
+
await registerBuiltinSkills(skillCatalog);
|
|
1923
2685
|
operatorSkills = await loadOperatorSkills(env, skillCatalog, warn);
|
|
1924
2686
|
for (const skill of operatorSkills) {
|
|
1925
2687
|
log(`skill ${skill.id} loaded from ${skill.path}`);
|
|
@@ -1991,6 +2753,7 @@ export const createGateway = (options = {}) => {
|
|
|
1991
2753
|
// agent.delegate call is only answerable while nothing can resume that
|
|
1992
2754
|
// parent. See listOrphanedDelegations.
|
|
1993
2755
|
const orphaned = await listOrphanedDelegations();
|
|
2756
|
+
orphanedAtStart = orphaned;
|
|
1994
2757
|
// Channels come up after the roster so their first inbound message
|
|
1995
2758
|
// already has agents to dispatch to. One failing adapter must not
|
|
1996
2759
|
// keep the rest (or the gateway) down. The host's adapters first, then
|
|
@@ -2262,6 +3025,24 @@ export const createGateway = (options = {}) => {
|
|
|
2262
3025
|
},
|
|
2263
3026
|
dispatch,
|
|
2264
3027
|
observe,
|
|
3028
|
+
async holdsMessage(sessionId, idempotencyKey) {
|
|
3029
|
+
// A dispatch still queued behind another turn holds the message too:
|
|
3030
|
+
// its key reaches the transcript only when the chain gets to it. By
|
|
3031
|
+
// the same rule as the transcript below — a judged turn still waiting
|
|
3032
|
+
// to run did not take the message either.
|
|
3033
|
+
if (liveWorkItems.get(sessionId)?.get(idempotencyKey)?.addressed === true) {
|
|
3034
|
+
return true;
|
|
3035
|
+
}
|
|
3036
|
+
const session = await store.get(sessionId);
|
|
3037
|
+
if (session === undefined) {
|
|
3038
|
+
return false;
|
|
3039
|
+
}
|
|
3040
|
+
const transcript = workItemState(session, idempotencyKey) !== undefined ? session : await archivedRepeat(session, idempotencyKey);
|
|
3041
|
+
// Accepted as addressed: a turn nobody asked for (`addressed: false`,
|
|
3042
|
+
// stored overheard) was this agent hearing the message, not taking it.
|
|
3043
|
+
const keyed = transcript?.messages.findLast((message) => message.role === 'user' && message.idempotencyKey === idempotencyKey);
|
|
3044
|
+
return keyed !== undefined && keyed.overheard !== true;
|
|
3045
|
+
},
|
|
2265
3046
|
async sessionRouting(sessionId) {
|
|
2266
3047
|
const session = await store.get(sessionId);
|
|
2267
3048
|
if (!session) {
|
|
@@ -2392,7 +3173,9 @@ export const createGateway = (options = {}) => {
|
|
|
2392
3173
|
return registry.list();
|
|
2393
3174
|
},
|
|
2394
3175
|
async reloadRoster() {
|
|
2395
|
-
|
|
3176
|
+
// Under the soul lock, so a repoint cannot land between a credential
|
|
3177
|
+
// grant's check of the served path and its write (`grantSoulCredential`).
|
|
3178
|
+
await reloadsInOrder(() => withSoulFileLock(loadRoster));
|
|
2396
3179
|
const roster = registry.list();
|
|
2397
3180
|
log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
|
|
2398
3181
|
return roster;
|
|
@@ -2459,6 +3242,9 @@ export const createGateway = (options = {}) => {
|
|
|
2459
3242
|
});
|
|
2460
3243
|
return status;
|
|
2461
3244
|
},
|
|
3245
|
+
async checkRestart() {
|
|
3246
|
+
await options.restartPreflight?.();
|
|
3247
|
+
},
|
|
2462
3248
|
activeTurnId(sessionId) {
|
|
2463
3249
|
return activeTurns.get(sessionId);
|
|
2464
3250
|
},
|
|
@@ -2493,7 +3279,7 @@ export const createGateway = (options = {}) => {
|
|
|
2493
3279
|
// From the registry, not from the load records: the registry is the
|
|
2494
3280
|
// live answer for which bare aliases still resolve — a plugin loaded
|
|
2495
3281
|
// later can have retired one a record still remembers.
|
|
2496
|
-
const provenance = new Map();
|
|
3282
|
+
const provenance = new Map([[STRATUS_SKILL_ID, { path: STRATUS_SKILL_PATH }]]);
|
|
2497
3283
|
for (const info of operatorSkills) {
|
|
2498
3284
|
provenance.set(info.id, { path: info.path });
|
|
2499
3285
|
}
|
|
@@ -2512,6 +3298,7 @@ export const createGateway = (options = {}) => {
|
|
|
2512
3298
|
...(alias !== undefined ? { alias } : {}),
|
|
2513
3299
|
...(from?.package !== undefined ? { package: from.package } : {}),
|
|
2514
3300
|
...(from !== undefined ? { path: from.path } : {}),
|
|
3301
|
+
...(skill.builtin === true ? { builtin: true } : {}),
|
|
2515
3302
|
};
|
|
2516
3303
|
});
|
|
2517
3304
|
},
|
|
@@ -2583,6 +3370,7 @@ export const createGateway = (options = {}) => {
|
|
|
2583
3370
|
})).then((entries) => entries.filter((entry) => entry !== undefined));
|
|
2584
3371
|
},
|
|
2585
3372
|
resolveApproval,
|
|
3373
|
+
provideCredential,
|
|
2586
3374
|
};
|
|
2587
3375
|
return gateway;
|
|
2588
3376
|
};
|