@stratusagent/gateway 0.12.4 → 0.12.6
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 +33 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +349 -19
- 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";
|
|
@@ -267,6 +267,27 @@ class CrossIdentityError extends Error {
|
|
|
267
267
|
const crossIdentityError = (sessionId, owner, claimed) => new CrossIdentityError(sessionId, owner, claimed);
|
|
268
268
|
/** Where the session's tool calls ran: `local-command`, or a contributed executor's registered name. */
|
|
269
269
|
export const EXECUTOR_METADATA_KEY = 'executor';
|
|
270
|
+
/**
|
|
271
|
+
* Marks a sub-agent's session (roadmap 24). Reserved: it is what makes the
|
|
272
|
+
* daemon deny the session's gated calls without asking anyone, so a caller
|
|
273
|
+
* that could set it could also clear it.
|
|
274
|
+
*/
|
|
275
|
+
export const SUB_AGENT_METADATA_KEY = 'subAgent';
|
|
276
|
+
export const SUB_AGENT_SESSION_ID_MARKER = ':subagent:';
|
|
277
|
+
/** How many sub-agents one parent turn runs at once unless the host says otherwise. */
|
|
278
|
+
export const DEFAULT_SUB_AGENTS_PER_TURN = 4;
|
|
279
|
+
export const isSubAgentSession = (session) => session.metadata?.[SUB_AGENT_METADATA_KEY] === true && session.id.includes(SUB_AGENT_SESSION_ID_MARKER);
|
|
280
|
+
/**
|
|
281
|
+
* Tools a sub-agent never gets, whatever its parent holds. `agent.spawn`
|
|
282
|
+
* would fan out inside a fan-out; `agent.delegate` runs a roster agent
|
|
283
|
+
* under that agent's own allowlist, which is the way out of a narrowed
|
|
284
|
+
* slice; `memory.*` would let it persist what nobody reviewed; and
|
|
285
|
+
* `credential.request` asks a person, which a sub-agent never does.
|
|
286
|
+
*/
|
|
287
|
+
const subAgentNeverGets = (name) => name === 'agent.spawn'
|
|
288
|
+
|| name === DELEGATE_TOOL_NAME
|
|
289
|
+
|| name.startsWith('memory.')
|
|
290
|
+
|| name === CREDENTIAL_REQUEST_TOOL_NAME;
|
|
270
291
|
export const RESERVED_SESSION_METADATA_KEYS = [
|
|
271
292
|
PENDING_APPROVAL_METADATA_KEY,
|
|
272
293
|
FALLBACK_ACTIVE_METADATA_KEY,
|
|
@@ -292,6 +313,7 @@ export const RESERVED_SESSION_METADATA_KEYS = [
|
|
|
292
313
|
// no messages and, once it grows past that index, silently stops sending
|
|
293
314
|
// its own earliest turns, with no overflow and no trim event to say so.
|
|
294
315
|
CONTEXT_FLOOR_METADATA_KEY,
|
|
316
|
+
SUB_AGENT_METADATA_KEY,
|
|
295
317
|
];
|
|
296
318
|
/**
|
|
297
319
|
* The first reserved key `metadata` carries, or undefined when it carries
|
|
@@ -387,7 +409,7 @@ export const createGateway = (options = {}) => {
|
|
|
387
409
|
* "cannot address" rather than a stale reference — and rejecting is how
|
|
388
410
|
* both callers (schedule creation, `message.send`) hear why.
|
|
389
411
|
*/
|
|
390
|
-
const outboundFor = async (agentId, destination, thread) => {
|
|
412
|
+
const outboundFor = async (agentId, destination, thread, speaker) => {
|
|
391
413
|
const { adapter, carriesOthers } = channelCarrying(destination.channel, agentId, (candidate) => candidate.resolveOutbound !== undefined);
|
|
392
414
|
if (!adapter?.resolveOutbound && carriesOthers) {
|
|
393
415
|
throw new Error(`No running '${destination.channel}' channel carries agent ${agentId} — `
|
|
@@ -399,7 +421,7 @@ export const createGateway = (options = {}) => {
|
|
|
399
421
|
? 'no channel adapters are running.'
|
|
400
422
|
: `running channels: ${startedChannels.map((candidate) => candidate.name).join(', ')}.`));
|
|
401
423
|
}
|
|
402
|
-
return adapter.resolveOutbound({ agentId, to: destination.to, ...(thread !== undefined ? { thread } : {}) });
|
|
424
|
+
return adapter.resolveOutbound({ agentId, to: destination.to, ...(thread !== undefined ? { thread } : {}), ...(speaker !== undefined ? { speaker } : {}) });
|
|
403
425
|
};
|
|
404
426
|
const scheduler = createSchedulerRuntime({
|
|
405
427
|
store: scheduleStore,
|
|
@@ -489,7 +511,35 @@ export const createGateway = (options = {}) => {
|
|
|
489
511
|
}
|
|
490
512
|
return { agentId: root.agent.id, sessionId: root.id, ...(root.metadata ? { metadata: root.metadata } : {}) };
|
|
491
513
|
};
|
|
514
|
+
/**
|
|
515
|
+
* Which sessions are sub-agents, as the host knows it rather than as the
|
|
516
|
+
* session says (roadmap 24). Every tool in a slice is handed the same
|
|
517
|
+
* mutable Session, so the metadata marker and the id are claims a tool
|
|
518
|
+
* can rewrite. The ids are the ones `subAgents.run` minted, held while
|
|
519
|
+
* the sub-agent runs. A session object's id is read once, by the
|
|
520
|
+
* executor on its first tool call, before any tool has been handed it,
|
|
521
|
+
* so a session a tool renamed is still known by the id it ran under.
|
|
522
|
+
*/
|
|
523
|
+
const liveSubAgentIds = new Set();
|
|
524
|
+
const idAtFirstCall = new WeakMap();
|
|
525
|
+
const hostIdOf = (session) => idAtFirstCall.get(session) ?? session.id;
|
|
526
|
+
const isHostSubAgent = (session) => liveSubAgentIds.has(hostIdOf(session)) || isSubAgentSession(session);
|
|
492
527
|
const requestApproval = async (request) => {
|
|
528
|
+
// A sub-agent asks no one (roadmap 24): its parent turn is the one a
|
|
529
|
+
// person is accountable for, and that turn gets the refusal back as a
|
|
530
|
+
// result and decides what to do. Denied as undeliverable, since nobody
|
|
531
|
+
// was asked, and announced only as a resolution, so no channel renders
|
|
532
|
+
// a question nobody may answer.
|
|
533
|
+
if (isHostSubAgent(request.session)) {
|
|
534
|
+
void bus.emit({
|
|
535
|
+
type: 'tool.approval-resolved',
|
|
536
|
+
sessionId: request.session.id,
|
|
537
|
+
requestId: randomUUID(),
|
|
538
|
+
answer: 'deny',
|
|
539
|
+
reason: 'undeliverable',
|
|
540
|
+
});
|
|
541
|
+
return { answer: 'deny' };
|
|
542
|
+
}
|
|
493
543
|
const delegatedFrom = await delegationOriginOf(request.session);
|
|
494
544
|
return requestApprovalFrom(request, delegatedFrom);
|
|
495
545
|
};
|
|
@@ -533,6 +583,10 @@ export const createGateway = (options = {}) => {
|
|
|
533
583
|
* all and its denial should not wait on an emission that never runs.
|
|
534
584
|
*/
|
|
535
585
|
let announced = Promise.resolve();
|
|
586
|
+
// Read when the request is made, from the scheduler's own records: a
|
|
587
|
+
// live firing of a schedule approved with a destination, nothing else.
|
|
588
|
+
// Delegated work is not included yet; its sub-session is not a firing.
|
|
589
|
+
const scheduleDestination = scheduler.approvalDestinationFor(request.session);
|
|
536
590
|
// Work one agent does for another is allowed one call at a time. The
|
|
537
591
|
// people asked are the delegating conversation's, and a standing grant
|
|
538
592
|
// would widen what the other agent may do on its own work for good —
|
|
@@ -594,6 +648,7 @@ export const createGateway = (options = {}) => {
|
|
|
594
648
|
...(expiresAt ? { expiresAt } : {}),
|
|
595
649
|
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
596
650
|
...(delegatedFrom ? { delegatedFrom } : {}),
|
|
651
|
+
...(scheduleDestination ? { scheduleDestination } : {}),
|
|
597
652
|
},
|
|
598
653
|
});
|
|
599
654
|
if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
|
|
@@ -641,6 +696,7 @@ export const createGateway = (options = {}) => {
|
|
|
641
696
|
...(always !== undefined ? { always } : {}),
|
|
642
697
|
...(request.session.metadata ? { metadata: request.session.metadata } : {}),
|
|
643
698
|
...(delegatedFrom ? { delegatedFrom } : {}),
|
|
699
|
+
...(scheduleDestination ? { scheduleDestination } : {}),
|
|
644
700
|
...(expiresAt ? { expiresAt } : {}),
|
|
645
701
|
});
|
|
646
702
|
// Drained at shutdown alongside the resolutions: a channel that has not
|
|
@@ -719,6 +775,21 @@ export const createGateway = (options = {}) => {
|
|
|
719
775
|
return channelCarrying(kind, agentId, (candidate) => candidate.accountFor !== undefined)
|
|
720
776
|
.adapter?.accountFor?.(agentId);
|
|
721
777
|
},
|
|
778
|
+
conversationKindFor: async (agentId, destination) => {
|
|
779
|
+
const separator = destination.indexOf(':');
|
|
780
|
+
if (separator <= 0) {
|
|
781
|
+
return undefined;
|
|
782
|
+
}
|
|
783
|
+
const kind = destination.slice(0, separator);
|
|
784
|
+
const adapter = channelCarrying(kind, agentId, (candidate) => candidate.conversationKindFor !== undefined).adapter;
|
|
785
|
+
try {
|
|
786
|
+
return await adapter?.conversationKindFor?.(agentId, destination.slice(separator + 1));
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
// Unknown, so the call asks: never a reason to allow.
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
},
|
|
722
793
|
},
|
|
723
794
|
contributorOf: (toolName) => toolProvenance().get(toolName)?.package,
|
|
724
795
|
})
|
|
@@ -1107,10 +1178,12 @@ export const createGateway = (options = {}) => {
|
|
|
1107
1178
|
const runtimeContextFor = (source, config, onFallback,
|
|
1108
1179
|
// The turn's own metadata, where a channel records who can read the
|
|
1109
1180
|
// conversation now; a recovered turn has none and passes the session's.
|
|
1110
|
-
metadata
|
|
1181
|
+
metadata,
|
|
1182
|
+
// A sub-agent has no soul of its own, and its parent's is not its to edit.
|
|
1183
|
+
subAgent = false) => {
|
|
1111
1184
|
const conversation = conversationContextFrom(metadata);
|
|
1112
1185
|
return {
|
|
1113
|
-
...(source.soulPath !== undefined ? { soulPath: source.soulPath, soulReloads: true } : {}),
|
|
1186
|
+
...(source.soulPath !== undefined && !subAgent ? { soulPath: source.soulPath, soulReloads: true } : {}),
|
|
1114
1187
|
workspace: agentWorkspaces.forAgent(source.definition.id),
|
|
1115
1188
|
...(config.language !== undefined ? { language: config.language } : {}),
|
|
1116
1189
|
model: describeServingModel(config, onFallback),
|
|
@@ -1178,8 +1251,8 @@ export const createGateway = (options = {}) => {
|
|
|
1178
1251
|
for (const scheduleTool of createScheduleTools(scheduler.handle)) {
|
|
1179
1252
|
tools.register(scheduleTool);
|
|
1180
1253
|
}
|
|
1181
|
-
tools.register(createMessageSendTool(async ({ agentId, destination, text, thread }) => {
|
|
1182
|
-
const connection = await outboundFor(agentId, destination, thread);
|
|
1254
|
+
tools.register(createMessageSendTool(async ({ agentId, destination, text, thread, speaker }) => {
|
|
1255
|
+
const connection = await outboundFor(agentId, destination, thread, speaker);
|
|
1183
1256
|
const posted = await connection.post(text);
|
|
1184
1257
|
// The channel contract's `OutboundMessageRef`, read structurally: the
|
|
1185
1258
|
// gateway's mirror of the adapter types `post` loosely, and an adapter
|
|
@@ -1473,6 +1546,229 @@ export const createGateway = (options = {}) => {
|
|
|
1473
1546
|
...(input.signal ? { signal: input.signal } : {}),
|
|
1474
1547
|
}),
|
|
1475
1548
|
}));
|
|
1549
|
+
// ---- sub-agents (roadmap 24) -------------------------------------------
|
|
1550
|
+
/**
|
|
1551
|
+
* Running sub-agents per parent session, and who waits for a slot. A
|
|
1552
|
+
* parent session runs one turn at a time, so this is per turn.
|
|
1553
|
+
*/
|
|
1554
|
+
const maxSubAgentsPerTurn = options.subAgents?.maxConcurrentPerTurn ?? DEFAULT_SUB_AGENTS_PER_TURN;
|
|
1555
|
+
// NaN, 0, a fraction or Infinity would queue every sub-agent forever or
|
|
1556
|
+
// defeat the cap, so refuse it here instead of failing later in a turn.
|
|
1557
|
+
if (!Number.isInteger(maxSubAgentsPerTurn) || maxSubAgentsPerTurn < 1) {
|
|
1558
|
+
throw new Error(`subAgents.maxConcurrentPerTurn must be a whole number of at least 1 (got ${String(maxSubAgentsPerTurn)}).`);
|
|
1559
|
+
}
|
|
1560
|
+
const subAgentSlots = new Map();
|
|
1561
|
+
let subAgentCount = 0;
|
|
1562
|
+
/**
|
|
1563
|
+
* The session object each in-flight tool call was handed, with that
|
|
1564
|
+
* call's turn signal. `subAgents.run` accepts only a parent found here:
|
|
1565
|
+
* the session a running tool was given, by identity, never an id, so a
|
|
1566
|
+
* plugin cannot start sub-agents of another turn (another agent's
|
|
1567
|
+
* included) just because that turn is running. The signal is how the
|
|
1568
|
+
* parent's cancel reaches its sub-agents whether or not the plugin
|
|
1569
|
+
* passes one.
|
|
1570
|
+
*/
|
|
1571
|
+
const toolCallsInFlight = new Map();
|
|
1572
|
+
const bindToolCalls = (inner) => ({
|
|
1573
|
+
async execute(call, tool, session, context) {
|
|
1574
|
+
if (!idAtFirstCall.has(session)) {
|
|
1575
|
+
idAtFirstCall.set(session, session.id);
|
|
1576
|
+
}
|
|
1577
|
+
const entry = toolCallsInFlight.get(session) ?? [];
|
|
1578
|
+
toolCallsInFlight.set(session, entry);
|
|
1579
|
+
const signal = context?.signal;
|
|
1580
|
+
entry.push(signal);
|
|
1581
|
+
try {
|
|
1582
|
+
return await inner.execute(call, tool, session, context);
|
|
1583
|
+
}
|
|
1584
|
+
finally {
|
|
1585
|
+
entry.splice(entry.indexOf(signal), 1);
|
|
1586
|
+
if (entry.length === 0) {
|
|
1587
|
+
toolCallsInFlight.delete(session);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
});
|
|
1592
|
+
const subAgentSlot = (parentId, signal) => new Promise((resolve, reject) => {
|
|
1593
|
+
const entry = subAgentSlots.get(parentId) ?? { running: 0, waiting: [] };
|
|
1594
|
+
subAgentSlots.set(parentId, entry);
|
|
1595
|
+
const release = () => {
|
|
1596
|
+
entry.running -= 1;
|
|
1597
|
+
const next = entry.waiting.shift();
|
|
1598
|
+
if (next) {
|
|
1599
|
+
next();
|
|
1600
|
+
}
|
|
1601
|
+
else if (entry.running === 0) {
|
|
1602
|
+
subAgentSlots.delete(parentId);
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
const take = () => {
|
|
1606
|
+
entry.running += 1;
|
|
1607
|
+
resolve(release);
|
|
1608
|
+
};
|
|
1609
|
+
if (entry.running < maxSubAgentsPerTurn) {
|
|
1610
|
+
take();
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
const onAbort = () => {
|
|
1614
|
+
const at = entry.waiting.indexOf(waiter);
|
|
1615
|
+
if (at >= 0) {
|
|
1616
|
+
entry.waiting.splice(at, 1);
|
|
1617
|
+
}
|
|
1618
|
+
reject(abortErrorFor(signal));
|
|
1619
|
+
};
|
|
1620
|
+
const waiter = () => {
|
|
1621
|
+
signal?.removeEventListener('abort', onAbort);
|
|
1622
|
+
take();
|
|
1623
|
+
};
|
|
1624
|
+
entry.waiting.push(waiter);
|
|
1625
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1626
|
+
});
|
|
1627
|
+
/** Why this slice cannot be a sub-agent's, or undefined when it can. */
|
|
1628
|
+
const subAgentSliceProblem = (parent, requested) => {
|
|
1629
|
+
if (!Array.isArray(requested) || requested.some((name) => typeof name !== 'string' || name.length === 0)) {
|
|
1630
|
+
return 'A sub-agent\'s tools are a list of tool names.';
|
|
1631
|
+
}
|
|
1632
|
+
const allowlist = parent.agent.tools;
|
|
1633
|
+
for (const name of requested) {
|
|
1634
|
+
if (/[*?]/.test(name)) {
|
|
1635
|
+
return `A sub-agent's tools are exact names, and ${name} is a pattern.`;
|
|
1636
|
+
}
|
|
1637
|
+
if (subAgentNeverGets(name)) {
|
|
1638
|
+
return `${name} is never given to a sub-agent, even by an agent that holds it.`;
|
|
1639
|
+
}
|
|
1640
|
+
if (!tools.get(name)) {
|
|
1641
|
+
return `There is no tool named ${name}.`;
|
|
1642
|
+
}
|
|
1643
|
+
// The parent's allowlist as its running turn holds it: a slice can
|
|
1644
|
+
// only narrow what that turn may already do.
|
|
1645
|
+
if (allowlist !== undefined && !matchesToolAllowlist(name, allowlist)) {
|
|
1646
|
+
return `${name} is not one of ${parent.agent.id}'s tools, so a sub-agent of it cannot have it.`;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return undefined;
|
|
1650
|
+
};
|
|
1651
|
+
const subAgents = {
|
|
1652
|
+
async run(request) {
|
|
1653
|
+
// Copied before the first await, and only the copy is read from here
|
|
1654
|
+
// on: the plugin keeps its own objects and can change them while this
|
|
1655
|
+
// request is checked or waits for a slot, and a slice changed after
|
|
1656
|
+
// it was checked must not be the slice the sub-agent runs with.
|
|
1657
|
+
const handed = request?.parent;
|
|
1658
|
+
const callSignals = handed ? toolCallsInFlight.get(handed) : undefined;
|
|
1659
|
+
if (!callSignals) {
|
|
1660
|
+
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".');
|
|
1661
|
+
}
|
|
1662
|
+
// The parent turn's own stop, and the caller's as an extra one: a
|
|
1663
|
+
// plugin that passes no signal still has its sub-agents cancelled
|
|
1664
|
+
// with the turn.
|
|
1665
|
+
const stops = [...callSignals, request.signal].filter((signal) => signal !== undefined);
|
|
1666
|
+
const input = {
|
|
1667
|
+
parent: { id: hostIdOf(handed) },
|
|
1668
|
+
instruction: request?.instruction,
|
|
1669
|
+
tools: Array.isArray(request?.tools) ? [...request.tools] : request?.tools,
|
|
1670
|
+
model: request?.model,
|
|
1671
|
+
signal: stops.length === 0 ? undefined : stops.length === 1 ? stops[0] : AbortSignal.any(stops),
|
|
1672
|
+
};
|
|
1673
|
+
const parentId = typeof input.parent.id === 'string' ? input.parent.id : '';
|
|
1674
|
+
if (!parentId) {
|
|
1675
|
+
throw new Error('subAgents.run needs the session of the turn asking.');
|
|
1676
|
+
}
|
|
1677
|
+
const instruction = typeof input.instruction === 'string' ? input.instruction.trim() : '';
|
|
1678
|
+
if (!instruction) {
|
|
1679
|
+
throw new Error('subAgents.run needs an instruction.');
|
|
1680
|
+
}
|
|
1681
|
+
if (input.model !== undefined) {
|
|
1682
|
+
throw new Error('A sub-agent\'s model cannot be chosen yet: it runs on its parent\'s. Leave "model" out.');
|
|
1683
|
+
}
|
|
1684
|
+
const parent = await store.get(parentId);
|
|
1685
|
+
if (!parent) {
|
|
1686
|
+
throw new Error(`There is no session ${parentId} to start a sub-agent from.`);
|
|
1687
|
+
}
|
|
1688
|
+
if (isHostSubAgent(handed) || isSubAgentSession(parent)) {
|
|
1689
|
+
throw new Error('A sub-agent cannot start sub-agents.');
|
|
1690
|
+
}
|
|
1691
|
+
if (parent.status !== 'running') {
|
|
1692
|
+
throw new Error(`Session ${parentId} is not running a turn, and sub-agents start only from one.`);
|
|
1693
|
+
}
|
|
1694
|
+
const depth = typeof parent.metadata?.[DELEGATION_DEPTH_METADATA_KEY] === 'number'
|
|
1695
|
+
? parent.metadata[DELEGATION_DEPTH_METADATA_KEY]
|
|
1696
|
+
: 0;
|
|
1697
|
+
if (depth >= DEFAULT_MAX_DELEGATION_DEPTH) {
|
|
1698
|
+
throw new Error(`Delegation depth limit reached (${DEFAULT_MAX_DELEGATION_DEPTH}).`);
|
|
1699
|
+
}
|
|
1700
|
+
const problem = subAgentSliceProblem(parent, input.tools);
|
|
1701
|
+
if (problem) {
|
|
1702
|
+
throw new Error(problem);
|
|
1703
|
+
}
|
|
1704
|
+
if (input.signal?.aborted) {
|
|
1705
|
+
throw abortErrorFor(input.signal);
|
|
1706
|
+
}
|
|
1707
|
+
const release = await subAgentSlot(parent.id, input.signal);
|
|
1708
|
+
subAgentCount += 1;
|
|
1709
|
+
const sessionId = `${parent.id}${SUB_AGENT_SESSION_ID_MARKER}${depth + 1}:${subAgentCount}-${randomUUID().slice(0, 8)}`;
|
|
1710
|
+
// The parent agent, narrowed: its id, provider and credentials, the
|
|
1711
|
+
// slice for tools, and nothing else — no instructions (the persona),
|
|
1712
|
+
// no skills, no delegates.
|
|
1713
|
+
const definition = {
|
|
1714
|
+
id: parent.agent.id,
|
|
1715
|
+
name: parent.agent.name,
|
|
1716
|
+
tools: [...new Set(input.tools)],
|
|
1717
|
+
skills: [],
|
|
1718
|
+
delegates: [],
|
|
1719
|
+
...(parent.agent.credentials !== undefined ? { credentials: [...parent.agent.credentials] } : {}),
|
|
1720
|
+
};
|
|
1721
|
+
let session;
|
|
1722
|
+
liveSubAgentIds.add(sessionId);
|
|
1723
|
+
try {
|
|
1724
|
+
session = await onSessionChain(sessionId, () => dispatchInternal({
|
|
1725
|
+
sessionId,
|
|
1726
|
+
agentId: parent.agent.id,
|
|
1727
|
+
userMessage: instruction,
|
|
1728
|
+
metadata: {
|
|
1729
|
+
[SUB_AGENT_METADATA_KEY]: true,
|
|
1730
|
+
[DELEGATION_DEPTH_METADATA_KEY]: depth + 1,
|
|
1731
|
+
[DELEGATED_BY_METADATA_KEY]: parent.agent.id,
|
|
1732
|
+
[ROOT_SESSION_ID_METADATA_KEY]: typeof parent.metadata?.[ROOT_SESSION_ID_METADATA_KEY] === 'string'
|
|
1733
|
+
? parent.metadata[ROOT_SESSION_ID_METADATA_KEY]
|
|
1734
|
+
: parent.id,
|
|
1735
|
+
// The instruction is the parent's text, at the parent's label:
|
|
1736
|
+
// a tainted parent must not hand a stranger's words to a
|
|
1737
|
+
// session that has seen nothing.
|
|
1738
|
+
[SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(parent),
|
|
1739
|
+
// On the runtime serving the parent's turn: a parent that has
|
|
1740
|
+
// switched to its sticky fallback must not have its helpers
|
|
1741
|
+
// retry the primary it left.
|
|
1742
|
+
...(parent.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true ? { [FALLBACK_ACTIVE_METADATA_KEY]: true } : {}),
|
|
1743
|
+
},
|
|
1744
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
1745
|
+
}, [], definition));
|
|
1746
|
+
}
|
|
1747
|
+
catch (error) {
|
|
1748
|
+
if (input.signal?.aborted) {
|
|
1749
|
+
throw error;
|
|
1750
|
+
}
|
|
1751
|
+
// The error text may be the provider quoting what it refused,
|
|
1752
|
+
// which is this sub-agent's context: labelled as nobody can vouch.
|
|
1753
|
+
return { status: 'error', error: error instanceof Error ? error.message : String(error), sessionId, trust: 'unknown' };
|
|
1754
|
+
}
|
|
1755
|
+
finally {
|
|
1756
|
+
liveSubAgentIds.delete(sessionId);
|
|
1757
|
+
release();
|
|
1758
|
+
}
|
|
1759
|
+
const trust = sessionTrustOf(session);
|
|
1760
|
+
if (session.status === 'failed') {
|
|
1761
|
+
return { status: 'error', error: session.lastError ?? 'The sub-agent failed.', sessionId, trust: 'unknown' };
|
|
1762
|
+
}
|
|
1763
|
+
const reply = [...session.messages]
|
|
1764
|
+
.reverse()
|
|
1765
|
+
.find((message) => message.role === 'assistant' && message.content.trim().length > 0);
|
|
1766
|
+
const outcome = reply
|
|
1767
|
+
? { status: 'result', text: reply.content, sessionId, trust }
|
|
1768
|
+
: { status: 'empty', sessionId, trust };
|
|
1769
|
+
return outcome;
|
|
1770
|
+
},
|
|
1771
|
+
};
|
|
1476
1772
|
const runners = new Map();
|
|
1477
1773
|
// Every provider-construction input participates in the key — model,
|
|
1478
1774
|
// endpoint, credentials, system prompt, fallback config. A rotated
|
|
@@ -1484,13 +1780,17 @@ export const createGateway = (options = {}) => {
|
|
|
1484
1780
|
const { soul: _soul, soulPath: _soulPath, fetch: _fetch, ...providerInputs } = config;
|
|
1485
1781
|
return createHash('sha256').update(JSON.stringify(providerInputs)).digest('hex');
|
|
1486
1782
|
};
|
|
1487
|
-
|
|
1783
|
+
// A sub-agent's runner has no memory store, so no turn of it injects or
|
|
1784
|
+
// reads the agent's memory; no skill catalog, so its prompt is its
|
|
1785
|
+
// instruction and its tools; and grants nothing to every agent, since
|
|
1786
|
+
// the credential request asks a person.
|
|
1787
|
+
const runnerFor = (config, agentId, subAgent = false) => {
|
|
1488
1788
|
// The budget is a provider-construction input too (the harness
|
|
1489
1789
|
// runtimes take it as their inner limit), so it is part of the key:
|
|
1490
1790
|
// two agents on one model with different budgets get their own runners.
|
|
1491
1791
|
const maxTurns = options.maxTurnsFor?.(agentId) ?? options.maxTurns;
|
|
1492
1792
|
const autoContinue = options.autoContinueFor?.(agentId);
|
|
1493
|
-
const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}`;
|
|
1793
|
+
const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}${subAgent ? ':sub' : ''}`;
|
|
1494
1794
|
const existing = runners.get(key);
|
|
1495
1795
|
if (existing) {
|
|
1496
1796
|
return existing;
|
|
@@ -1515,16 +1815,15 @@ export const createGateway = (options = {}) => {
|
|
|
1515
1815
|
// Runners are built on dispatch, after the plugins loaded and the
|
|
1516
1816
|
// selection below ran, so a contributed executor is in place for
|
|
1517
1817
|
// the first turn.
|
|
1518
|
-
executor: selectedExecutor ?? createLocalCommandExecutor(),
|
|
1818
|
+
executor: bindToolCalls(selectedExecutor ?? createLocalCommandExecutor()),
|
|
1519
1819
|
...(approvals ? { approvals } : {}),
|
|
1520
1820
|
store,
|
|
1521
1821
|
bus,
|
|
1522
1822
|
agents: registry,
|
|
1523
1823
|
// Asking a person for a key is harmless to grant: they decide
|
|
1524
1824
|
// everything after the question, so no soul has to list it.
|
|
1525
|
-
grantedToEveryAgent: [CREDENTIAL_REQUEST_TOOL_NAME],
|
|
1526
|
-
skills: skillCatalog,
|
|
1527
|
-
memory,
|
|
1825
|
+
grantedToEveryAgent: subAgent ? [] : [CREDENTIAL_REQUEST_TOOL_NAME],
|
|
1826
|
+
...(subAgent ? {} : { skills: skillCatalog, memory }),
|
|
1528
1827
|
streaming: true,
|
|
1529
1828
|
...(maxTurns !== undefined ? { maxTurns } : {}),
|
|
1530
1829
|
...(autoContinue !== undefined
|
|
@@ -1978,7 +2277,12 @@ export const createGateway = (options = {}) => {
|
|
|
1978
2277
|
? runsOnHarness(fallback)
|
|
1979
2278
|
: runsOnHarness(config) || (fallback !== undefined && runsOnHarness(fallback));
|
|
1980
2279
|
};
|
|
1981
|
-
|
|
2280
|
+
/**
|
|
2281
|
+
* `subAgent` is the narrowed definition `subAgents.run` built: the turn
|
|
2282
|
+
* runs as that, on the parent's resolved provider, with no memory and no
|
|
2283
|
+
* soul. Only that path passes it.
|
|
2284
|
+
*/
|
|
2285
|
+
const dispatchInternal = async (input, absorbedKeys = [], subAgent) => {
|
|
1982
2286
|
// Re-read on every turn, not once at start: a newer build that stamps
|
|
1983
2287
|
// the home while this daemon is serving has formats this build does not
|
|
1984
2288
|
// understand, and a daemon that kept writing into them would be the
|
|
@@ -2001,7 +2305,10 @@ export const createGateway = (options = {}) => {
|
|
|
2001
2305
|
const existing = await store.get(input.sessionId);
|
|
2002
2306
|
const agentId = input.agentId ?? existing?.agent.id ?? await defaultAgentId();
|
|
2003
2307
|
const source = await refreshAgent(agentId);
|
|
2004
|
-
const agent = source.definition;
|
|
2308
|
+
const agent = subAgent ?? source.definition;
|
|
2309
|
+
if (subAgent !== undefined && existing) {
|
|
2310
|
+
throw new Error(`Session ${input.sessionId} already exists; a sub-agent's session is new every time.`);
|
|
2311
|
+
}
|
|
2005
2312
|
// An archived transcript is the record of a conversation that has moved
|
|
2006
2313
|
// on. Continuing it would revive a session the operator deliberately
|
|
2007
2314
|
// left behind, under an id nothing routes to any more.
|
|
@@ -2010,7 +2317,7 @@ export const createGateway = (options = {}) => {
|
|
|
2010
2317
|
throw new Error(`Session ${input.sessionId} was rolled over; it is an archived transcript and the conversation continues as ${continuedAs}.`);
|
|
2011
2318
|
}
|
|
2012
2319
|
const config = await runtimeForAgent(source);
|
|
2013
|
-
const runner = runnerFor(config, source.definition.id);
|
|
2320
|
+
const runner = runnerFor(config, source.definition.id, subAgent !== undefined);
|
|
2014
2321
|
// Which executor the turn's tool calls run through — the built-in
|
|
2015
2322
|
// under the name `stratus run` has always recorded, a contributed one
|
|
2016
2323
|
// under its registered name — so a transcript says where a command
|
|
@@ -2030,7 +2337,8 @@ export const createGateway = (options = {}) => {
|
|
|
2030
2337
|
if (existing && existing.agent.id !== agent.id) {
|
|
2031
2338
|
throw crossIdentityError(input.sessionId, existing.agent.id, agent.id);
|
|
2032
2339
|
}
|
|
2033
|
-
|
|
2340
|
+
// A sub-agent's session is new, but may start on its parent's fallback.
|
|
2341
|
+
const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(existing ?? (subAgent !== undefined ? { metadata } : undefined), config);
|
|
2034
2342
|
// Recorded with a keyed message so a restart judges the turn by what it
|
|
2035
2343
|
// could have run on, not by whatever the config resolves to by then.
|
|
2036
2344
|
// The fallback counts: a turn can switch to it mid-run. Once a session
|
|
@@ -2057,6 +2365,13 @@ export const createGateway = (options = {}) => {
|
|
|
2057
2365
|
if (isDelegatedSession(existing) && existing.metadata) {
|
|
2058
2366
|
existing.metadata = withoutDelegation(existing.metadata);
|
|
2059
2367
|
}
|
|
2368
|
+
// The same for a sub-agent's session, continued as the agent itself:
|
|
2369
|
+
// the marker that denies its approvals unasked comes off with the
|
|
2370
|
+
// delegation markers it carries.
|
|
2371
|
+
if (isSubAgentSession(existing) && existing.metadata) {
|
|
2372
|
+
const { [SUB_AGENT_METADATA_KEY]: _subAgent, ...rest } = withoutDelegation(existing.metadata);
|
|
2373
|
+
existing.metadata = rest;
|
|
2374
|
+
}
|
|
2060
2375
|
// The executor record follows the daemon, not the session's first
|
|
2061
2376
|
// turn: a restart that selected another executor runs this turn's
|
|
2062
2377
|
// commands there, and a session from before the record existed
|
|
@@ -2106,7 +2421,7 @@ export const createGateway = (options = {}) => {
|
|
|
2106
2421
|
...(hostedLoop ? { hostedLoop } : {}),
|
|
2107
2422
|
...(input.earlier !== undefined && input.earlier.length > 0 ? { earlier: input.earlier } : {}),
|
|
2108
2423
|
metadata,
|
|
2109
|
-
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
|
|
2424
|
+
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
|
|
2110
2425
|
signal,
|
|
2111
2426
|
});
|
|
2112
2427
|
});
|
|
@@ -2220,6 +2535,14 @@ export const createGateway = (options = {}) => {
|
|
|
2220
2535
|
// Whether it IS an orphan was decided before the channels started
|
|
2221
2536
|
// (`listOrphanedDelegations`), from the parent's transcript, which
|
|
2222
2537
|
// a message arriving since may already have rewritten.
|
|
2538
|
+
// A sub-agent never parks (nobody is asked for it), and if one ever
|
|
2539
|
+
// did, its parent's turn died with the last process: nothing would
|
|
2540
|
+
// read its answer, and recovering it would run it as the full agent.
|
|
2541
|
+
if (isSubAgentSession(session) && session.status === 'pending_approval') {
|
|
2542
|
+
log(`${sessionId}: a sub-agent parked when the last stratusd stopped; failing it`);
|
|
2543
|
+
await failParked(session, ABANDONED_TURN_ERROR);
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2223
2546
|
if (orphaned && isDelegatedSession(session) && session.status === 'pending_approval' && record) {
|
|
2224
2547
|
log(`${sessionId}: parked on ${record.call.toolName} for a delegating turn the last stratusd was still running; failing it`);
|
|
2225
2548
|
await failOrphanedDelegation(session);
|
|
@@ -2449,6 +2772,12 @@ export const createGateway = (options = {}) => {
|
|
|
2449
2772
|
* completes it first.)
|
|
2450
2773
|
*/
|
|
2451
2774
|
const continueAbandonedTurn = async (session) => {
|
|
2775
|
+
// Its parent's turn died with the last process, and continuing it here
|
|
2776
|
+
// would run it as the full agent. (Sub-agent turns carry no key today,
|
|
2777
|
+
// so this is a guard, not a path.)
|
|
2778
|
+
if (isSubAgentSession(session)) {
|
|
2779
|
+
return failAbandoned(session);
|
|
2780
|
+
}
|
|
2452
2781
|
// Settled either way: a turn whose agent left the roster, or whose
|
|
2453
2782
|
// provider no longer resolves, must not stay `running` for every later
|
|
2454
2783
|
// start and every redelivery to find unfinished and trip over again.
|
|
@@ -3091,6 +3420,7 @@ export const createGateway = (options = {}) => {
|
|
|
3091
3420
|
// not only on a stderr the service manager owns.
|
|
3092
3421
|
log,
|
|
3093
3422
|
warn,
|
|
3423
|
+
subAgents,
|
|
3094
3424
|
});
|
|
3095
3425
|
loadedPlugins = result.loaded;
|
|
3096
3426
|
pluginFailures = result.failures;
|