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