@stratusagent/gateway 0.12.5 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +61 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +478 -76
- package/dist/index.js.map +1 -1
- package/dist/schedules.d.ts +7 -0
- package/dist/schedules.d.ts.map +1 -1
- package/dist/schedules.js +15 -0
- package/dist/schedules.js.map +1 -1
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -9,8 +9,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
10
10
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import { filePathsOf, isUnaddressedTurn, carriesKey, leastTrusted, senderTrustOf, SENDER_TRUST_METADATA_KEY, workItemState, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, imagesWithinReplayBudget, matchesToolAllowlist, } from '@stratusagent/core';
|
|
13
|
-
import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
|
|
12
|
+
import { filePathsOf, isUnaddressedTurn, carriesKey, leastTrusted, senderTrustOf, SENDER_TRUST_METADATA_KEY, workItemState, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, sessionTrustOf, sessionWriteTrust, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, imagesWithinReplayBudget, matchesToolAllowlist, } from '@stratusagent/core';
|
|
13
|
+
import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, DELEGATE_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DEFAULT_MAX_DELEGATION_DEPTH, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
|
|
14
14
|
import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
|
|
15
15
|
import { ShardedSessionStore } from "./sessions.js";
|
|
16
16
|
export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
|
|
@@ -85,6 +85,22 @@ export class IntakePausedError extends Error {
|
|
|
85
85
|
this.name = 'IntakePausedError';
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* A dispatch refused because the daemon is restarting or stopping. Channels
|
|
90
|
+
* stay connected while a restart drains, so they hear people during it, and
|
|
91
|
+
* this is what tells them the message reached a daemon on its way down: a
|
|
92
|
+
* channel can say so and keep the message for the next one. Matched by name
|
|
93
|
+
* there, as channel packages do not import the gateway.
|
|
94
|
+
*/
|
|
95
|
+
export class GatewayStoppingError extends Error {
|
|
96
|
+
/** True for a restart, which comes back on its own; false for a stop. */
|
|
97
|
+
restarting;
|
|
98
|
+
constructor(message, restarting) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = 'GatewayStoppingError';
|
|
101
|
+
this.restarting = restarting;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
88
104
|
/**
|
|
89
105
|
* The intake state a previous process left, or open when there is none.
|
|
90
106
|
*
|
|
@@ -104,7 +120,7 @@ const readIntakeState = async (env, warn) => {
|
|
|
104
120
|
return { paused: false };
|
|
105
121
|
}
|
|
106
122
|
warn(`intake: ${file} could not be read (${error.message}); starting paused. Resume intake to clear it.`);
|
|
107
|
-
return { paused: true, since: new Date().toISOString() };
|
|
123
|
+
return { paused: true, since: new Date().toISOString(), unreadable: true };
|
|
108
124
|
}
|
|
109
125
|
try {
|
|
110
126
|
const parsed = JSON.parse(raw);
|
|
@@ -113,7 +129,7 @@ const readIntakeState = async (env, warn) => {
|
|
|
113
129
|
// rule as an unreadable file applies.
|
|
114
130
|
if (typeof parsed !== 'object' || parsed === null || parsed.paused !== true) {
|
|
115
131
|
warn(`intake: ${file} does not hold a pause record; starting paused. Resume intake to clear it.`);
|
|
116
|
-
return { paused: true, since: new Date().toISOString() };
|
|
132
|
+
return { paused: true, since: new Date().toISOString(), unreadable: true };
|
|
117
133
|
}
|
|
118
134
|
return {
|
|
119
135
|
paused: true,
|
|
@@ -123,7 +139,7 @@ const readIntakeState = async (env, warn) => {
|
|
|
123
139
|
}
|
|
124
140
|
catch {
|
|
125
141
|
warn(`intake: ${file} is not valid JSON; starting paused. Resume intake to clear it.`);
|
|
126
|
-
return { paused: true, since: new Date().toISOString() };
|
|
142
|
+
return { paused: true, since: new Date().toISOString(), unreadable: true };
|
|
127
143
|
}
|
|
128
144
|
};
|
|
129
145
|
/**
|
|
@@ -267,6 +283,27 @@ class CrossIdentityError extends Error {
|
|
|
267
283
|
const crossIdentityError = (sessionId, owner, claimed) => new CrossIdentityError(sessionId, owner, claimed);
|
|
268
284
|
/** Where the session's tool calls ran: `local-command`, or a contributed executor's registered name. */
|
|
269
285
|
export const EXECUTOR_METADATA_KEY = 'executor';
|
|
286
|
+
/**
|
|
287
|
+
* Marks a sub-agent's session (roadmap 24). Reserved: it is what makes the
|
|
288
|
+
* daemon deny the session's gated calls without asking anyone, so a caller
|
|
289
|
+
* that could set it could also clear it.
|
|
290
|
+
*/
|
|
291
|
+
export const SUB_AGENT_METADATA_KEY = 'subAgent';
|
|
292
|
+
export const SUB_AGENT_SESSION_ID_MARKER = ':subagent:';
|
|
293
|
+
/** How many sub-agents one parent turn runs at once unless the host says otherwise. */
|
|
294
|
+
export const DEFAULT_SUB_AGENTS_PER_TURN = 4;
|
|
295
|
+
export const isSubAgentSession = (session) => session.metadata?.[SUB_AGENT_METADATA_KEY] === true && session.id.includes(SUB_AGENT_SESSION_ID_MARKER);
|
|
296
|
+
/**
|
|
297
|
+
* Tools a sub-agent never gets, whatever its parent holds. `agent.spawn`
|
|
298
|
+
* would fan out inside a fan-out; `agent.delegate` runs a roster agent
|
|
299
|
+
* under that agent's own allowlist, which is the way out of a narrowed
|
|
300
|
+
* slice; `memory.*` would let it persist what nobody reviewed; and
|
|
301
|
+
* `credential.request` asks a person, which a sub-agent never does.
|
|
302
|
+
*/
|
|
303
|
+
const subAgentNeverGets = (name) => name === 'agent.spawn'
|
|
304
|
+
|| name === DELEGATE_TOOL_NAME
|
|
305
|
+
|| name.startsWith('memory.')
|
|
306
|
+
|| name === CREDENTIAL_REQUEST_TOOL_NAME;
|
|
270
307
|
export const RESERVED_SESSION_METADATA_KEYS = [
|
|
271
308
|
PENDING_APPROVAL_METADATA_KEY,
|
|
272
309
|
FALLBACK_ACTIVE_METADATA_KEY,
|
|
@@ -292,6 +329,7 @@ export const RESERVED_SESSION_METADATA_KEYS = [
|
|
|
292
329
|
// no messages and, once it grows past that index, silently stops sending
|
|
293
330
|
// its own earliest turns, with no overflow and no trim event to say so.
|
|
294
331
|
CONTEXT_FLOOR_METADATA_KEY,
|
|
332
|
+
SUB_AGENT_METADATA_KEY,
|
|
295
333
|
];
|
|
296
334
|
/**
|
|
297
335
|
* The first reserved key `metadata` carries, or undefined when it carries
|
|
@@ -489,7 +527,35 @@ export const createGateway = (options = {}) => {
|
|
|
489
527
|
}
|
|
490
528
|
return { agentId: root.agent.id, sessionId: root.id, ...(root.metadata ? { metadata: root.metadata } : {}) };
|
|
491
529
|
};
|
|
530
|
+
/**
|
|
531
|
+
* Which sessions are sub-agents, as the host knows it rather than as the
|
|
532
|
+
* session says (roadmap 24). Every tool in a slice is handed the same
|
|
533
|
+
* mutable Session, so the metadata marker and the id are claims a tool
|
|
534
|
+
* can rewrite. The ids are the ones `subAgents.run` minted, held while
|
|
535
|
+
* the sub-agent runs. A session object's id is read once, by the
|
|
536
|
+
* executor on its first tool call, before any tool has been handed it,
|
|
537
|
+
* so a session a tool renamed is still known by the id it ran under.
|
|
538
|
+
*/
|
|
539
|
+
const liveSubAgentIds = new Set();
|
|
540
|
+
const idAtFirstCall = new WeakMap();
|
|
541
|
+
const hostIdOf = (session) => idAtFirstCall.get(session) ?? session.id;
|
|
542
|
+
const isHostSubAgent = (session) => liveSubAgentIds.has(hostIdOf(session)) || isSubAgentSession(session);
|
|
492
543
|
const requestApproval = async (request) => {
|
|
544
|
+
// A sub-agent asks no one (roadmap 24): its parent turn is the one a
|
|
545
|
+
// person is accountable for, and that turn gets the refusal back as a
|
|
546
|
+
// result and decides what to do. Denied as undeliverable, since nobody
|
|
547
|
+
// was asked, and announced only as a resolution, so no channel renders
|
|
548
|
+
// a question nobody may answer.
|
|
549
|
+
if (isHostSubAgent(request.session)) {
|
|
550
|
+
void bus.emit({
|
|
551
|
+
type: 'tool.approval-resolved',
|
|
552
|
+
sessionId: request.session.id,
|
|
553
|
+
requestId: randomUUID(),
|
|
554
|
+
answer: 'deny',
|
|
555
|
+
reason: 'undeliverable',
|
|
556
|
+
});
|
|
557
|
+
return { answer: 'deny' };
|
|
558
|
+
}
|
|
493
559
|
const delegatedFrom = await delegationOriginOf(request.session);
|
|
494
560
|
return requestApprovalFrom(request, delegatedFrom);
|
|
495
561
|
};
|
|
@@ -533,6 +599,10 @@ export const createGateway = (options = {}) => {
|
|
|
533
599
|
* all and its denial should not wait on an emission that never runs.
|
|
534
600
|
*/
|
|
535
601
|
let announced = Promise.resolve();
|
|
602
|
+
// Read when the request is made, from the scheduler's own records: a
|
|
603
|
+
// live firing of a schedule approved with a destination, nothing else.
|
|
604
|
+
// Delegated work is not included yet; its sub-session is not a firing.
|
|
605
|
+
const scheduleDestination = scheduler.approvalDestinationFor(request.session);
|
|
536
606
|
// Work one agent does for another is allowed one call at a time. The
|
|
537
607
|
// people asked are the delegating conversation's, and a standing grant
|
|
538
608
|
// would widen what the other agent may do on its own work for good —
|
|
@@ -594,6 +664,7 @@ export const createGateway = (options = {}) => {
|
|
|
594
664
|
...(expiresAt ? { expiresAt } : {}),
|
|
595
665
|
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
596
666
|
...(delegatedFrom ? { delegatedFrom } : {}),
|
|
667
|
+
...(scheduleDestination ? { scheduleDestination } : {}),
|
|
597
668
|
},
|
|
598
669
|
});
|
|
599
670
|
if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
|
|
@@ -641,6 +712,7 @@ export const createGateway = (options = {}) => {
|
|
|
641
712
|
...(always !== undefined ? { always } : {}),
|
|
642
713
|
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
643
714
|
...(delegatedFrom ? { delegatedFrom } : {}),
|
|
715
|
+
...(scheduleDestination ? { scheduleDestination } : {}),
|
|
644
716
|
...(expiresAt ? { expiresAt } : {}),
|
|
645
717
|
});
|
|
646
718
|
// Drained at shutdown alongside the resolutions: a channel that has not
|
|
@@ -719,6 +791,21 @@ export const createGateway = (options = {}) => {
|
|
|
719
791
|
return channelCarrying(kind, agentId, (candidate) => candidate.accountFor !== undefined)
|
|
720
792
|
.adapter?.accountFor?.(agentId);
|
|
721
793
|
},
|
|
794
|
+
conversationKindFor: async (agentId, destination) => {
|
|
795
|
+
const separator = destination.indexOf(':');
|
|
796
|
+
if (separator <= 0) {
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
const kind = destination.slice(0, separator);
|
|
800
|
+
const adapter = channelCarrying(kind, agentId, (candidate) => candidate.conversationKindFor !== undefined).adapter;
|
|
801
|
+
try {
|
|
802
|
+
return await adapter?.conversationKindFor?.(agentId, destination.slice(separator + 1));
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
// Unknown, so the call asks: never a reason to allow.
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
808
|
+
},
|
|
722
809
|
},
|
|
723
810
|
contributorOf: (toolName) => toolProvenance().get(toolName)?.package,
|
|
724
811
|
})
|
|
@@ -1107,10 +1194,12 @@ export const createGateway = (options = {}) => {
|
|
|
1107
1194
|
const runtimeContextFor = (source, config, onFallback,
|
|
1108
1195
|
// The turn's own metadata, where a channel records who can read the
|
|
1109
1196
|
// conversation now; a recovered turn has none and passes the session's.
|
|
1110
|
-
metadata
|
|
1197
|
+
metadata,
|
|
1198
|
+
// A sub-agent has no soul of its own, and its parent's is not its to edit.
|
|
1199
|
+
subAgent = false) => {
|
|
1111
1200
|
const conversation = conversationContextFrom(metadata);
|
|
1112
1201
|
return {
|
|
1113
|
-
...(source.soulPath !== undefined ? { soulPath: source.soulPath, soulReloads: true } : {}),
|
|
1202
|
+
...(source.soulPath !== undefined && !subAgent ? { soulPath: source.soulPath, soulReloads: true } : {}),
|
|
1114
1203
|
workspace: agentWorkspaces.forAgent(source.definition.id),
|
|
1115
1204
|
...(config.language !== undefined ? { language: config.language } : {}),
|
|
1116
1205
|
model: describeServingModel(config, onFallback),
|
|
@@ -1473,6 +1562,229 @@ export const createGateway = (options = {}) => {
|
|
|
1473
1562
|
...(input.signal ? { signal: input.signal } : {}),
|
|
1474
1563
|
}),
|
|
1475
1564
|
}));
|
|
1565
|
+
// ---- sub-agents (roadmap 24) -------------------------------------------
|
|
1566
|
+
/**
|
|
1567
|
+
* Running sub-agents per parent session, and who waits for a slot. A
|
|
1568
|
+
* parent session runs one turn at a time, so this is per turn.
|
|
1569
|
+
*/
|
|
1570
|
+
const maxSubAgentsPerTurn = options.subAgents?.maxConcurrentPerTurn ?? DEFAULT_SUB_AGENTS_PER_TURN;
|
|
1571
|
+
// NaN, 0, a fraction or Infinity would queue every sub-agent forever or
|
|
1572
|
+
// defeat the cap, so refuse it here instead of failing later in a turn.
|
|
1573
|
+
if (!Number.isInteger(maxSubAgentsPerTurn) || maxSubAgentsPerTurn < 1) {
|
|
1574
|
+
throw new Error(`subAgents.maxConcurrentPerTurn must be a whole number of at least 1 (got ${String(maxSubAgentsPerTurn)}).`);
|
|
1575
|
+
}
|
|
1576
|
+
const subAgentSlots = new Map();
|
|
1577
|
+
let subAgentCount = 0;
|
|
1578
|
+
/**
|
|
1579
|
+
* The session object each in-flight tool call was handed, with that
|
|
1580
|
+
* call's turn signal. `subAgents.run` accepts only a parent found here:
|
|
1581
|
+
* the session a running tool was given, by identity, never an id, so a
|
|
1582
|
+
* plugin cannot start sub-agents of another turn (another agent's
|
|
1583
|
+
* included) just because that turn is running. The signal is how the
|
|
1584
|
+
* parent's cancel reaches its sub-agents whether or not the plugin
|
|
1585
|
+
* passes one.
|
|
1586
|
+
*/
|
|
1587
|
+
const toolCallsInFlight = new Map();
|
|
1588
|
+
const bindToolCalls = (inner) => ({
|
|
1589
|
+
async execute(call, tool, session, context) {
|
|
1590
|
+
if (!idAtFirstCall.has(session)) {
|
|
1591
|
+
idAtFirstCall.set(session, session.id);
|
|
1592
|
+
}
|
|
1593
|
+
const entry = toolCallsInFlight.get(session) ?? [];
|
|
1594
|
+
toolCallsInFlight.set(session, entry);
|
|
1595
|
+
const signal = context?.signal;
|
|
1596
|
+
entry.push(signal);
|
|
1597
|
+
try {
|
|
1598
|
+
return await inner.execute(call, tool, session, context);
|
|
1599
|
+
}
|
|
1600
|
+
finally {
|
|
1601
|
+
entry.splice(entry.indexOf(signal), 1);
|
|
1602
|
+
if (entry.length === 0) {
|
|
1603
|
+
toolCallsInFlight.delete(session);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
},
|
|
1607
|
+
});
|
|
1608
|
+
const subAgentSlot = (parentId, signal) => new Promise((resolve, reject) => {
|
|
1609
|
+
const entry = subAgentSlots.get(parentId) ?? { running: 0, waiting: [] };
|
|
1610
|
+
subAgentSlots.set(parentId, entry);
|
|
1611
|
+
const release = () => {
|
|
1612
|
+
entry.running -= 1;
|
|
1613
|
+
const next = entry.waiting.shift();
|
|
1614
|
+
if (next) {
|
|
1615
|
+
next();
|
|
1616
|
+
}
|
|
1617
|
+
else if (entry.running === 0) {
|
|
1618
|
+
subAgentSlots.delete(parentId);
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
const take = () => {
|
|
1622
|
+
entry.running += 1;
|
|
1623
|
+
resolve(release);
|
|
1624
|
+
};
|
|
1625
|
+
if (entry.running < maxSubAgentsPerTurn) {
|
|
1626
|
+
take();
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
const onAbort = () => {
|
|
1630
|
+
const at = entry.waiting.indexOf(waiter);
|
|
1631
|
+
if (at >= 0) {
|
|
1632
|
+
entry.waiting.splice(at, 1);
|
|
1633
|
+
}
|
|
1634
|
+
reject(abortErrorFor(signal));
|
|
1635
|
+
};
|
|
1636
|
+
const waiter = () => {
|
|
1637
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1638
|
+
take();
|
|
1639
|
+
};
|
|
1640
|
+
entry.waiting.push(waiter);
|
|
1641
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1642
|
+
});
|
|
1643
|
+
/** Why this slice cannot be a sub-agent's, or undefined when it can. */
|
|
1644
|
+
const subAgentSliceProblem = (parent, requested) => {
|
|
1645
|
+
if (!Array.isArray(requested) || requested.some((name) => typeof name !== 'string' || name.length === 0)) {
|
|
1646
|
+
return 'A sub-agent\'s tools are a list of tool names.';
|
|
1647
|
+
}
|
|
1648
|
+
const allowlist = parent.agent.tools;
|
|
1649
|
+
for (const name of requested) {
|
|
1650
|
+
if (/[*?]/.test(name)) {
|
|
1651
|
+
return `A sub-agent's tools are exact names, and ${name} is a pattern.`;
|
|
1652
|
+
}
|
|
1653
|
+
if (subAgentNeverGets(name)) {
|
|
1654
|
+
return `${name} is never given to a sub-agent, even by an agent that holds it.`;
|
|
1655
|
+
}
|
|
1656
|
+
if (!tools.get(name)) {
|
|
1657
|
+
return `There is no tool named ${name}.`;
|
|
1658
|
+
}
|
|
1659
|
+
// The parent's allowlist as its running turn holds it: a slice can
|
|
1660
|
+
// only narrow what that turn may already do.
|
|
1661
|
+
if (allowlist !== undefined && !matchesToolAllowlist(name, allowlist)) {
|
|
1662
|
+
return `${name} is not one of ${parent.agent.id}'s tools, so a sub-agent of it cannot have it.`;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return undefined;
|
|
1666
|
+
};
|
|
1667
|
+
const subAgents = {
|
|
1668
|
+
async run(request) {
|
|
1669
|
+
// Copied before the first await, and only the copy is read from here
|
|
1670
|
+
// on: the plugin keeps its own objects and can change them while this
|
|
1671
|
+
// request is checked or waits for a slot, and a slice changed after
|
|
1672
|
+
// it was checked must not be the slice the sub-agent runs with.
|
|
1673
|
+
const handed = request?.parent;
|
|
1674
|
+
const callSignals = handed ? toolCallsInFlight.get(handed) : undefined;
|
|
1675
|
+
if (!callSignals) {
|
|
1676
|
+
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".');
|
|
1677
|
+
}
|
|
1678
|
+
// The parent turn's own stop, and the caller's as an extra one: a
|
|
1679
|
+
// plugin that passes no signal still has its sub-agents cancelled
|
|
1680
|
+
// with the turn.
|
|
1681
|
+
const stops = [...callSignals, request.signal].filter((signal) => signal !== undefined);
|
|
1682
|
+
const input = {
|
|
1683
|
+
parent: { id: hostIdOf(handed) },
|
|
1684
|
+
instruction: request?.instruction,
|
|
1685
|
+
tools: Array.isArray(request?.tools) ? [...request.tools] : request?.tools,
|
|
1686
|
+
model: request?.model,
|
|
1687
|
+
signal: stops.length === 0 ? undefined : stops.length === 1 ? stops[0] : AbortSignal.any(stops),
|
|
1688
|
+
};
|
|
1689
|
+
const parentId = typeof input.parent.id === 'string' ? input.parent.id : '';
|
|
1690
|
+
if (!parentId) {
|
|
1691
|
+
throw new Error('subAgents.run needs the session of the turn asking.');
|
|
1692
|
+
}
|
|
1693
|
+
const instruction = typeof input.instruction === 'string' ? input.instruction.trim() : '';
|
|
1694
|
+
if (!instruction) {
|
|
1695
|
+
throw new Error('subAgents.run needs an instruction.');
|
|
1696
|
+
}
|
|
1697
|
+
if (input.model !== undefined) {
|
|
1698
|
+
throw new Error('A sub-agent\'s model cannot be chosen yet: it runs on its parent\'s. Leave "model" out.');
|
|
1699
|
+
}
|
|
1700
|
+
const parent = await store.get(parentId);
|
|
1701
|
+
if (!parent) {
|
|
1702
|
+
throw new Error(`There is no session ${parentId} to start a sub-agent from.`);
|
|
1703
|
+
}
|
|
1704
|
+
if (isHostSubAgent(handed) || isSubAgentSession(parent)) {
|
|
1705
|
+
throw new Error('A sub-agent cannot start sub-agents.');
|
|
1706
|
+
}
|
|
1707
|
+
if (parent.status !== 'running') {
|
|
1708
|
+
throw new Error(`Session ${parentId} is not running a turn, and sub-agents start only from one.`);
|
|
1709
|
+
}
|
|
1710
|
+
const depth = typeof parent.metadata?.[DELEGATION_DEPTH_METADATA_KEY] === 'number'
|
|
1711
|
+
? parent.metadata[DELEGATION_DEPTH_METADATA_KEY]
|
|
1712
|
+
: 0;
|
|
1713
|
+
if (depth >= DEFAULT_MAX_DELEGATION_DEPTH) {
|
|
1714
|
+
throw new Error(`Delegation depth limit reached (${DEFAULT_MAX_DELEGATION_DEPTH}).`);
|
|
1715
|
+
}
|
|
1716
|
+
const problem = subAgentSliceProblem(parent, input.tools);
|
|
1717
|
+
if (problem) {
|
|
1718
|
+
throw new Error(problem);
|
|
1719
|
+
}
|
|
1720
|
+
if (input.signal?.aborted) {
|
|
1721
|
+
throw abortErrorFor(input.signal);
|
|
1722
|
+
}
|
|
1723
|
+
const release = await subAgentSlot(parent.id, input.signal);
|
|
1724
|
+
subAgentCount += 1;
|
|
1725
|
+
const sessionId = `${parent.id}${SUB_AGENT_SESSION_ID_MARKER}${depth + 1}:${subAgentCount}-${randomUUID().slice(0, 8)}`;
|
|
1726
|
+
// The parent agent, narrowed: its id, provider and credentials, the
|
|
1727
|
+
// slice for tools, and nothing else — no instructions (the persona),
|
|
1728
|
+
// no skills, no delegates.
|
|
1729
|
+
const definition = {
|
|
1730
|
+
id: parent.agent.id,
|
|
1731
|
+
name: parent.agent.name,
|
|
1732
|
+
tools: [...new Set(input.tools)],
|
|
1733
|
+
skills: [],
|
|
1734
|
+
delegates: [],
|
|
1735
|
+
...(parent.agent.credentials !== undefined ? { credentials: [...parent.agent.credentials] } : {}),
|
|
1736
|
+
};
|
|
1737
|
+
let session;
|
|
1738
|
+
liveSubAgentIds.add(sessionId);
|
|
1739
|
+
try {
|
|
1740
|
+
session = await onSessionChain(sessionId, () => dispatchInternal({
|
|
1741
|
+
sessionId,
|
|
1742
|
+
agentId: parent.agent.id,
|
|
1743
|
+
userMessage: instruction,
|
|
1744
|
+
metadata: {
|
|
1745
|
+
[SUB_AGENT_METADATA_KEY]: true,
|
|
1746
|
+
[DELEGATION_DEPTH_METADATA_KEY]: depth + 1,
|
|
1747
|
+
[DELEGATED_BY_METADATA_KEY]: parent.agent.id,
|
|
1748
|
+
[ROOT_SESSION_ID_METADATA_KEY]: typeof parent.metadata?.[ROOT_SESSION_ID_METADATA_KEY] === 'string'
|
|
1749
|
+
? parent.metadata[ROOT_SESSION_ID_METADATA_KEY]
|
|
1750
|
+
: parent.id,
|
|
1751
|
+
// The instruction is the parent's text, at the parent's label:
|
|
1752
|
+
// a tainted parent must not hand a stranger's words to a
|
|
1753
|
+
// session that has seen nothing.
|
|
1754
|
+
[SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(parent),
|
|
1755
|
+
// On the runtime serving the parent's turn: a parent that has
|
|
1756
|
+
// switched to its sticky fallback must not have its helpers
|
|
1757
|
+
// retry the primary it left.
|
|
1758
|
+
...(parent.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true ? { [FALLBACK_ACTIVE_METADATA_KEY]: true } : {}),
|
|
1759
|
+
},
|
|
1760
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
1761
|
+
}, [], definition));
|
|
1762
|
+
}
|
|
1763
|
+
catch (error) {
|
|
1764
|
+
if (input.signal?.aborted) {
|
|
1765
|
+
throw error;
|
|
1766
|
+
}
|
|
1767
|
+
// The error text may be the provider quoting what it refused,
|
|
1768
|
+
// which is this sub-agent's context: labelled as nobody can vouch.
|
|
1769
|
+
return { status: 'error', error: error instanceof Error ? error.message : String(error), sessionId, trust: 'unknown' };
|
|
1770
|
+
}
|
|
1771
|
+
finally {
|
|
1772
|
+
liveSubAgentIds.delete(sessionId);
|
|
1773
|
+
release();
|
|
1774
|
+
}
|
|
1775
|
+
const trust = sessionTrustOf(session);
|
|
1776
|
+
if (session.status === 'failed') {
|
|
1777
|
+
return { status: 'error', error: session.lastError ?? 'The sub-agent failed.', sessionId, trust: 'unknown' };
|
|
1778
|
+
}
|
|
1779
|
+
const reply = [...session.messages]
|
|
1780
|
+
.reverse()
|
|
1781
|
+
.find((message) => message.role === 'assistant' && message.content.trim().length > 0);
|
|
1782
|
+
const outcome = reply
|
|
1783
|
+
? { status: 'result', text: reply.content, sessionId, trust }
|
|
1784
|
+
: { status: 'empty', sessionId, trust };
|
|
1785
|
+
return outcome;
|
|
1786
|
+
},
|
|
1787
|
+
};
|
|
1476
1788
|
const runners = new Map();
|
|
1477
1789
|
// Every provider-construction input participates in the key — model,
|
|
1478
1790
|
// endpoint, credentials, system prompt, fallback config. A rotated
|
|
@@ -1484,13 +1796,17 @@ export const createGateway = (options = {}) => {
|
|
|
1484
1796
|
const { soul: _soul, soulPath: _soulPath, fetch: _fetch, ...providerInputs } = config;
|
|
1485
1797
|
return createHash('sha256').update(JSON.stringify(providerInputs)).digest('hex');
|
|
1486
1798
|
};
|
|
1487
|
-
|
|
1799
|
+
// A sub-agent's runner has no memory store, so no turn of it injects or
|
|
1800
|
+
// reads the agent's memory; no skill catalog, so its prompt is its
|
|
1801
|
+
// instruction and its tools; and grants nothing to every agent, since
|
|
1802
|
+
// the credential request asks a person.
|
|
1803
|
+
const runnerFor = (config, agentId, subAgent = false) => {
|
|
1488
1804
|
// The budget is a provider-construction input too (the harness
|
|
1489
1805
|
// runtimes take it as their inner limit), so it is part of the key:
|
|
1490
1806
|
// two agents on one model with different budgets get their own runners.
|
|
1491
1807
|
const maxTurns = options.maxTurnsFor?.(agentId) ?? options.maxTurns;
|
|
1492
1808
|
const autoContinue = options.autoContinueFor?.(agentId);
|
|
1493
|
-
const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}`;
|
|
1809
|
+
const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}${subAgent ? ':sub' : ''}`;
|
|
1494
1810
|
const existing = runners.get(key);
|
|
1495
1811
|
if (existing) {
|
|
1496
1812
|
return existing;
|
|
@@ -1515,16 +1831,15 @@ export const createGateway = (options = {}) => {
|
|
|
1515
1831
|
// Runners are built on dispatch, after the plugins loaded and the
|
|
1516
1832
|
// selection below ran, so a contributed executor is in place for
|
|
1517
1833
|
// the first turn.
|
|
1518
|
-
executor: selectedExecutor ?? createLocalCommandExecutor(),
|
|
1834
|
+
executor: bindToolCalls(selectedExecutor ?? createLocalCommandExecutor()),
|
|
1519
1835
|
...(approvals ? { approvals } : {}),
|
|
1520
1836
|
store,
|
|
1521
1837
|
bus,
|
|
1522
1838
|
agents: registry,
|
|
1523
1839
|
// Asking a person for a key is harmless to grant: they decide
|
|
1524
1840
|
// everything after the question, so no soul has to list it.
|
|
1525
|
-
grantedToEveryAgent: [CREDENTIAL_REQUEST_TOOL_NAME],
|
|
1526
|
-
skills: skillCatalog,
|
|
1527
|
-
memory,
|
|
1841
|
+
grantedToEveryAgent: subAgent ? [] : [CREDENTIAL_REQUEST_TOOL_NAME],
|
|
1842
|
+
...(subAgent ? {} : { skills: skillCatalog, memory }),
|
|
1528
1843
|
streaming: true,
|
|
1529
1844
|
...(maxTurns !== undefined ? { maxTurns } : {}),
|
|
1530
1845
|
...(autoContinue !== undefined
|
|
@@ -1613,7 +1928,7 @@ export const createGateway = (options = {}) => {
|
|
|
1613
1928
|
// No watchdog and nobody outside to cancel it — but still a turn an
|
|
1614
1929
|
// announced restart must be able to cut short at the window.
|
|
1615
1930
|
const idle = new AbortController();
|
|
1616
|
-
|
|
1931
|
+
trackTurn(sessionId, idle);
|
|
1617
1932
|
if (abortingWith !== undefined) {
|
|
1618
1933
|
idle.abort(new RunAbortedError(abortingWith));
|
|
1619
1934
|
}
|
|
@@ -1621,11 +1936,11 @@ export const createGateway = (options = {}) => {
|
|
|
1621
1936
|
return await run(idle.signal);
|
|
1622
1937
|
}
|
|
1623
1938
|
finally {
|
|
1624
|
-
|
|
1939
|
+
untrackTurn(sessionId, idle);
|
|
1625
1940
|
}
|
|
1626
1941
|
}
|
|
1627
1942
|
const controller = new AbortController();
|
|
1628
|
-
|
|
1943
|
+
trackTurn(sessionId, controller);
|
|
1629
1944
|
if (abortingWith !== undefined) {
|
|
1630
1945
|
controller.abort(new RunAbortedError(abortingWith));
|
|
1631
1946
|
}
|
|
@@ -1783,7 +2098,7 @@ export const createGateway = (options = {}) => {
|
|
|
1783
2098
|
unsubscribeObserver();
|
|
1784
2099
|
unsubscribeDrain();
|
|
1785
2100
|
suspendWatchdog.delete(sessionId);
|
|
1786
|
-
|
|
2101
|
+
untrackTurn(sessionId, controller);
|
|
1787
2102
|
external?.removeEventListener('abort', onExternalAbort);
|
|
1788
2103
|
}
|
|
1789
2104
|
};
|
|
@@ -1803,6 +2118,11 @@ export const createGateway = (options = {}) => {
|
|
|
1803
2118
|
// Open until `startServing` reads what the last process left, which it
|
|
1804
2119
|
// does before any channel or the scheduler can deliver work.
|
|
1805
2120
|
let intakeState = { paused: false };
|
|
2121
|
+
// A copy for callers, saying while paused whether this is the pause an
|
|
2122
|
+
// unreadable file started (see `IntakeState.unreadable`).
|
|
2123
|
+
const intakeView = () => (intakeState.paused
|
|
2124
|
+
? { ...intakeState, unreadable: intakeState.unreadable === true }
|
|
2125
|
+
: { ...intakeState });
|
|
1806
2126
|
let heldState = options.held === true;
|
|
1807
2127
|
/** What a held start left for `promote()`: the channels it did not start, and the sweeps it did not run. */
|
|
1808
2128
|
let deferredStart;
|
|
@@ -1821,9 +2141,27 @@ export const createGateway = (options = {}) => {
|
|
|
1821
2141
|
* window ends by aborting these — the same signal the watchdog fires —
|
|
1822
2142
|
* so a turn that outlives the window fails as aborted, its session
|
|
1823
2143
|
* saved, instead of being cut off by a process exit and found as
|
|
1824
|
-
* abandoned by the next one.
|
|
2144
|
+
* abandoned by the next one. Keyed by session, so a stop for one
|
|
2145
|
+
* conversation (`abortTurns`) reaches every turn running there, a
|
|
2146
|
+
* recovered one included, and nothing else.
|
|
1825
2147
|
*/
|
|
1826
|
-
const turnControllers = new
|
|
2148
|
+
const turnControllers = new Map();
|
|
2149
|
+
const trackTurn = (sessionId, controller) => {
|
|
2150
|
+
const running = turnControllers.get(sessionId);
|
|
2151
|
+
if (running) {
|
|
2152
|
+
running.add(controller);
|
|
2153
|
+
}
|
|
2154
|
+
else {
|
|
2155
|
+
turnControllers.set(sessionId, new Set([controller]));
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
const untrackTurn = (sessionId, controller) => {
|
|
2159
|
+
const running = turnControllers.get(sessionId);
|
|
2160
|
+
running?.delete(controller);
|
|
2161
|
+
if (running?.size === 0) {
|
|
2162
|
+
turnControllers.delete(sessionId);
|
|
2163
|
+
}
|
|
2164
|
+
};
|
|
1827
2165
|
/**
|
|
1828
2166
|
* Set once a restart's or a stop's window has closed and the turns in it
|
|
1829
2167
|
* were aborted, to the reason they were aborted with. A turn that starts
|
|
@@ -1845,9 +2183,9 @@ export const createGateway = (options = {}) => {
|
|
|
1845
2183
|
let serving = false;
|
|
1846
2184
|
/** Set once `restart()` announced; the refusal a caller reads says why. */
|
|
1847
2185
|
let restartStatus;
|
|
1848
|
-
const refusal = () =>
|
|
1849
|
-
? 'stratusd is restarting and will accept new work once it is back up.'
|
|
1850
|
-
: 'The gateway is stopping and no longer accepts new work.');
|
|
2186
|
+
const refusal = () => restartStatus
|
|
2187
|
+
? new GatewayStoppingError('stratusd is restarting and will accept new work once it is back up.', true)
|
|
2188
|
+
: new GatewayStoppingError('The gateway is stopping and no longer accepts new work.', false);
|
|
1851
2189
|
/**
|
|
1852
2190
|
* The turn id running on each session, for callers that named one.
|
|
1853
2191
|
*
|
|
@@ -1978,7 +2316,12 @@ export const createGateway = (options = {}) => {
|
|
|
1978
2316
|
? runsOnHarness(fallback)
|
|
1979
2317
|
: runsOnHarness(config) || (fallback !== undefined && runsOnHarness(fallback));
|
|
1980
2318
|
};
|
|
1981
|
-
|
|
2319
|
+
/**
|
|
2320
|
+
* `subAgent` is the narrowed definition `subAgents.run` built: the turn
|
|
2321
|
+
* runs as that, on the parent's resolved provider, with no memory and no
|
|
2322
|
+
* soul. Only that path passes it.
|
|
2323
|
+
*/
|
|
2324
|
+
const dispatchInternal = async (input, absorbedKeys = [], subAgent) => {
|
|
1982
2325
|
// Re-read on every turn, not once at start: a newer build that stamps
|
|
1983
2326
|
// the home while this daemon is serving has formats this build does not
|
|
1984
2327
|
// understand, and a daemon that kept writing into them would be the
|
|
@@ -2001,7 +2344,10 @@ export const createGateway = (options = {}) => {
|
|
|
2001
2344
|
const existing = await store.get(input.sessionId);
|
|
2002
2345
|
const agentId = input.agentId ?? existing?.agent.id ?? await defaultAgentId();
|
|
2003
2346
|
const source = await refreshAgent(agentId);
|
|
2004
|
-
const agent = source.definition;
|
|
2347
|
+
const agent = subAgent ?? source.definition;
|
|
2348
|
+
if (subAgent !== undefined && existing) {
|
|
2349
|
+
throw new Error(`Session ${input.sessionId} already exists; a sub-agent's session is new every time.`);
|
|
2350
|
+
}
|
|
2005
2351
|
// An archived transcript is the record of a conversation that has moved
|
|
2006
2352
|
// on. Continuing it would revive a session the operator deliberately
|
|
2007
2353
|
// left behind, under an id nothing routes to any more.
|
|
@@ -2010,7 +2356,7 @@ export const createGateway = (options = {}) => {
|
|
|
2010
2356
|
throw new Error(`Session ${input.sessionId} was rolled over; it is an archived transcript and the conversation continues as ${continuedAs}.`);
|
|
2011
2357
|
}
|
|
2012
2358
|
const config = await runtimeForAgent(source);
|
|
2013
|
-
const runner = runnerFor(config, source.definition.id);
|
|
2359
|
+
const runner = runnerFor(config, source.definition.id, subAgent !== undefined);
|
|
2014
2360
|
// Which executor the turn's tool calls run through — the built-in
|
|
2015
2361
|
// under the name `stratus run` has always recorded, a contributed one
|
|
2016
2362
|
// under its registered name — so a transcript says where a command
|
|
@@ -2030,7 +2376,8 @@ export const createGateway = (options = {}) => {
|
|
|
2030
2376
|
if (existing && existing.agent.id !== agent.id) {
|
|
2031
2377
|
throw crossIdentityError(input.sessionId, existing.agent.id, agent.id);
|
|
2032
2378
|
}
|
|
2033
|
-
|
|
2379
|
+
// A sub-agent's session is new, but may start on its parent's fallback.
|
|
2380
|
+
const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(existing ?? (subAgent !== undefined ? { metadata } : undefined), config);
|
|
2034
2381
|
// Recorded with a keyed message so a restart judges the turn by what it
|
|
2035
2382
|
// could have run on, not by whatever the config resolves to by then.
|
|
2036
2383
|
// The fallback counts: a turn can switch to it mid-run. Once a session
|
|
@@ -2057,6 +2404,13 @@ export const createGateway = (options = {}) => {
|
|
|
2057
2404
|
if (isDelegatedSession(existing) && existing.metadata) {
|
|
2058
2405
|
existing.metadata = withoutDelegation(existing.metadata);
|
|
2059
2406
|
}
|
|
2407
|
+
// The same for a sub-agent's session, continued as the agent itself:
|
|
2408
|
+
// the marker that denies its approvals unasked comes off with the
|
|
2409
|
+
// delegation markers it carries.
|
|
2410
|
+
if (isSubAgentSession(existing) && existing.metadata) {
|
|
2411
|
+
const { [SUB_AGENT_METADATA_KEY]: _subAgent, ...rest } = withoutDelegation(existing.metadata);
|
|
2412
|
+
existing.metadata = rest;
|
|
2413
|
+
}
|
|
2060
2414
|
// The executor record follows the daemon, not the session's first
|
|
2061
2415
|
// turn: a restart that selected another executor runs this turn's
|
|
2062
2416
|
// commands there, and a session from before the record existed
|
|
@@ -2106,7 +2460,7 @@ export const createGateway = (options = {}) => {
|
|
|
2106
2460
|
...(hostedLoop ? { hostedLoop } : {}),
|
|
2107
2461
|
...(input.earlier !== undefined && input.earlier.length > 0 ? { earlier: input.earlier } : {}),
|
|
2108
2462
|
metadata,
|
|
2109
|
-
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
|
|
2463
|
+
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
|
|
2110
2464
|
signal,
|
|
2111
2465
|
});
|
|
2112
2466
|
});
|
|
@@ -2220,6 +2574,14 @@ export const createGateway = (options = {}) => {
|
|
|
2220
2574
|
// Whether it IS an orphan was decided before the channels started
|
|
2221
2575
|
// (`listOrphanedDelegations`), from the parent's transcript, which
|
|
2222
2576
|
// a message arriving since may already have rewritten.
|
|
2577
|
+
// A sub-agent never parks (nobody is asked for it), and if one ever
|
|
2578
|
+
// did, its parent's turn died with the last process: nothing would
|
|
2579
|
+
// read its answer, and recovering it would run it as the full agent.
|
|
2580
|
+
if (isSubAgentSession(session) && session.status === 'pending_approval') {
|
|
2581
|
+
log(`${sessionId}: a sub-agent parked when the last stratusd stopped; failing it`);
|
|
2582
|
+
await failParked(session, ABANDONED_TURN_ERROR);
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2223
2585
|
if (orphaned && isDelegatedSession(session) && session.status === 'pending_approval' && record) {
|
|
2224
2586
|
log(`${sessionId}: parked on ${record.call.toolName} for a delegating turn the last stratusd was still running; failing it`);
|
|
2225
2587
|
await failOrphanedDelegation(session);
|
|
@@ -2255,15 +2617,21 @@ export const createGateway = (options = {}) => {
|
|
|
2255
2617
|
await store.save(session);
|
|
2256
2618
|
const recoveredConfig = await runtimeForAgent(source);
|
|
2257
2619
|
const runner = runnerFor(recoveredConfig, source.definition.id);
|
|
2258
|
-
// Tracked like a dispatched turn's controller: a recovered turn
|
|
2259
|
-
//
|
|
2260
|
-
//
|
|
2261
|
-
//
|
|
2262
|
-
//
|
|
2620
|
+
// Tracked like a dispatched turn's controller: a recovered turn that
|
|
2621
|
+
// runs on past its approval is a turn like any other, so a restart's
|
|
2622
|
+
// window has to be able to cut it short with the same reason (or it
|
|
2623
|
+
// runs unseen to the second window and the restart reports
|
|
2624
|
+
// undrained), and a stop for the session has to find it. Only once
|
|
2625
|
+
// the checkpoint has been read on this chain, right before the runner
|
|
2626
|
+
// takes it: a stop counted then always ends in the turn's own
|
|
2627
|
+
// outcome. A stop sent during the reads above finds nothing running
|
|
2628
|
+
// and reaches the session as an ordinary message, queued behind it.
|
|
2263
2629
|
const controller = new AbortController();
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2630
|
+
if (record) {
|
|
2631
|
+
trackTurn(sessionId, controller);
|
|
2632
|
+
if (abortingWith !== undefined) {
|
|
2633
|
+
controller.abort(new RunAbortedError(abortingWith));
|
|
2634
|
+
}
|
|
2267
2635
|
}
|
|
2268
2636
|
try {
|
|
2269
2637
|
await runner.recoverPendingApproval(sessionId, {
|
|
@@ -2273,7 +2641,7 @@ export const createGateway = (options = {}) => {
|
|
|
2273
2641
|
});
|
|
2274
2642
|
}
|
|
2275
2643
|
finally {
|
|
2276
|
-
|
|
2644
|
+
untrackTurn(sessionId, controller);
|
|
2277
2645
|
// A recovered firing's row outlived the process that would have
|
|
2278
2646
|
// retired it, and the firing is over either way — a recovery that
|
|
2279
2647
|
// failed has durably failed its session, which is as finished as
|
|
@@ -2449,6 +2817,12 @@ export const createGateway = (options = {}) => {
|
|
|
2449
2817
|
* completes it first.)
|
|
2450
2818
|
*/
|
|
2451
2819
|
const continueAbandonedTurn = async (session) => {
|
|
2820
|
+
// Its parent's turn died with the last process, and continuing it here
|
|
2821
|
+
// would run it as the full agent. (Sub-agent turns carry no key today,
|
|
2822
|
+
// so this is a guard, not a path.)
|
|
2823
|
+
if (isSubAgentSession(session)) {
|
|
2824
|
+
return failAbandoned(session);
|
|
2825
|
+
}
|
|
2452
2826
|
// Settled either way: a turn whose agent left the roster, or whose
|
|
2453
2827
|
// provider no longer resolves, must not stay `running` for every later
|
|
2454
2828
|
// start and every redelivery to find unfinished and trip over again.
|
|
@@ -3091,6 +3465,7 @@ export const createGateway = (options = {}) => {
|
|
|
3091
3465
|
// not only on a stderr the service manager owns.
|
|
3092
3466
|
log,
|
|
3093
3467
|
warn,
|
|
3468
|
+
subAgents,
|
|
3094
3469
|
});
|
|
3095
3470
|
loadedPlugins = result.loaded;
|
|
3096
3471
|
pluginFailures = result.failures;
|
|
@@ -3260,6 +3635,18 @@ export const createGateway = (options = {}) => {
|
|
|
3260
3635
|
}
|
|
3261
3636
|
try {
|
|
3262
3637
|
await adapter.start(gateway);
|
|
3638
|
+
// A shutdown that gave up waiting on this start has already stopped
|
|
3639
|
+
// the channels it could see; one that comes up after that is
|
|
3640
|
+
// stopped here, or it would stay connected beside the next daemon.
|
|
3641
|
+
if (channelsClosed) {
|
|
3642
|
+
try {
|
|
3643
|
+
await adapter.stop();
|
|
3644
|
+
}
|
|
3645
|
+
catch (stopError) {
|
|
3646
|
+
warn(`channel ${adapter.name} started after the shutdown stopped the channels, and failed to stop: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
3647
|
+
}
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3263
3650
|
startedChannels.push(adapter);
|
|
3264
3651
|
}
|
|
3265
3652
|
catch (error) {
|
|
@@ -3407,6 +3794,8 @@ export const createGateway = (options = {}) => {
|
|
|
3407
3794
|
return released;
|
|
3408
3795
|
};
|
|
3409
3796
|
const startedChannels = [];
|
|
3797
|
+
/** Set once a shutdown has stopped the started channels; a start that finishes after it stops its own. */
|
|
3798
|
+
let channelsClosed = false;
|
|
3410
3799
|
/** Resolves with `true` when `work` settles before `ms` elapse. */
|
|
3411
3800
|
const settlesWithin = (work, ms) => new Promise((resolve) => {
|
|
3412
3801
|
const timer = setTimeout(() => resolve(false), ms);
|
|
@@ -3461,22 +3850,43 @@ export const createGateway = (options = {}) => {
|
|
|
3461
3850
|
// the same messages. Reported, and for a restart, counted as not
|
|
3462
3851
|
// drained; a plain stop goes on closing either way.
|
|
3463
3852
|
let channelsReleased = true;
|
|
3853
|
+
// Once, whichever path gets there first: the end of the drain, or the
|
|
3854
|
+
// window giving up on a turn that ignores its abort.
|
|
3855
|
+
let channelsStopping;
|
|
3856
|
+
const stopChannels = () => {
|
|
3857
|
+
channelsStopping ??= (async () => {
|
|
3858
|
+
channelsClosed = true;
|
|
3859
|
+
const stopping = [...startedChannels];
|
|
3860
|
+
startedChannels.length = 0;
|
|
3861
|
+
const stops = await Promise.allSettled(stopping.map((adapter) => adapter.stop()));
|
|
3862
|
+
stops.forEach((result, index) => {
|
|
3863
|
+
if (result.status === 'rejected') {
|
|
3864
|
+
channelsReleased = false;
|
|
3865
|
+
warn(`channel ${stopping[index]?.name} failed to stop: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
|
|
3866
|
+
}
|
|
3867
|
+
});
|
|
3868
|
+
})();
|
|
3869
|
+
return channelsStopping;
|
|
3870
|
+
};
|
|
3464
3871
|
const drain = (async () => {
|
|
3465
3872
|
// A promotion still connecting channels finishes first, so every
|
|
3466
3873
|
// channel it started is in the list stopped below; seeing `stopping`,
|
|
3467
3874
|
// it starts nothing after that.
|
|
3468
3875
|
await promoting?.catch(() => undefined);
|
|
3469
3876
|
await Promise.allSettled([...approvalEmissions]);
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3877
|
+
// The turns first, with the channels still connected. A channel that
|
|
3878
|
+
// disconnects at the start of a drain cannot hear anyone for as long
|
|
3879
|
+
// as the drain lasts, and a long turn makes that minutes: a message
|
|
3880
|
+
// sent then reached no daemon at all, with no reply and no log line.
|
|
3881
|
+
// Connected, it is refused with `GatewayStoppingError`, which the
|
|
3882
|
+
// channel can answer and keep for the next daemon. Until the set stays
|
|
3883
|
+
// empty: a turn queued behind one that finished joins it.
|
|
3884
|
+
while (inflight.size > 0) {
|
|
3885
|
+
await Promise.allSettled([...inflight]);
|
|
3886
|
+
}
|
|
3887
|
+
// Then the channels, whose stop awaits the rendering they still have
|
|
3888
|
+
// in flight: the outcomes of the turns that just finished.
|
|
3889
|
+
await stopChannels();
|
|
3480
3890
|
})();
|
|
3481
3891
|
// One deadline for the whole bounded shutdown: the drain window, then
|
|
3482
3892
|
// one more window that everything after it shares (the aborted turns'
|
|
@@ -3501,12 +3911,20 @@ export const createGateway = (options = {}) => {
|
|
|
3501
3911
|
// Before the loop, so a turn queued behind one aborted here, which
|
|
3502
3912
|
// starts as soon as that one settles, is aborted the same way.
|
|
3503
3913
|
abortingWith = kind === 'stop' ? STOPPING_TURN_ERROR : RESTARTING_TURN_ERROR;
|
|
3504
|
-
for (const
|
|
3505
|
-
|
|
3914
|
+
for (const running of turnControllers.values()) {
|
|
3915
|
+
for (const controller of running) {
|
|
3916
|
+
controller.abort(new RunAbortedError(abortingWith));
|
|
3917
|
+
}
|
|
3506
3918
|
}
|
|
3507
3919
|
drained = await settlesWithin(drain, remaining());
|
|
3508
3920
|
if (!drained) {
|
|
3509
3921
|
warn(`${kind}: ${inflight.size} turn(s) did not stop after being aborted; shutting down around them`);
|
|
3922
|
+
// The drain never got to the channels. Stopped here instead, so a
|
|
3923
|
+
// host that goes on to start the next daemon does not find this one
|
|
3924
|
+
// still connected beside it; a channel's stop takes its intake down
|
|
3925
|
+
// before it waits on anything, so this ends what it can hear even if
|
|
3926
|
+
// it never settles.
|
|
3927
|
+
await settlesWithin(stopChannels(), remaining());
|
|
3510
3928
|
}
|
|
3511
3929
|
drained = drained && channelsReleased;
|
|
3512
3930
|
}
|
|
@@ -3662,35 +4080,10 @@ export const createGateway = (options = {}) => {
|
|
|
3662
4080
|
|| (message.role === 'tool' && message.toolResult !== undefined && filePathsOf(message.toolResult).length > 0);
|
|
3663
4081
|
const spokeIndex = session.messages.findLastIndex(spoken);
|
|
3664
4082
|
const lastSpokeAt = spokeIndex >= 0 ? session.messages[spokeIndex]?.createdAt : undefined;
|
|
3665
|
-
// The attention anchor is narrower than the thread rule's: the newest
|
|
3666
|
-
// reply to a turn somebody ASKED for — one whose message was not
|
|
3667
|
-
// overheard. A reply the agent chose to give on a turn nobody asked
|
|
3668
|
-
// for is speaking, for the thread rule, and not an anchor, or a
|
|
3669
|
-
// judge that likes the sound of its own voice would never drift out.
|
|
3670
|
-
// The trigger of a reply is the nearest user message before it.
|
|
3671
|
-
let answeredIndex = -1;
|
|
3672
|
-
for (let index = session.messages.length - 1; index >= 0 && answeredIndex < 0; index -= 1) {
|
|
3673
|
-
const message = session.messages[index];
|
|
3674
|
-
if (!message || !spoken(message)) {
|
|
3675
|
-
continue;
|
|
3676
|
-
}
|
|
3677
|
-
const trigger = session.messages.slice(0, index).findLast((earlier) => earlier.role === 'user');
|
|
3678
|
-
if (trigger !== undefined && trigger.overheard !== true) {
|
|
3679
|
-
answeredIndex = index;
|
|
3680
|
-
}
|
|
3681
|
-
}
|
|
3682
|
-
const lastAnsweredAt = answeredIndex >= 0 ? session.messages[answeredIndex]?.createdAt : undefined;
|
|
3683
|
-
// The messages after that answer: what it has heard, judged, or been
|
|
3684
|
-
// asked and not answered since — the other half of the window.
|
|
3685
|
-
const heardSinceAnswered = session.messages
|
|
3686
|
-
.slice(answeredIndex + 1)
|
|
3687
|
-
.filter((message) => message.role === 'user').length;
|
|
3688
4083
|
return {
|
|
3689
4084
|
agentId: session.agent.id,
|
|
3690
4085
|
metadata: session.metadata ?? {},
|
|
3691
4086
|
...(lastSpokeAt !== undefined ? { lastSpokeAt } : {}),
|
|
3692
|
-
...(lastAnsweredAt !== undefined ? { lastAnsweredAt } : {}),
|
|
3693
|
-
heardSinceAnswered,
|
|
3694
4087
|
...(reply !== undefined ? { reply } : {}),
|
|
3695
4088
|
...(isUnaddressedTurn(session) ? { unaddressed: true } : {}),
|
|
3696
4089
|
};
|
|
@@ -3837,6 +4230,15 @@ export const createGateway = (options = {}) => {
|
|
|
3837
4230
|
activeTurnId(sessionId) {
|
|
3838
4231
|
return activeTurns.get(sessionId);
|
|
3839
4232
|
},
|
|
4233
|
+
abortTurns(sessionId, reason) {
|
|
4234
|
+
const running = turnControllers.get(sessionId) ?? new Set();
|
|
4235
|
+
for (const controller of running) {
|
|
4236
|
+
if (!controller.signal.aborted) {
|
|
4237
|
+
controller.abort(new RunAbortedError(reason));
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
return running.size;
|
|
4241
|
+
},
|
|
3840
4242
|
pendingApprovals() {
|
|
3841
4243
|
return [...pendingApprovals.values()]
|
|
3842
4244
|
.map((parked) => parked.pending)
|
|
@@ -3960,7 +4362,7 @@ export const createGateway = (options = {}) => {
|
|
|
3960
4362
|
},
|
|
3961
4363
|
resolveApproval,
|
|
3962
4364
|
intake() {
|
|
3963
|
-
return
|
|
4365
|
+
return intakeView();
|
|
3964
4366
|
},
|
|
3965
4367
|
async pauseIntake(input = {}) {
|
|
3966
4368
|
let message;
|
|
@@ -3985,7 +4387,7 @@ export const createGateway = (options = {}) => {
|
|
|
3985
4387
|
await writeIntakeState(env, next);
|
|
3986
4388
|
intakeState = next;
|
|
3987
4389
|
log('intake paused: new work will be refused until it resumes');
|
|
3988
|
-
return
|
|
4390
|
+
return intakeView();
|
|
3989
4391
|
});
|
|
3990
4392
|
},
|
|
3991
4393
|
held() {
|
|
@@ -3999,7 +4401,7 @@ export const createGateway = (options = {}) => {
|
|
|
3999
4401
|
log('intake resumed');
|
|
4000
4402
|
}
|
|
4001
4403
|
intakeState = { paused: false };
|
|
4002
|
-
return
|
|
4404
|
+
return intakeView();
|
|
4003
4405
|
});
|
|
4004
4406
|
},
|
|
4005
4407
|
provideCredential,
|