@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.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
- const runnerFor = (config, agentId) => {
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
- turnControllers.add(idle);
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
- turnControllers.delete(idle);
1939
+ untrackTurn(sessionId, idle);
1625
1940
  }
1626
1941
  }
1627
1942
  const controller = new AbortController();
1628
- turnControllers.add(controller);
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
- turnControllers.delete(controller);
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 Set();
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 = () => new Error(restartStatus
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
- const dispatchInternal = async (input, absorbedKeys = []) => {
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
- const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(existing, config);
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
- // that runs on past its approval is a turn like any other, and a
2260
- // restart's window has to be able to cut it short with the same
2261
- // reason — or it runs unseen to the second window and the restart
2262
- // reports undrained for a turn it could have finished cleanly.
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
- turnControllers.add(controller);
2265
- if (abortingWith !== undefined) {
2266
- controller.abort(new RunAbortedError(abortingWith));
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
- turnControllers.delete(controller);
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
- const stopping = [...startedChannels];
3471
- const stops = await Promise.allSettled(stopping.map((adapter) => adapter.stop()));
3472
- stops.forEach((result, index) => {
3473
- if (result.status === 'rejected') {
3474
- channelsReleased = false;
3475
- warn(`channel ${stopping[index]?.name} failed to stop: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`);
3476
- }
3477
- });
3478
- startedChannels.length = 0;
3479
- await Promise.allSettled([...inflight]);
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 controller of turnControllers) {
3505
- controller.abort(new RunAbortedError(abortingWith));
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 { ...intakeState };
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 { ...intakeState };
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 { ...intakeState };
4404
+ return intakeView();
4003
4405
  });
4004
4406
  },
4005
4407
  provideCredential,