@animalabs/agent-framework 0.29.0 → 0.30.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/src/admission/admit.d.ts +90 -0
- package/dist/src/admission/admit.d.ts.map +1 -0
- package/dist/src/admission/admit.js +43 -0
- package/dist/src/admission/admit.js.map +1 -0
- package/dist/src/agent.d.ts +21 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +43 -6
- package/dist/src/agent.js.map +1 -1
- package/dist/src/author-presentation.d.ts +50 -0
- package/dist/src/author-presentation.d.ts.map +1 -0
- package/dist/src/author-presentation.js +58 -0
- package/dist/src/author-presentation.js.map +1 -0
- package/dist/src/dendrite/index.d.ts +6 -0
- package/dist/src/dendrite/index.d.ts.map +1 -0
- package/dist/src/dendrite/index.js +3 -0
- package/dist/src/dendrite/index.js.map +1 -0
- package/dist/src/dendrite/presets.d.ts +60 -0
- package/dist/src/dendrite/presets.d.ts.map +1 -0
- package/dist/src/dendrite/presets.js +117 -0
- package/dist/src/dendrite/presets.js.map +1 -0
- package/dist/src/dendrite/registry.d.ts +260 -0
- package/dist/src/dendrite/registry.d.ts.map +1 -0
- package/dist/src/dendrite/registry.js +742 -0
- package/dist/src/dendrite/registry.js.map +1 -0
- package/dist/src/dendrite/types.d.ts +392 -0
- package/dist/src/dendrite/types.d.ts.map +1 -0
- package/dist/src/dendrite/types.js +24 -0
- package/dist/src/dendrite/types.js.map +1 -0
- package/dist/src/framework.d.ts +684 -33
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +2998 -350
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +12 -4
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +25 -5
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/host-presentation.d.ts +4 -0
- package/dist/src/host-presentation.d.ts.map +1 -1
- package/dist/src/host-presentation.js +7 -3
- package/dist/src/host-presentation.js.map +1 -1
- package/dist/src/index.d.ts +5 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +84 -9
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +136 -58
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +9 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +21 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
- package/dist/src/mcpl/toolsets-module.js +7 -0
- package/dist/src/mcpl/toolsets-module.js.map +1 -1
- package/dist/src/notice-tags.d.ts +2 -0
- package/dist/src/notice-tags.d.ts.map +1 -1
- package/dist/src/notice-tags.js +2 -0
- package/dist/src/notice-tags.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +4 -2
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +7 -4
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/stored-injections.d.ts +4 -0
- package/dist/src/stored-injections.d.ts.map +1 -1
- package/dist/src/stored-injections.js +6 -0
- package/dist/src/stored-injections.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +379 -9
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +1080 -97
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +101 -5
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/tune-out/tools.js +55 -18
- package/dist/src/tune-out/tools.js.map +1 -1
- package/dist/src/types/agent.d.ts +25 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +99 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +136 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/admission/admit.ts +113 -0
- package/src/agent.ts +48 -6
- package/src/author-presentation.ts +81 -0
- package/src/dendrite/index.ts +32 -0
- package/src/dendrite/presets.ts +149 -0
- package/src/dendrite/registry.ts +808 -0
- package/src/dendrite/types.ts +397 -0
- package/src/framework.ts +3239 -369
- package/src/gate/event-gate.ts +31 -5
- package/src/host-presentation.ts +10 -3
- package/src/index.ts +15 -2
- package/src/mcpl/channel-registry.ts +170 -60
- package/src/mcpl/push-coalescer.ts +19 -0
- package/src/mcpl/toolsets-module.ts +7 -0
- package/src/notice-tags.ts +2 -0
- package/src/silent-heartbeat.ts +7 -4
- package/src/stored-injections.ts +7 -0
- package/src/tune-out/coordinator.ts +1229 -98
- package/src/tune-out/tools.ts +152 -11
- package/src/types/agent.ts +28 -0
- package/src/types/framework.ts +102 -0
- package/src/types/trace.ts +150 -0
package/dist/src/framework.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue } from "./tool-presentation.js";
|
|
2
|
+
import { presentAuthor } from './author-presentation.js';
|
|
2
3
|
import { constants as bufferConstants } from 'node:buffer';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { EXPIRY_REASON, REFERENCE_LIMITS, claimedExpiry, classifyBlock, isReferenceExpired, referenceBlockForScript, referenceRegistry, sanitizeLabel } from './mcpl/references.js';
|
|
@@ -9,8 +10,9 @@ import { MembraneError } from '@animalabs/membrane';
|
|
|
9
10
|
import { classifyFailure, retryDecision, MEMBRANE_RETRY_SETTINGS } from '@animalabs/membrane/retry';
|
|
10
11
|
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager';
|
|
11
12
|
import { SILENT_HEARTBEAT_SEPARATOR } from './silent-heartbeat.js';
|
|
12
|
-
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
|
|
13
|
+
import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, defaultReaderFraming, } from './tune-out/tools.js';
|
|
13
14
|
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS, alreadyTunedOutText } from './tune-out/coordinator.js';
|
|
15
|
+
import { admit } from './admission/admit.js';
|
|
14
16
|
import { bodyGroupRun, bodyRangeIn, locateMessageIndex, locateMessageIndices, removeStoredMessage, removeStoredMessages, } from './stored-removal.js';
|
|
15
17
|
import { asModuleRow, heldHandle, heldWriteId, matchesMessageQuery } from './held-writes.js';
|
|
16
18
|
import { ProcessQueueImpl } from './queue.js';
|
|
@@ -41,6 +43,7 @@ import { InferenceRouter } from './mcpl/inference-router.js';
|
|
|
41
43
|
import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, NO_CHANNEL_EXAMPLE, NO_PERSON_EXAMPLE, qualifiedChannelName, sameChannel } from './mcpl/channel-registry.js';
|
|
42
44
|
import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt } from './prose-drafts.js';
|
|
43
45
|
import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
|
|
46
|
+
import { AgentRegistry, conversationForkSpec, residentSpec, subconsciousSpec, taskForkSpec, workerSpec, } from './dendrite/index.js';
|
|
44
47
|
import { INBOUND_SOURCE_KEY, sourceBodyDigest } from './mcpl/inbound-source.js';
|
|
45
48
|
import { markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from './supplied-text.js';
|
|
46
49
|
import { NOTICE_TAG } from './notice-tags.js';
|
|
@@ -65,7 +68,7 @@ import { splitThinkingDrops, ThinkingLossIncidents } from './thinking-drops.js';
|
|
|
65
68
|
import { asNextRequestCarries, roundRows } from './sent-rows.js';
|
|
66
69
|
import { ReasoningLossTeller } from './reasoning-loss.js';
|
|
67
70
|
import { contentAsStored, nameAsStored } from './text-as-stored.js';
|
|
68
|
-
import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isStoredKind, storedInjectionMarkOf, unitHash } from './stored-injections.js';
|
|
71
|
+
import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isOwnInjection, isStoredKind, storedInjectionMarkOf, unitHash } from './stored-injections.js';
|
|
69
72
|
/** An MCPL tool call refused because its server isn't in the registry: one
|
|
70
73
|
* closed for good, one removed, or one whose connect failed without
|
|
71
74
|
* reconnect, which this host doesn't bring back on its own. A server that's
|
|
@@ -244,10 +247,11 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread';
|
|
|
244
247
|
* a deferred write, `external` a module's own row for an outside message,
|
|
245
248
|
* `openaiResponsesItems` membrane's mark of an imported Responses
|
|
246
249
|
* history, `contextInjection` an agent's stored context injection
|
|
247
|
-
* (stored-injections.ts), which every other agent's view leaves out,
|
|
250
|
+
* (stored-injections.ts), which every other agent's view leaves out,
|
|
248
251
|
* `primaryIfUnread` a module's write for an ephemeral run or a
|
|
249
252
|
* conversation fork, which goes to the primary if its target never read it
|
|
250
|
-
* (passesToPrimary)
|
|
253
|
+
* (passesToPrimary), and `dendrite` the stamp of delivered agent mail, whose
|
|
254
|
+
* `mailId` releases that held mail once the row lands (releaseLandedMail). A
|
|
251
255
|
* server's metadata, a channel message's `metadata` or a
|
|
252
256
|
* push event's `origin`, is stored without them, and the host writes its own
|
|
253
257
|
* after it: MCPL SPEC §16.6 makes metadata the producer's untrusted claims,
|
|
@@ -258,7 +262,7 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread';
|
|
|
258
262
|
*/
|
|
259
263
|
const HOST_METADATA_KEYS = [
|
|
260
264
|
'system', 'kind', 'draftOwner', 'draftIds', 'tuneOut', 'coalescingSubject', 'firstView',
|
|
261
|
-
'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY,
|
|
265
|
+
'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY, 'dendrite',
|
|
262
266
|
];
|
|
263
267
|
/**
|
|
264
268
|
* True when an injected message is real conversational input — something the
|
|
@@ -510,13 +514,15 @@ function mcplToolNoWordsText(serverId, callName) {
|
|
|
510
514
|
'Whether it did anything is unknown: check before calling it again.';
|
|
511
515
|
}
|
|
512
516
|
/**
|
|
513
|
-
* True when an incoming channel message explicitly addressed the agent
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
+
* True when an incoming channel message explicitly addressed the agent: the
|
|
518
|
+
* MCPL RFC-001 `chat:addressed` tag (mention / reply / DM, as classified by
|
|
519
|
+
* the server). Tag-only — there is no metadata fallback, so a server that
|
|
520
|
+
* does not tag never addresses. Addressed messages outrank ambient chatter
|
|
517
521
|
* when picking a turn's frozen speech locus.
|
|
518
522
|
*/
|
|
519
|
-
const isAddressedMessage = (tags
|
|
523
|
+
const isAddressedMessage = (tags) => Array.isArray(tags) && tags.includes('chat:addressed');
|
|
524
|
+
/** The author a channel message names, for a notice about it. */
|
|
525
|
+
const authorName = (metadata) => metadata?.author?.name ?? 'Someone';
|
|
520
526
|
/** A wake's `ambient` mark: only a message its server tagged `chat:ambient`
|
|
521
527
|
* (RFC-001) is known to ask nothing of the agent. */
|
|
522
528
|
const ambientMark = (tags) => Array.isArray(tags) && tags.includes('chat:ambient') ? { ambient: true } : {};
|
|
@@ -669,7 +675,7 @@ import { HostPresentation, BUILTIN_TOOLSET } from './host-presentation.js';
|
|
|
669
675
|
import { isEmptyChange } from './mcpl/tool-presence.js';
|
|
670
676
|
import { ToolsetsModule, resolveAct, withLookHint } from './mcpl/toolsets-module.js';
|
|
671
677
|
import { ModernMcpConnection } from './mcpl/modern-connection.js';
|
|
672
|
-
import { WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor } from './mcpl/protocol-family.js';
|
|
678
|
+
import { MAX_TIMER_MS, WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor } from './mcpl/protocol-family.js';
|
|
673
679
|
import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js';
|
|
674
680
|
import { boundedForLog, describeCause } from './mcpl/errors.js';
|
|
675
681
|
import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js';
|
|
@@ -679,6 +685,12 @@ import { firstFreeBranchName } from './branch-names.js';
|
|
|
679
685
|
import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, DiscordAwarenessRefusal, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, selectDiscordAwarenessRefs, checkedDiscordAwarenessMarks, boundDiscordAwarenessText, discordChannelOf, isPermanentDiscordReactionFailure, unsyncedWriteText, } from './recovery/discord-awareness-outbox.js';
|
|
680
686
|
import { OperatorActionError, OperatorLog, capIds, defaultOperatorLogPath, } from './operator-log.js';
|
|
681
687
|
const FRAMEWORK_STATE_ID = 'framework/state';
|
|
688
|
+
/** Dendrite agent registry: live records, incarnation counters, held mail. */
|
|
689
|
+
const AGENT_REGISTRY_STATE_ID = 'framework/dendrite';
|
|
690
|
+
/** Dendrite agent registry: append-only history of ended agent records. */
|
|
691
|
+
const AGENT_REGISTRY_ENDED_ID = 'framework/dendrite/ended';
|
|
692
|
+
/** How many ended agent records are reloaded for inspection at boot. */
|
|
693
|
+
const AGENT_REGISTRY_ENDED_RELOAD = 256;
|
|
682
694
|
/** Snapshot slot mirroring context writes deferred while quiesced (#122). */
|
|
683
695
|
const DEFERRED_WRITES_ID = 'framework/deferred-writes';
|
|
684
696
|
/** RFC-006 coalescing bookkeeping: receipts (retry window) + subject history. */
|
|
@@ -750,11 +762,12 @@ function describeDeferredWrite(msg) {
|
|
|
750
762
|
/** Whether a deferred message can join a live stream at a tool boundary:
|
|
751
763
|
* no tool blocks (the membrane's tool cycle) and not named as the agent
|
|
752
764
|
* itself (it would render as an assistant turn, a prefill). */
|
|
753
|
-
function isInjectableMidTurn(msg,
|
|
754
|
-
// Exact, as membrane's role test is:
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
|
|
765
|
+
function isInjectableMidTurn(msg, agent) {
|
|
766
|
+
// Exact, as membrane's role test is: a participant the request assigns
|
|
767
|
+
// the assistant role (the agent's name, and for a derived agent the
|
|
768
|
+
// voice it inherited) renders as the assistant. It's the name as sent,
|
|
769
|
+
// which the boundary takes as the store keeps it.
|
|
770
|
+
return !agent.isOwnParticipant(nameAsStored(msg.participant))
|
|
758
771
|
&& !msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
|
|
759
772
|
}
|
|
760
773
|
/**
|
|
@@ -933,6 +946,7 @@ function freshTurnRouting() {
|
|
|
933
946
|
engaged: new Set(),
|
|
934
947
|
wasIn: [],
|
|
935
948
|
continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], heard: new Map(),
|
|
949
|
+
addressedElsewhere: new Set(),
|
|
936
950
|
};
|
|
937
951
|
}
|
|
938
952
|
const sameReach = (a, b) => {
|
|
@@ -1618,6 +1632,33 @@ function extractGateFields(event) {
|
|
|
1618
1632
|
: undefined;
|
|
1619
1633
|
return { content, metadata, mount, paths };
|
|
1620
1634
|
}
|
|
1635
|
+
/**
|
|
1636
|
+
* The admission record on a stored message: it was held from the residents
|
|
1637
|
+
* by a policy epoch (`metadata.tuneOut = { epochId }`). ONE predicate for
|
|
1638
|
+
* "held from the resident", read by the compiled-view filter and by the
|
|
1639
|
+
* mid-turn injection alike (#232 H1), so the window and the wire agree.
|
|
1640
|
+
*/
|
|
1641
|
+
export const heldFromResidents = (metadata) => !!metadata?.tuneOut;
|
|
1642
|
+
/**
|
|
1643
|
+
* What a reader of the residents' shared message slot does not see: held
|
|
1644
|
+
* messages. Used by every resident, and by a context derived from one when
|
|
1645
|
+
* it is reopened.
|
|
1646
|
+
*/
|
|
1647
|
+
const RESIDENT_VIEW_FILTER = (message) => !heldFromResidents(message.metadata);
|
|
1648
|
+
/** Rejection delivered to the caller awaiting a bounded run that was ended from outside. */
|
|
1649
|
+
export class AgentStoppedError extends Error {
|
|
1650
|
+
agentName;
|
|
1651
|
+
reason;
|
|
1652
|
+
by;
|
|
1653
|
+
constructor(agentName, reason, by, detail) {
|
|
1654
|
+
super(`Agent "${agentName}" was ended (${reason}${by ? ` by ${by}` : ''})` +
|
|
1655
|
+
(detail ? `: ${detail}` : ''));
|
|
1656
|
+
this.agentName = agentName;
|
|
1657
|
+
this.reason = reason;
|
|
1658
|
+
this.by = by;
|
|
1659
|
+
this.name = 'AgentStoppedError';
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1621
1662
|
export const REINIT_MOVES = ['undo', 'redo', 'rollback', 'suppression', 'branch switch'];
|
|
1622
1663
|
const RECOVERY = 're-initializes it and stores its queued writes, at resume on a quiesced host; a restart can lose them.';
|
|
1623
1664
|
export function refusedWorkAlert(move, cause) {
|
|
@@ -2248,6 +2289,13 @@ export class AgentFramework {
|
|
|
2248
2289
|
quiesceReason;
|
|
2249
2290
|
quiescedAt;
|
|
2250
2291
|
traceListeners = [];
|
|
2292
|
+
/** Traces emitted before start() — registry restore, interrupted work,
|
|
2293
|
+
* resumed epochs — replayed to listeners that attach afterwards, since a
|
|
2294
|
+
* host attaches its sinks after create() returns. Dropped at start().
|
|
2295
|
+
* It keeps the first 1,000, the start of the stream, and counts the rest
|
|
2296
|
+
* (earlyTracesDropped), which a replay ends by naming. */
|
|
2297
|
+
earlyTraces = [];
|
|
2298
|
+
earlyTracesDropped = 0;
|
|
2251
2299
|
syncIntervalMs;
|
|
2252
2300
|
syncTimer = null;
|
|
2253
2301
|
maintenanceIntervalMs;
|
|
@@ -2308,8 +2356,9 @@ export class AgentFramework {
|
|
|
2308
2356
|
midTurnInputSignals = new Set();
|
|
2309
2357
|
/** Last outbound locus announced to each agent as a durable `[routing]`
|
|
2310
2358
|
* window message. Announce-on-change only: steady state emits nothing
|
|
2311
|
-
* (KV-safe, no per-turn chatter).
|
|
2312
|
-
*
|
|
2359
|
+
* (KV-safe, no per-turn chatter). Kept in the store as well
|
|
2360
|
+
* (rememberAnnouncedLocus), so a restart compares with what the agent was
|
|
2361
|
+
* last told rather than with nothing. */
|
|
2313
2362
|
lastAnnouncedLocus = new Map();
|
|
2314
2363
|
// ---- Held prose drafts (src/prose-drafts.ts) ---------------------------
|
|
2315
2364
|
/** Every resident's held drafts: plain speech that was suppressed, bounced
|
|
@@ -2395,6 +2444,13 @@ export class AgentFramework {
|
|
|
2395
2444
|
usedEphemeralAgentNames = new Map();
|
|
2396
2445
|
/** One-shot generation tickets minted by createEphemeralAgent. */
|
|
2397
2446
|
ephemeralCandidates = new WeakMap();
|
|
2447
|
+
/**
|
|
2448
|
+
* Derived agent → the agent whose tool presentation it shows. A derived
|
|
2449
|
+
* agent that inherits its parent's tools must advertise the identical
|
|
2450
|
+
* tool block (it is the front of the shared request prefix), including
|
|
2451
|
+
* the parent's resident-edited visibility and descriptions.
|
|
2452
|
+
*/
|
|
2453
|
+
toolSurfaceOwners = new Map();
|
|
2398
2454
|
/** Every agent createEphemeralAgent made, for as long as the object lives:
|
|
2399
2455
|
* its usage counts in the usage record's one shared ephemeral entry. Kept
|
|
2400
2456
|
* by the object, not the run's record (ephemeralRuns), which its cleanup
|
|
@@ -2465,8 +2521,25 @@ export class AgentFramework {
|
|
|
2465
2521
|
subconsciousStrategy = null;
|
|
2466
2522
|
/** Its config (speak_in_channel gate, voice block provenance). */
|
|
2467
2523
|
subconsciousConfig = null;
|
|
2468
|
-
/** Tune-out coordinator (issue #77); non-null iff
|
|
2524
|
+
/** Tune-out coordinator (issue #77); non-null iff a reader is configured + channels. */
|
|
2469
2525
|
tuneOutCoordinator = null;
|
|
2526
|
+
/** Residents are offered `tune_out` (a reader is configured, or `tuneOut.enabled`). */
|
|
2527
|
+
tuneOutOffered = false;
|
|
2528
|
+
/**
|
|
2529
|
+
* The reply anchor a coalesced delivery would set, parked until the
|
|
2530
|
+
* acceptance is acknowledged to the server (handleCoalescedIncoming): an
|
|
2531
|
+
* acceptance whose commit fails after its delivery moves no anchor, and the
|
|
2532
|
+
* retry the receipt answers, which delivers nothing again, commits the one
|
|
2533
|
+
* its first attempt parked. Keyed by server and event id; bounded.
|
|
2534
|
+
*/
|
|
2535
|
+
parkedAnchors = new Map();
|
|
2536
|
+
/** `tuneOut.announce`: the excepted channel hears the all-except scope open and close. */
|
|
2537
|
+
tuneOutAnnounce = true;
|
|
2538
|
+
/** `speech.locus`: where prose goes when nothing names a destination (see FrameworkConfig). */
|
|
2539
|
+
speechLocus = 'delivery';
|
|
2540
|
+
/** `subconscious.reader: 'forks'`: the reader is a succession of forks (Dendrite). */
|
|
2541
|
+
readerForks = false;
|
|
2542
|
+
readerForkCounter = 0;
|
|
2470
2543
|
/** Agents an abandon could not cancel (token held, no stream) — surfaced
|
|
2471
2544
|
* in HostModeStatus until the next quiesce/resume. */
|
|
2472
2545
|
lastUnabandonable = [];
|
|
@@ -2484,6 +2557,24 @@ export class AgentFramework {
|
|
|
2484
2557
|
/** True once the cap has been met in the current run, so its line is
|
|
2485
2558
|
* logged once a run, whatever began the run. */
|
|
2486
2559
|
deferredWritesCapLogged = false;
|
|
2560
|
+
/** The agent registry and its ended log, as recovery files beside the store
|
|
2561
|
+
* (nissa's #546 finding 3): Chronicle state follows the store's branch, and
|
|
2562
|
+
* an undo or an operator's surgery must not erase a result held for
|
|
2563
|
+
* delivery or the record of work that ran. The file wins where it exists;
|
|
2564
|
+
* the branch-local state is read only for stores that predate it. */
|
|
2565
|
+
agentRegistryPath;
|
|
2566
|
+
agentRegistryEndedPath;
|
|
2567
|
+
/** The ended log as the recovery file keeps it: the newest records, bounded. */
|
|
2568
|
+
endedRecovery = [];
|
|
2569
|
+
/** The branch-local ended log by name (its newest entry for each), and how far it is indexed (findEndedAgentRecord). */
|
|
2570
|
+
endedLogIndex = new Map();
|
|
2571
|
+
/** The ended log's entries by the namespace their context holds (the latest for each). */
|
|
2572
|
+
endedLogNamespaceIndex = new Map();
|
|
2573
|
+
/** Namespaces this process opened for an ephemeral agent's context, by owner: candidates not yet registered included. */
|
|
2574
|
+
namespaceOwners = new Map();
|
|
2575
|
+
endedLogIndexed = 0;
|
|
2576
|
+
/** The last indexed entry's mark, so a log that changed under the index is seen. */
|
|
2577
|
+
endedLogTail = '';
|
|
2487
2578
|
/** Branch-independent recovery files (see FrameworkConfig.hostModePath /
|
|
2488
2579
|
* deferredWritesPath). Undefined → branch-local slot fallback. */
|
|
2489
2580
|
hostModePath;
|
|
@@ -2634,12 +2725,32 @@ export class AgentFramework {
|
|
|
2634
2725
|
coalescingSnapshotDirty = null;
|
|
2635
2726
|
coalescingRecentReceipts = [];
|
|
2636
2727
|
/**
|
|
2637
|
-
*
|
|
2638
|
-
*
|
|
2639
|
-
*
|
|
2640
|
-
*
|
|
2641
|
-
*
|
|
2642
|
-
*/
|
|
2728
|
+
* Dendrite agent registry — every agent this framework runs, with its
|
|
2729
|
+
* roles, lifetime and relationships. `agents` stays the map of live Agent
|
|
2730
|
+
* OBJECTS (the generation identity streams are checked against); the
|
|
2731
|
+
* registry is what says what each of them is for. Selection sites read
|
|
2732
|
+
* roles from here instead of excluding kinds by hand.
|
|
2733
|
+
*/
|
|
2734
|
+
registry = new AgentRegistry({
|
|
2735
|
+
persist: (snapshot) => this.persistAgentRegistry(snapshot),
|
|
2736
|
+
appendEnded: (record) => this.appendEndedAgentRecord(record),
|
|
2737
|
+
onRollback: () => this.rewriteAgentRegistryAfterRollback(),
|
|
2738
|
+
findEnded: (name) => this.findEndedAgentRecord(name),
|
|
2739
|
+
});
|
|
2740
|
+
/** Tell the first adoption candidate when task work is orphaned (config.dendrite.announceOrphans). */
|
|
2741
|
+
announceOrphans = true;
|
|
2742
|
+
/**
|
|
2743
|
+
* After this many derivations of one kind from one agent, that agent hears
|
|
2744
|
+
* a count line every so many instead of a line per fork
|
|
2745
|
+
* (config.dendrite.collapseDerivationNoticesAfter; 0 = never collapse).
|
|
2746
|
+
*/
|
|
2747
|
+
collapseDerivationNoticesAfter = 10;
|
|
2748
|
+
/** The host's say over each derivation, ephemeral spawn and resume (config.dendrite.beforeDerive). */
|
|
2749
|
+
beforeDerive;
|
|
2750
|
+
/** What a source is told of a derivation (config.dendrite.derivationNotice); absent, the default line. */
|
|
2751
|
+
derivationNoticeHook;
|
|
2752
|
+
/** Per source agent, per kind: how many derivations, since when, and at which count the last note went. */
|
|
2753
|
+
derivationTallies = new Map();
|
|
2643
2754
|
sharedSlotAgents = new Set();
|
|
2644
2755
|
/** The batched-wake lines already written (placeWakeLines), so every agent a
|
|
2645
2756
|
* wake was for, and every later start of it, writes each at most once. */
|
|
@@ -2648,6 +2759,7 @@ export class AgentFramework {
|
|
|
2648
2759
|
* in the round counted by their share of the bound: such a round is
|
|
2649
2760
|
* stored by its share whether or not it fits (buildStoredToolResultContent). */
|
|
2650
2761
|
toolRoundsByShare = new WeakSet();
|
|
2762
|
+
/** Group commit: one fsync per event-loop turn, shared by every waiter. */
|
|
2651
2763
|
/** The windows of forks and runs deregistered while the host was quiesced,
|
|
2652
2764
|
* by agent name (releaseWindowNowOrAfterResume); resume() releases them
|
|
2653
2765
|
* (runReleasesAfterResume), or stop() passes on what they left, unread or
|
|
@@ -2656,6 +2768,7 @@ export class AgentFramework {
|
|
|
2656
2768
|
releasesAfterResume = new Map();
|
|
2657
2769
|
managerReleases = new WeakMap();
|
|
2658
2770
|
releasedByCleanup = new WeakSet();
|
|
2771
|
+
resumesPending = new Map();
|
|
2659
2772
|
// context-manager 0.23.1: a strategy that keeps its context, as
|
|
2660
2773
|
// AutobiographicalStrategy does (hostContext, autobiographical.ts:2897),
|
|
2661
2774
|
// keeps its manager reachable while its work is pending, since the
|
|
@@ -2676,19 +2789,17 @@ export class AgentFramework {
|
|
|
2676
2789
|
conversationRouter = null;
|
|
2677
2790
|
/** Agent configs by name — fork agents are built from the template's config. */
|
|
2678
2791
|
agentConfigs = new Map();
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
*/
|
|
2684
|
-
conversationAgentHomes = new Map();
|
|
2792
|
+
// A fork agent's home channel is its registry record's `homeChannel`
|
|
2793
|
+
// (homeOf). Permanent (unlike router bindings, which expire):
|
|
2794
|
+
// publish/injection scoping must survive unbinding so the closure turn
|
|
2795
|
+
// still lands in the right channel.
|
|
2685
2796
|
/** Forks being spawned, by channel id, each until its spawn settles (see
|
|
2686
2797
|
* routeConversation). The promise never rejects. */
|
|
2687
2798
|
conversationSpawns = new Map();
|
|
2688
2799
|
/** Last idle-TTL sweep timestamp. */
|
|
2689
2800
|
lastConversationSweep = 0;
|
|
2690
2801
|
/** Forks whose TTL closure turn has been queued — disposed (removed from
|
|
2691
|
-
* agents/agentConfigs
|
|
2802
|
+
* agents/agentConfigs and ended in the registry) when their stream ends. */
|
|
2692
2803
|
closingConversationAgents = new Set();
|
|
2693
2804
|
// Client-side programmatic tool calling (`code_execution`). Null unless
|
|
2694
2805
|
// config.codeExecution.enabled: its settings, and every agent's scripts,
|
|
@@ -2991,7 +3102,7 @@ export class AgentFramework {
|
|
|
2991
3102
|
// Register framework states
|
|
2992
3103
|
ensureStateRegistered(store, { id: FRAMEWORK_STATE_ID, strategy: 'snapshot' });
|
|
2993
3104
|
ensureStateRegistered(store, { id: DEFERRED_WRITES_ID, strategy: 'snapshot' });
|
|
2994
|
-
for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) {
|
|
3105
|
+
for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID, AGENT_REGISTRY_STATE_ID]) {
|
|
2995
3106
|
ensureStateRegistered(store, { id, strategy: 'snapshot' });
|
|
2996
3107
|
}
|
|
2997
3108
|
ensureStateRegistered(store, {
|
|
@@ -3019,6 +3130,12 @@ export class AgentFramework {
|
|
|
3019
3130
|
throw new Error(`the conversation router's generation counters (${CONVERSATION_ROUTER_STATE_ID}) could not be read: ${thrownText(error)}`, { cause: error });
|
|
3020
3131
|
}
|
|
3021
3132
|
}
|
|
3133
|
+
ensureStateRegistered(store, {
|
|
3134
|
+
id: AGENT_REGISTRY_ENDED_ID,
|
|
3135
|
+
strategy: 'append_log',
|
|
3136
|
+
deltaSnapshotEvery: 100,
|
|
3137
|
+
fullSnapshotEvery: 20,
|
|
3138
|
+
});
|
|
3022
3139
|
// Process logging config (default: disabled)
|
|
3023
3140
|
const processLoggingPersist = config.processLogging?.persist === true;
|
|
3024
3141
|
// Register process log state only if persistence is enabled
|
|
@@ -3081,8 +3198,36 @@ export class AgentFramework {
|
|
|
3081
3198
|
// subconscious disabled now may have run and left its own entry.
|
|
3082
3199
|
framework.restoreUsageState([
|
|
3083
3200
|
...config.agents.map((agent) => String(agent.name)),
|
|
3084
|
-
|
|
3201
|
+
// A forks reader has no agent of its own; its forks are bounded jobs.
|
|
3202
|
+
...(config.subconscious && config.subconscious.reader !== 'forks' ? [String(config.subconscious.name ?? 'Subconscious')] : []),
|
|
3085
3203
|
]);
|
|
3204
|
+
framework.announceOrphans = config.dendrite?.announceOrphans ?? true;
|
|
3205
|
+
framework.collapseDerivationNoticesAfter = Math.max(0, Math.floor(config.dendrite?.collapseDerivationNoticesAfter ?? 10));
|
|
3206
|
+
framework.beforeDerive = config.dendrite?.beforeDerive;
|
|
3207
|
+
framework.derivationNoticeHook = config.dendrite?.derivationNotice;
|
|
3208
|
+
framework.speechLocus = config.speech?.locus ?? 'delivery';
|
|
3209
|
+
framework.tuneOutOffered = !!config.subconscious?.enabled || config.tuneOut?.enabled === true;
|
|
3210
|
+
framework.tuneOutAnnounce = config.tuneOut?.announce !== false;
|
|
3211
|
+
// What the previous process left in the agent registry. Read before any
|
|
3212
|
+
// agent is declared so a restored persistent agent becomes a new
|
|
3213
|
+
// incarnation of the same identity rather than a stranger. The registry
|
|
3214
|
+
// lives in recovery files beside the store (see agentRegistryPath).
|
|
3215
|
+
framework.agentRegistryPath = config.agentRegistryPath
|
|
3216
|
+
?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-registry.json') : undefined);
|
|
3217
|
+
framework.agentRegistryEndedPath = config.agentRegistryEndedPath
|
|
3218
|
+
?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-ended.json') : undefined);
|
|
3219
|
+
if (!framework.agentRegistryPath || !framework.agentRegistryEndedPath) {
|
|
3220
|
+
const missing = [
|
|
3221
|
+
...(framework.agentRegistryPath ? [] : ['agentRegistryPath']),
|
|
3222
|
+
...(framework.agentRegistryEndedPath ? [] : ['agentRegistryEndedPath']),
|
|
3223
|
+
];
|
|
3224
|
+
console.error(`[dendrite] no storePath / ${missing.join(' / ')}: the agent registry's ` +
|
|
3225
|
+
(framework.agentRegistryPath
|
|
3226
|
+
? 'ended log persists only in the branch-local store, where a branch switch or historical rollback can erase it'
|
|
3227
|
+
: 'records and held results persist only in the branch-local store, where a branch switch or historical rollback can erase them') +
|
|
3228
|
+
', and a crash can lose what wasn\'t synced; configure the recovery paths');
|
|
3229
|
+
}
|
|
3230
|
+
const interruptedAgents = framework.restoreAgentRegistry();
|
|
3086
3231
|
// Create agents
|
|
3087
3232
|
for (const agentConfig of config.agents) {
|
|
3088
3233
|
await framework.createAgent(agentConfig);
|
|
@@ -3092,8 +3237,13 @@ export class AgentFramework {
|
|
|
3092
3237
|
// The subconscious resident (issue #77) registers after the residents so
|
|
3093
3238
|
// it can never become primary; it is excluded from every broadcast
|
|
3094
3239
|
// fan-out and woken only by the tune-out coordinator's explicit pushes.
|
|
3095
|
-
if (config.subconscious?.enabled
|
|
3096
|
-
|
|
3240
|
+
if (config.subconscious?.enabled) {
|
|
3241
|
+
if (config.subconscious.reader === 'forks') {
|
|
3242
|
+
framework.configureReaderForks(config.subconscious);
|
|
3243
|
+
}
|
|
3244
|
+
else {
|
|
3245
|
+
await framework.createSubconsciousAgent(config.subconscious);
|
|
3246
|
+
}
|
|
3097
3247
|
}
|
|
3098
3248
|
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
3099
3249
|
// branches. This runs before modules, MCPL connections, or inbound traffic.
|
|
@@ -3218,7 +3368,8 @@ export class AgentFramework {
|
|
|
3218
3368
|
})(),
|
|
3219
3369
|
});
|
|
3220
3370
|
},
|
|
3221
|
-
getAgentNames: () =>
|
|
3371
|
+
getAgentNames: () => framework.registry.gateRecipients(),
|
|
3372
|
+
isHeld: (serverId, channelId) => !!(serverId && channelId && framework.tuneOutCoordinator?.holdFor(serverId, channelId)),
|
|
3222
3373
|
// Push events reach the gate with the adapter's raw channel id; map
|
|
3223
3374
|
// them to the registered composite id the same way ingestion does
|
|
3224
3375
|
// (handleMcplPushEvent registers that channel on arrival, before any
|
|
@@ -3372,6 +3523,14 @@ export class AgentFramework {
|
|
|
3372
3523
|
// and each MCPL server's tab, per agent, kept beside the store.
|
|
3373
3524
|
framework.hostPresentation = framework.makeHostPresentation(config.storePath);
|
|
3374
3525
|
// Initialize MCPL subsystems if configured
|
|
3526
|
+
// Configuration has now declared its agents, and host mode and the
|
|
3527
|
+
// deferred-write queue are restored: settle what the previous process
|
|
3528
|
+
// left unfinished (interrupted bounded work, persistent agents no longer
|
|
3529
|
+
// configured, results still held for delivery). After the restore, so a
|
|
3530
|
+
// quiesced host holds the restart notices and redelivered mail as it
|
|
3531
|
+
// holds any write, and mail an entry of the restored queue carries is
|
|
3532
|
+
// left to that entry's landing (nissa's #546 finding 4).
|
|
3533
|
+
framework.reconcileAgentRegistry(interruptedAgents);
|
|
3375
3534
|
if (config.mcplServers && config.mcplServers.length > 0) {
|
|
3376
3535
|
// Check every server's config before any is brought up: its boolean
|
|
3377
3536
|
// settings, and its tool prefix, which mustn't collide with a module
|
|
@@ -3422,21 +3581,34 @@ export class AgentFramework {
|
|
|
3422
3581
|
// MCPL or none.
|
|
3423
3582
|
await framework.ensureToolsetsModule();
|
|
3424
3583
|
}
|
|
3425
|
-
// Tune-out coordinator (issue #77):
|
|
3426
|
-
//
|
|
3427
|
-
|
|
3584
|
+
// Tune-out coordinator (issue #77, #232 §3.3): the hold machinery exists
|
|
3585
|
+
// wherever tune_out is offered — with a reader (persistent subconscious
|
|
3586
|
+
// or reader forks) it summarizes and wakes; with `tuneOut.enabled` alone
|
|
3587
|
+
// it is a silent hold, and a persisted epoch is resumed and released
|
|
3588
|
+
// (#232 gap 10). Where neither is configured nothing could ever cancel a
|
|
3589
|
+
// tune-out on record, so each one ends at the start instead
|
|
3590
|
+
// (endTuneOutsWithoutCoordinator), as main has it.
|
|
3591
|
+
if (framework.channelRegistry && framework.tuneOutOffered) {
|
|
3428
3592
|
framework.tuneOutCoordinator = new TuneOutCoordinator(framework.channelRegistry, framework.mcplServerRegistry, {
|
|
3429
3593
|
addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata, forAgent ? { forAgent } : undefined),
|
|
3430
3594
|
requestInference: (agentName, reason, source) => {
|
|
3431
3595
|
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
3432
3596
|
},
|
|
3433
3597
|
subconsciousName: () => framework.subconsciousAgentName,
|
|
3598
|
+
...(framework.readerForks
|
|
3599
|
+
? {
|
|
3600
|
+
invokeReader: (invocation) => framework.invokeReaderFork(invocation),
|
|
3601
|
+
deliverFromReader: (reader, content, metadata) => framework.deliverFromReader(reader, content, metadata),
|
|
3602
|
+
}
|
|
3603
|
+
: {}),
|
|
3434
3604
|
primaryName: () => framework.primaryAgentName,
|
|
3435
3605
|
getStoredMessages: () => {
|
|
3436
3606
|
const primary = framework.primaryAgentName
|
|
3437
3607
|
? framework.agents.get(framework.primaryAgentName) : undefined;
|
|
3438
3608
|
return primary ? primary.getContextManager().getAllMessages() : [];
|
|
3439
3609
|
},
|
|
3610
|
+
commitDeliveries: () => framework.commitDeliveries(),
|
|
3611
|
+
historyTool: () => framework.residentHistoryTool(),
|
|
3440
3612
|
getDeferredMessages: () => {
|
|
3441
3613
|
const forPrimary = (m) => (m.forAgent ?? framework.primaryAgentName) === framework.primaryAgentName;
|
|
3442
3614
|
// Settled first, so a write that landed is in the stored rows
|
|
@@ -3444,7 +3616,7 @@ export class AgentFramework {
|
|
|
3444
3616
|
framework.settleUnknownLandings(forPrimary);
|
|
3445
3617
|
return framework.deferredMessages.filter(forPrimary)
|
|
3446
3618
|
.sort(bySeq)
|
|
3447
|
-
.map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata }));
|
|
3619
|
+
.map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata, writeId: m.id, heldAt: m.heldAt }));
|
|
3448
3620
|
},
|
|
3449
3621
|
// Every reader of the shared slot compiles the dump with its own
|
|
3450
3622
|
// strategy, so the dump fits the smallest of their bounds.
|
|
@@ -3459,9 +3631,20 @@ export class AgentFramework {
|
|
|
3459
3631
|
},
|
|
3460
3632
|
currentSequence: () => framework.store.currentSequence(),
|
|
3461
3633
|
setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence),
|
|
3462
|
-
isForkBound: (channel) =>
|
|
3634
|
+
isForkBound: (channel) => framework.registry.list().some((record) => sameChannel(framework.homeOf(record.name), channel)),
|
|
3463
3635
|
isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false,
|
|
3464
3636
|
allowChannelSpeech: () => framework.subconsciousConfig?.allowChannelSpeech === true,
|
|
3637
|
+
holdWithoutReader: () => framework.tuneOutOffered,
|
|
3638
|
+
...(framework.tuneOutAnnounce
|
|
3639
|
+
? {
|
|
3640
|
+
// The excepted channel on its server: a bare id two servers
|
|
3641
|
+
// register is refused (publishForAgent).
|
|
3642
|
+
announce: async (serverId, channelId, text) => {
|
|
3643
|
+
const r = await framework.channelRegistry.publishForAgent({ serverId, channelId }, text, framework.primaryAgentName ?? 'resident');
|
|
3644
|
+
return { success: r.success, ...(r.error ? { error: r.error } : {}) };
|
|
3645
|
+
},
|
|
3646
|
+
}
|
|
3647
|
+
: {}),
|
|
3465
3648
|
emitTrace: (e) => framework.emitTrace(e),
|
|
3466
3649
|
});
|
|
3467
3650
|
framework.tuneOutCoordinator.resumeActiveEpochs();
|
|
@@ -3549,6 +3732,7 @@ export class AgentFramework {
|
|
|
3549
3732
|
}
|
|
3550
3733
|
this.running = true;
|
|
3551
3734
|
this.providerAdmissionClosed = false;
|
|
3735
|
+
this.earlyTraces = null;
|
|
3552
3736
|
// A new lifecycle: a turn compiled before an earlier stop() never drives,
|
|
3553
3737
|
// and withheld spills, closed for the generation a stop ended, are open
|
|
3554
3738
|
// for this one.
|
|
@@ -3814,6 +3998,12 @@ export class AgentFramework {
|
|
|
3814
3998
|
step(() => this.pushCoalescer?.suspend());
|
|
3815
3999
|
step(() => this.flushCoalescingSnapshot());
|
|
3816
4000
|
this.running = false;
|
|
4001
|
+
// Bounded work this stop cuts off is neither finished nor failed. Its
|
|
4002
|
+
// record stays live in the snapshot, so the next boot ends it as
|
|
4003
|
+
// `host-restart` and tells its result recipient — the same as a crash
|
|
4004
|
+
// (first live run, 2026-10-12, finding D: a reader fork ended silently).
|
|
4005
|
+
for (const run of this.ephemeralRuns.values())
|
|
4006
|
+
run.endReason ??= 'host-stop';
|
|
3817
4007
|
// Flushed-but-unsynced deferred writes: sync and ack now, while the
|
|
3818
4008
|
// store is still open, rather than leaving them to a reboot replay.
|
|
3819
4009
|
step(() => this.ackDeferredWrites());
|
|
@@ -4009,12 +4199,12 @@ export class AgentFramework {
|
|
|
4009
4199
|
// since the name is never used again. These releases, and the pass after
|
|
4010
4200
|
// them, come before the registry forgets its channels, since a notice
|
|
4011
4201
|
// names its channel by the label the registry holds (channelForNotice).
|
|
4012
|
-
|
|
4202
|
+
// The registry names the conversation forks and their home channels.
|
|
4203
|
+
for (const { name, home } of this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ name: r.name, home: this.homeOf(r.name) }))) {
|
|
4013
4204
|
step(() => {
|
|
4014
|
-
|
|
4015
|
-
if (this.conversationRouter?.getBinding(home)?.agentName === name)
|
|
4205
|
+
if (home !== undefined && this.conversationRouter?.getBinding(home)?.agentName === name)
|
|
4016
4206
|
this.conversationRouter.unbind(home);
|
|
4017
|
-
this.disposeConversationAgent(name);
|
|
4207
|
+
this.disposeConversationAgent(name, 'host-stop');
|
|
4018
4208
|
});
|
|
4019
4209
|
}
|
|
4020
4210
|
// A window release the quiesce postponed (releasesAfterResume) never runs
|
|
@@ -4452,10 +4642,12 @@ export class AgentFramework {
|
|
|
4452
4642
|
}
|
|
4453
4643
|
}
|
|
4454
4644
|
/** The agents the heartbeat wakes: the residents, not conversation forks,
|
|
4455
|
-
* ephemeral runs or the subconscious, which keep windows of their own.
|
|
4645
|
+
* ephemeral runs or the subconscious, which keep windows of their own.
|
|
4646
|
+
* Read from the registry by role, as an untargeted push's recipients are
|
|
4647
|
+
* (the heartbeat's wakes were heartbeat-mcpl's pushes): the agents whose
|
|
4648
|
+
* spec takes untargeted inbound, in registration order. */
|
|
4456
4649
|
heartbeatTargets() {
|
|
4457
|
-
return
|
|
4458
|
-
&& !this.ephemeralRuns.has(n) && n !== this.subconsciousAgentName);
|
|
4650
|
+
return this.registry.untargetedRecipients().filter((n) => this.agents.has(n));
|
|
4459
4651
|
}
|
|
4460
4652
|
/**
|
|
4461
4653
|
* The event a heartbeat wake is to the gate: a push from the server id
|
|
@@ -4552,7 +4744,7 @@ export class AgentFramework {
|
|
|
4552
4744
|
const tool = call.name.replace(/^(mcpl--)?heartbeat--/, '');
|
|
4553
4745
|
if (!HEARTBEAT_CHANGING_TOOLS.includes(tool) || this.heartbeatTargets().includes(agentName))
|
|
4554
4746
|
return null;
|
|
4555
|
-
const one = this.
|
|
4747
|
+
const one = this.registry.homeChannel(agentName) !== undefined ? 'conversation'
|
|
4556
4748
|
: this.ephemeralRuns.has(agentName) ? 'run' : 'side agent';
|
|
4557
4749
|
return `Nothing was changed: the heartbeat and its reminders are the agent's, and wake its residents, so this ${one} can't `
|
|
4558
4750
|
+ 'change them. heartbeat--heartbeat_status and heartbeat--reminder_list show them.';
|
|
@@ -4801,7 +4993,7 @@ export class AgentFramework {
|
|
|
4801
4993
|
return true;
|
|
4802
4994
|
}
|
|
4803
4995
|
holdProviderAcceleration(agent, error, trigger, auxiliary = false) {
|
|
4804
|
-
if (this.
|
|
4996
|
+
if (!this.registry.ownsProviderScheduling(agent.name))
|
|
4805
4997
|
return false;
|
|
4806
4998
|
// A provider's stated wait binds later calls to the primary model, across
|
|
4807
4999
|
// restarts. (An auxiliary call records its own model's wait at the gate.)
|
|
@@ -4941,7 +5133,7 @@ export class AgentFramework {
|
|
|
4941
5133
|
// as a number, as the default error policy reads it.
|
|
4942
5134
|
if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number')
|
|
4943
5135
|
return undefined;
|
|
4944
|
-
if (this.
|
|
5136
|
+
if (this.registry.homeChannel(agent.name) !== undefined) {
|
|
4945
5137
|
const wait = this.recordProviderWait(agent.name, agent.model, error);
|
|
4946
5138
|
return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true)
|
|
4947
5139
|
? 'held'
|
|
@@ -5175,6 +5367,15 @@ export class AgentFramework {
|
|
|
5175
5367
|
// outlives the stop as a turn still alive. Nothing is started either.
|
|
5176
5368
|
if (this.providerAdmissionClosed)
|
|
5177
5369
|
return true;
|
|
5370
|
+
// A listener of the failure reported here (`report`) may have ended the
|
|
5371
|
+
// agent (stopAgent): its end dropped what of its turn was on its way,
|
|
5372
|
+
// and nothing is held for an agent that's gone, so no retry outlives it
|
|
5373
|
+
// as a turn still alive. (A caller that reports a failure before calling
|
|
5374
|
+
// here checks the agent first: nonOwningProviderWait holds only for an
|
|
5375
|
+
// agent whose registry record is still live, and the scheduler and the
|
|
5376
|
+
// last boundary pass this.agents' own Agent.)
|
|
5377
|
+
if (this.agents.get(agent.name) !== agent)
|
|
5378
|
+
return true;
|
|
5178
5379
|
// A listener of the report may have released the wait
|
|
5179
5380
|
// (releaseProviderWait) before anything was held: nothing binds now, so
|
|
5180
5381
|
// the requests start, as the release of an installed hold starts them.
|
|
@@ -5699,9 +5900,26 @@ export class AgentFramework {
|
|
|
5699
5900
|
/**
|
|
5700
5901
|
* Add a trace event listener for observability.
|
|
5701
5902
|
*/
|
|
5702
|
-
onTrace(listener) {
|
|
5903
|
+
onTrace(listener, options) {
|
|
5703
5904
|
const registration = { listener, active: true };
|
|
5704
5905
|
this.traceListeners.push(registration);
|
|
5906
|
+
// A host attaches its sinks after create() returns, so the traces
|
|
5907
|
+
// create() emitted (registry restore, interrupted work, resumed epochs)
|
|
5908
|
+
// are replayed to a listener that asks; dropped at start().
|
|
5909
|
+
if (options?.replay) {
|
|
5910
|
+
const replayed = [...(this.earlyTraces ?? [])];
|
|
5911
|
+
if (this.earlyTraces && this.earlyTracesDropped > 0) {
|
|
5912
|
+
replayed.push({ type: 'dendrite:early-traces-dropped', dropped: this.earlyTracesDropped, timestamp: Date.now() });
|
|
5913
|
+
}
|
|
5914
|
+
for (const event of replayed) {
|
|
5915
|
+
try {
|
|
5916
|
+
registration.listener(event);
|
|
5917
|
+
}
|
|
5918
|
+
catch (error) {
|
|
5919
|
+
console.error('Trace listener error (replay):', error);
|
|
5920
|
+
}
|
|
5921
|
+
}
|
|
5922
|
+
}
|
|
5705
5923
|
// Return an unsubscribe so callers with a bounded lifetime (e.g. modules
|
|
5706
5924
|
// that get torn down and recreated on session switch) don't leak
|
|
5707
5925
|
// listeners. Existing callers that ignore the return value are unaffected.
|
|
@@ -6048,8 +6266,8 @@ export class AgentFramework {
|
|
|
6048
6266
|
if (this.channelRegistry) {
|
|
6049
6267
|
channelTools.push(AgentFramework.DRAFTS_TOOL);
|
|
6050
6268
|
}
|
|
6051
|
-
if (this.tuneOutCoordinator) {
|
|
6052
|
-
channelTools.push(AgentFramework.TUNE_OUT_TOOL);
|
|
6269
|
+
if (this.tuneOutCoordinator && this.tuneOutOffered) {
|
|
6270
|
+
channelTools.push(AgentFramework.TUNE_OUT_TOOL, AgentFramework.FOCUS_TOOL);
|
|
6053
6271
|
}
|
|
6054
6272
|
const gateTools = this.eventGate
|
|
6055
6273
|
? [
|
|
@@ -6438,7 +6656,7 @@ export class AgentFramework {
|
|
|
6438
6656
|
tunedOutSendNote(agentName, channel) {
|
|
6439
6657
|
try {
|
|
6440
6658
|
const registry = this.channelRegistry;
|
|
6441
|
-
if (!registry || this.
|
|
6659
|
+
if (!registry || this.registry.homeChannel(agentName) !== undefined)
|
|
6442
6660
|
return undefined;
|
|
6443
6661
|
if (registry.getDesiredState(channel.serverId, channel.channelId) !== 'tuned-out')
|
|
6444
6662
|
return undefined;
|
|
@@ -6473,7 +6691,7 @@ export class AgentFramework {
|
|
|
6473
6691
|
* delivered, so that's logged and nothing is added. */
|
|
6474
6692
|
closedChannelNotes(agentName, channels) {
|
|
6475
6693
|
try {
|
|
6476
|
-
if (this.
|
|
6694
|
+
if (this.registry.homeChannel(agentName) !== undefined)
|
|
6477
6695
|
return [];
|
|
6478
6696
|
const notes = [...channels].flatMap((channel) => this.closedChannelNoteFor(agentName, channel) ?? []);
|
|
6479
6697
|
// The setting is read only when there's a note to give.
|
|
@@ -6694,7 +6912,13 @@ export class AgentFramework {
|
|
|
6694
6912
|
: t);
|
|
6695
6913
|
return [...SUBCONSCIOUS_TOOLS, ...basics];
|
|
6696
6914
|
}
|
|
6697
|
-
|
|
6915
|
+
const surfaceOwner = this.toolSurfaceOwners.get(agentName) ?? agentName;
|
|
6916
|
+
// Reader forks (subconscious.reader: 'forks') share the resident's
|
|
6917
|
+
// provider prefix, and the tool block is the front of it: the reader's
|
|
6918
|
+
// four tools are therefore in every agent's block and refused at
|
|
6919
|
+
// dispatch for everyone but a reader (see dispatchToolCall).
|
|
6920
|
+
const readerTools = this.readerForks ? SUBCONSCIOUS_TOOLS : [];
|
|
6921
|
+
return [...this.getAllTools(), ...readerTools, ...(this.toolPresentations.has(surfaceOwner) ? presentationTools(this.toolPresentations.get(surfaceOwner).config.cataloguePath) : [])]
|
|
6698
6922
|
.map((tool) => {
|
|
6699
6923
|
if (tool.name === 'think') {
|
|
6700
6924
|
return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
|
|
@@ -6720,7 +6944,9 @@ export class AgentFramework {
|
|
|
6720
6944
|
return tools;
|
|
6721
6945
|
}
|
|
6722
6946
|
inspectToolPresentation(agentName, snapshot) {
|
|
6723
|
-
|
|
6947
|
+
// A derived agent shows its parent's presentation (see toolSurfaceOwners);
|
|
6948
|
+
// only the owner can edit it (editToolPresentation looks up by own name).
|
|
6949
|
+
const presentation = this.toolPresentations.get(this.toolSurfaceOwners.get(agentName) ?? agentName);
|
|
6724
6950
|
if (!presentation)
|
|
6725
6951
|
return null;
|
|
6726
6952
|
const tools = this.availableToolsForPresentation(agentName, snapshot);
|
|
@@ -8017,6 +8243,16 @@ export class AgentFramework {
|
|
|
8017
8243
|
}
|
|
8018
8244
|
this.deferredMessages.push(...restored);
|
|
8019
8245
|
this.deferredWritesPersisted = true;
|
|
8246
|
+
// A tune-out release that was still deferred at the crash owes the
|
|
8247
|
+
// resident the wake its cancel requested (#232 gap 6).
|
|
8248
|
+
for (const m of restored) {
|
|
8249
|
+
if (m.metadata?.kind !== 'tune-out-cancel')
|
|
8250
|
+
continue;
|
|
8251
|
+
const target = m.forAgent ?? this.primaryAgentName;
|
|
8252
|
+
if (target) {
|
|
8253
|
+
this.pendingRequests.push({ agentName: target, reason: 'tune-out released (restored)', source: 'tune-out', timestamp: Date.now() });
|
|
8254
|
+
}
|
|
8255
|
+
}
|
|
8020
8256
|
// Rewrite the durable queue without the already-landed entries so a
|
|
8021
8257
|
// second interrupted boot does not re-scan them, without the malformed
|
|
8022
8258
|
// and repeated ones, and with a seq for each entry that came without a
|
|
@@ -8847,7 +9083,16 @@ export class AgentFramework {
|
|
|
8847
9083
|
* ContextManager, before the store. Until then the ContextManager stays
|
|
8848
9084
|
* open.
|
|
8849
9085
|
*/
|
|
8850
|
-
async createEphemeralAgent(config
|
|
9086
|
+
async createEphemeralAgent(config,
|
|
9087
|
+
/**
|
|
9088
|
+
* How the registry should describe this agent. Omitted: an independent
|
|
9089
|
+
* fresh worker with no spawner and no result route, which is what every
|
|
9090
|
+
* caller of this method got before the registry existed. Supplying a
|
|
9091
|
+
* spec (see the presets in `dendrite/`) declares who spawned it, where
|
|
9092
|
+
* its result goes and what it inherited, so lifecycle rules — orphan
|
|
9093
|
+
* notice, reparenting, held results — apply to it.
|
|
9094
|
+
*/
|
|
9095
|
+
spec) {
|
|
8851
9096
|
// Checked before anything happens: the constructor checks too, but only
|
|
8852
9097
|
// after the name is reserved and ContextManager.open has registered the
|
|
8853
9098
|
// agent's message and context states in the store.
|
|
@@ -8860,22 +9105,76 @@ export class AgentFramework {
|
|
|
8860
9105
|
}
|
|
8861
9106
|
this.usedEphemeralAgentNames.set(config.name, 'run');
|
|
8862
9107
|
const release = { released: false, calls: new Set() };
|
|
9108
|
+
let opened;
|
|
8863
9109
|
try {
|
|
8864
|
-
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
9110
|
+
// A namespace the spec names is where the context opens, as it is what
|
|
9111
|
+
// the record says and what inspection and resumption open later.
|
|
9112
|
+
let namespace = spec?.namespace ?? `subagent/${config.name}`;
|
|
9113
|
+
let contextManager;
|
|
9114
|
+
// The host's say, on the spec of what is about to exist.
|
|
9115
|
+
if (this.beforeDerive) {
|
|
9116
|
+
const effective = spec
|
|
9117
|
+
? { ...spec, name: config.name, model: config.model }
|
|
9118
|
+
: { ...workerSpec(config.name, { namespace }), model: config.model };
|
|
9119
|
+
const verdict = this.beforeDerive(effective);
|
|
9120
|
+
if (verdict && typeof verdict === 'object' && 'refuse' in verdict) {
|
|
9121
|
+
throw new Error(`${config.name} was not created: ${verdict.refuse}`);
|
|
9122
|
+
}
|
|
9123
|
+
}
|
|
9124
|
+
let inherit = spec?.inherit;
|
|
9125
|
+
if (inherit?.mode === 'shared') {
|
|
9126
|
+
// Shared reads, separate writes: the child's context is the parent's
|
|
9127
|
+
// at a checkpoint, on a branch of its own. Nothing is copied.
|
|
9128
|
+
const derived = await this.deriveSharedContext(config, inherit, release);
|
|
9129
|
+
contextManager = derived.contextManager;
|
|
9130
|
+
inherit = derived.inherit;
|
|
9131
|
+
namespace = derived.namespace;
|
|
9132
|
+
}
|
|
9133
|
+
else {
|
|
9134
|
+
// A namespace belongs to the agent that opened it: another agent's
|
|
9135
|
+
// turns there would be read as its own by inspection and by its
|
|
9136
|
+
// resumption, which reopens "its own namespace". So one another
|
|
9137
|
+
// agent's context holds (live, a candidate, awaiting re-declaration
|
|
9138
|
+
// or ended) is refused; an agent's own reopens in a new incarnation.
|
|
9139
|
+
const holder = this.namespaceHolder(namespace, config.name);
|
|
9140
|
+
if (holder) {
|
|
9141
|
+
// The default is offered only where it isn't the namespace refused.
|
|
9142
|
+
const fallback = `subagent/${config.name}`;
|
|
9143
|
+
throw new Error(`Ephemeral agent "${config.name}" refused: namespace "${namespace}" holds the context of agent ` +
|
|
9144
|
+
`"${holder}"; name another${namespace === fallback ? '' : `, or none for ${fallback}`}`);
|
|
9145
|
+
}
|
|
9146
|
+
this.namespaceOwners.set(namespace, config.name);
|
|
9147
|
+
opened = namespace;
|
|
9148
|
+
contextManager = await ContextManager.open({
|
|
9149
|
+
store: this.store,
|
|
9150
|
+
namespace,
|
|
9151
|
+
isolate: true,
|
|
9152
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
9153
|
+
membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
|
|
9154
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
9155
|
+
});
|
|
9156
|
+
}
|
|
9157
|
+
// Either manager is the run's to release (main's manager release), the derived one included.
|
|
8873
9158
|
this.trackManager(contextManager);
|
|
8874
9159
|
release.manager = contextManager;
|
|
8875
9160
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
8876
9161
|
this.adoptToolResultGuard(agent);
|
|
8877
9162
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
8878
|
-
this.ephemeralCandidates.set(agent,
|
|
9163
|
+
this.ephemeralCandidates.set(agent, {
|
|
9164
|
+
contextManager,
|
|
9165
|
+
config,
|
|
9166
|
+
spec: spec
|
|
9167
|
+
? {
|
|
9168
|
+
...spec,
|
|
9169
|
+
name: config.name,
|
|
9170
|
+
model: config.model,
|
|
9171
|
+
// A shared fork's context lives under its parent's namespace,
|
|
9172
|
+
// on the fork's own branch — not in a namespace of its own.
|
|
9173
|
+
namespace: inherit?.mode === 'shared' ? namespace : spec.namespace ?? namespace,
|
|
9174
|
+
...(inherit ? { inherit } : {}),
|
|
9175
|
+
}
|
|
9176
|
+
: { ...workerSpec(config.name, { namespace }), model: config.model },
|
|
9177
|
+
});
|
|
8879
9178
|
this.ephemeralAgents.add(agent);
|
|
8880
9179
|
this.managerReleases.set(agent, release);
|
|
8881
9180
|
const cleanup = () => {
|
|
@@ -8888,8 +9187,1371 @@ export class AgentFramework {
|
|
|
8888
9187
|
if (release.manager)
|
|
8889
9188
|
this.closeManager(release.manager);
|
|
8890
9189
|
this.usedEphemeralAgentNames.delete(config.name);
|
|
9190
|
+
if (opened !== undefined)
|
|
9191
|
+
this.namespaceOwners.delete(opened);
|
|
9192
|
+
throw error;
|
|
9193
|
+
}
|
|
9194
|
+
}
|
|
9195
|
+
/**
|
|
9196
|
+
* Derive a new agent from a live one: the parent's context at a
|
|
9197
|
+
* checkpoint, a private continuation, and — unless overridden — the
|
|
9198
|
+
* parent's whole configuration (model, thinking, cache TTL, provider
|
|
9199
|
+
* params, prompt, tools). The parent keeps running.
|
|
9200
|
+
*
|
|
9201
|
+
* The returned agent is a bounded job, driven with
|
|
9202
|
+
* `runEphemeralToCompletion` exactly like one from `createEphemeralAgent`.
|
|
9203
|
+
*
|
|
9204
|
+
* **Inheritance.** `mode: 'shared'` (the default) gives the child the
|
|
9205
|
+
* parent's messages and fold state through shared storage: nothing is
|
|
9206
|
+
* copied, the child folds with the parent's memory tree, and neither can
|
|
9207
|
+
* alter what the other reads. It needs branch-bound store handles and is
|
|
9208
|
+
* refused, not silently downgraded, where they are missing. `mode: 'copy'`
|
|
9209
|
+
* is the historical mechanism — the parent's compiled context re-added as
|
|
9210
|
+
* the child's own messages — and works everywhere.
|
|
9211
|
+
*
|
|
9212
|
+
* **Rendering.** With `solve: 'reuse'` (the default) and nothing
|
|
9213
|
+
* overridden, the child's first request is the parent's request plus the
|
|
9214
|
+
* child's framing: the same system prompt and tool block, the parent's
|
|
9215
|
+
* turns presented as the child's own, and a cache marker on the end of
|
|
9216
|
+
* the parent's request so the prefix it wrote is read back. `solve:
|
|
9217
|
+
* 'fresh'` deliberately re-solves, e.g. at another budget; so does any
|
|
9218
|
+
* override that changes what is rendered. Those are operating choices —
|
|
9219
|
+
* the presentation changes and the provider cache misses.
|
|
9220
|
+
*
|
|
9221
|
+
* **Framing** is ordinary messages appended to the child before it runs:
|
|
9222
|
+
* the task, and whatever tells it which stream it is. The parent's turn
|
|
9223
|
+
* in flight is not in its store yet (see `getPendingAssistantRound`), so
|
|
9224
|
+
* a fork made from inside a tool call starts from the message before the
|
|
9225
|
+
* turn that made it.
|
|
9226
|
+
*
|
|
9227
|
+
* Context isolation is not process or filesystem isolation: parent and
|
|
9228
|
+
* child share tools, any shell and the workspace.
|
|
9229
|
+
*/
|
|
9230
|
+
async deriveAgent(options) {
|
|
9231
|
+
const parent = this.agents.get(options.from);
|
|
9232
|
+
const parentConfig = this.agentConfigs.get(options.from);
|
|
9233
|
+
if (!parent || !parentConfig) {
|
|
9234
|
+
throw new Error(`deriveAgent: "${options.from}" is not a registered agent`);
|
|
9235
|
+
}
|
|
9236
|
+
const mode = options.mode ?? 'shared';
|
|
9237
|
+
const shared = mode === 'shared';
|
|
9238
|
+
if (!shared && options.inheritRefusals === true) {
|
|
9239
|
+
throw new Error(`deriveAgent: "${options.name}" was not created: inheritRefusals is for mode 'shared', and a copy brings no refusal ledger`);
|
|
9240
|
+
}
|
|
9241
|
+
if (!shared && options.solve !== undefined) {
|
|
9242
|
+
throw new Error(`deriveAgent: "${options.name}" was not created: solve is for mode 'shared', and a copy is not solved`);
|
|
9243
|
+
}
|
|
9244
|
+
const config = {
|
|
9245
|
+
...parentConfig,
|
|
9246
|
+
// The parent's LIVE settings, not just its recipe: a runtime budget or
|
|
9247
|
+
// think-policy override changes what it renders.
|
|
9248
|
+
...(parent.contextBudgetTokens !== undefined ? { contextBudgetTokens: parent.contextBudgetTokens } : {}),
|
|
9249
|
+
sameRoundThinkTextPolicy: parent.getEffectiveSameRoundThinkTextPolicy(),
|
|
9250
|
+
...options.config,
|
|
9251
|
+
name: options.name,
|
|
9252
|
+
strategy: options.strategy,
|
|
9253
|
+
// A shared inheritance has no copy step to rename the parent's turns,
|
|
9254
|
+
// so the mapping is declared: the parent's voice is the child's own,
|
|
9255
|
+
// presented under the name the parent's own requests use.
|
|
9256
|
+
...(shared
|
|
9257
|
+
? {
|
|
9258
|
+
selfParticipants: [...new Set([
|
|
9259
|
+
parent.name,
|
|
9260
|
+
...(parentConfig.selfParticipants ?? []),
|
|
9261
|
+
...(options.config?.selfParticipants ?? []),
|
|
9262
|
+
])],
|
|
9263
|
+
presentAs: options.config?.presentAs ?? parent.presentAs,
|
|
9264
|
+
}
|
|
9265
|
+
: { selfParticipants: options.config?.selfParticipants, presentAs: options.config?.presentAs }),
|
|
9266
|
+
};
|
|
9267
|
+
const spec = taskForkSpec(options.name, {
|
|
9268
|
+
parent: options.from,
|
|
9269
|
+
spawnedBy: options.spawnedBy ?? options.from,
|
|
9270
|
+
...(options.resultTo ? { resultTo: options.resultTo } : {}),
|
|
9271
|
+
...(options.deadlineMs !== undefined ? { deadlineMs: options.deadlineMs } : {}),
|
|
9272
|
+
...(options.idleTimeoutMs !== undefined ? { idleTimeoutMs: options.idleTimeoutMs } : {}),
|
|
9273
|
+
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
9274
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
9275
|
+
inherit: {
|
|
9276
|
+
mode,
|
|
9277
|
+
...(options.solve ? { solve: options.solve } : {}),
|
|
9278
|
+
...(options.inheritRefusals !== undefined ? { refusals: options.inheritRefusals } : {}),
|
|
9279
|
+
},
|
|
9280
|
+
});
|
|
9281
|
+
const { name: _name, ...specBody } = {
|
|
9282
|
+
...spec,
|
|
9283
|
+
// The record states the mapping the agent's requests actually use.
|
|
9284
|
+
...(config.selfParticipants?.length ? { selfParticipants: config.selfParticipants } : {}),
|
|
9285
|
+
...(config.presentAs && config.presentAs !== config.name ? { presentAs: config.presentAs } : {}),
|
|
9286
|
+
...(options.kind ? { kind: options.kind } : {}),
|
|
9287
|
+
...(options.onParentEnd ? { onParentEnd: options.onParentEnd } : {}),
|
|
9288
|
+
};
|
|
9289
|
+
const created = await this.createEphemeralAgent(config, specBody);
|
|
9290
|
+
try {
|
|
9291
|
+
if (this.agents.get(options.from) !== parent) {
|
|
9292
|
+
throw new Error(`deriveAgent: "${options.from}" ended while "${options.name}" was being derived from it`);
|
|
9293
|
+
}
|
|
9294
|
+
if (options.config?.allowedTools === undefined) {
|
|
9295
|
+
this.toolSurfaceOwners.set(options.name, this.toolSurfaceOwners.get(options.from) ?? options.from);
|
|
9296
|
+
}
|
|
9297
|
+
if (!shared) {
|
|
9298
|
+
// The historical copy: the parent's compiled view, its turns renamed
|
|
9299
|
+
// so the child reads its inheritance as its own history.
|
|
9300
|
+
const { messages } = await parent.getContextManager().compile();
|
|
9301
|
+
for (const message of messages) {
|
|
9302
|
+
created.contextManager.addMessage(parent.isOwnParticipant(message.participant) ? options.name : message.participant, message.content);
|
|
9303
|
+
}
|
|
9304
|
+
created.agent.markContextConsumed();
|
|
9305
|
+
}
|
|
9306
|
+
await this.framePendingRound(parent, created.contextManager, options, shared);
|
|
9307
|
+
for (const message of options.framing ?? []) {
|
|
9308
|
+
created.contextManager.addMessage(message.participant, message.content, message.metadata);
|
|
9309
|
+
}
|
|
9310
|
+
return created;
|
|
9311
|
+
}
|
|
9312
|
+
catch (error) {
|
|
9313
|
+
this.discardEphemeralCandidate(created.agent, created.contextManager);
|
|
9314
|
+
throw error;
|
|
9315
|
+
}
|
|
9316
|
+
}
|
|
9317
|
+
discardEphemeralCandidate(agent, contextManager) {
|
|
9318
|
+
this.ephemeralCandidates.delete(agent);
|
|
9319
|
+
this.ephemeralAgents.delete(agent);
|
|
9320
|
+
if (this.toolSurfaceOwners.has(agent.name) && !this.agents.has(agent.name))
|
|
9321
|
+
this.toolSurfaceOwners.delete(agent.name);
|
|
9322
|
+
this.releaseManager(agent);
|
|
9323
|
+
this.managerReleases.delete(agent);
|
|
9324
|
+
this.closeManager(contextManager);
|
|
9325
|
+
}
|
|
9326
|
+
/**
|
|
9327
|
+
* The default wording for the call that derived a child: who it is, what
|
|
9328
|
+
* kind, and how long — the fields the consent event names, so the fork
|
|
9329
|
+
* never has to infer its own lifetime from context (Linn, 10-09).
|
|
9330
|
+
*/
|
|
9331
|
+
derivedNote(parentName, options) {
|
|
9332
|
+
const record = this.registry.inspect(options.name);
|
|
9333
|
+
const kind = record?.kind ?? options.kind ?? 'task-fork';
|
|
9334
|
+
const onParentEnd = record?.onParentEnd ?? options.onParentEnd ?? 'orphan';
|
|
9335
|
+
const route = record?.relationships.resultTo ?? options.resultTo;
|
|
9336
|
+
const returns = route
|
|
9337
|
+
? route.as === 'message'
|
|
9338
|
+
? `your report returns to ${route.to} as mail under your own name`
|
|
9339
|
+
: `your result returns to ${route.to} as this call's result`
|
|
9340
|
+
: null;
|
|
9341
|
+
const lifetime = onParentEnd === 'end'
|
|
9342
|
+
? `You end when ${parentName} does${returns ? `; ${returns}` : ''}; nothing you do speaks as ${parentName}.`
|
|
9343
|
+
: `You finish your task and then end${returns ? `; ${returns}` : ''}; if ${parentName} ends first you keep running, as an orphan with the same return address.`;
|
|
9344
|
+
return (`[This call derived ${options.name}. You are ${options.name}, a ${kind}, continuing from here on your own branch. ` +
|
|
9345
|
+
`${lifetime} ${parentName} receives this call's result, not you.]`);
|
|
9346
|
+
}
|
|
9347
|
+
/**
|
|
9348
|
+
* The parent's in-flight tool round, reproduced on the child's branch so
|
|
9349
|
+
* the child sees the call that made it (see `DeriveAgentOptions.pendingRound`).
|
|
9350
|
+
*/
|
|
9351
|
+
async framePendingRound(parent, child, options, shared) {
|
|
9352
|
+
const round = options.pendingRound ?? {};
|
|
9353
|
+
if (round.include === false)
|
|
9354
|
+
return;
|
|
9355
|
+
const pending = this.pendingAssistantBlocks.get(parent.name);
|
|
9356
|
+
if (!pending)
|
|
9357
|
+
return;
|
|
9358
|
+
const calls = pending.filter((b) => b.type === 'tool_use');
|
|
9359
|
+
if (calls.length === 0)
|
|
9360
|
+
return;
|
|
9361
|
+
const state = parent.state;
|
|
9362
|
+
const completed = state.status === 'waiting_for_tools' ? state.completed : [];
|
|
9363
|
+
const cap = this.resolveToolResultInlineCap(parent).cap;
|
|
9364
|
+
const { blocks: received } = await this.buildStoredToolResultContent(options.name, completed, cap);
|
|
9365
|
+
const byId = new Map(received.map((b) => [b.toolUseId, b]));
|
|
9366
|
+
const open = calls.filter((c) => !byId.has(c.id));
|
|
9367
|
+
const madeBy = round.madeBy?.toolUseId ?? (open.length === 1 ? open[0].id : undefined);
|
|
9368
|
+
const results = calls.map((call) => {
|
|
9369
|
+
const stored = byId.get(call.id);
|
|
9370
|
+
if (stored)
|
|
9371
|
+
return stored;
|
|
9372
|
+
const content = call.id === madeBy
|
|
9373
|
+
? round.madeBy?.result ?? this.derivedNote(parent.name, options)
|
|
9374
|
+
: round.results?.[call.id] ??
|
|
9375
|
+
`[No result yet: this call was still running in ${parent.name} when ${options.name} was derived.]`;
|
|
9376
|
+
return { type: 'tool_result', toolUseId: call.id, toolName: call.name, content, isError: false };
|
|
9377
|
+
});
|
|
9378
|
+
const metadata = { kind: 'dendrite-pending-round', from: parent.name };
|
|
9379
|
+
child.addMessage(shared ? parent.name : options.name, structuredClone(pending), metadata);
|
|
9380
|
+
child.addMessage('user', results, metadata);
|
|
9381
|
+
}
|
|
9382
|
+
/**
|
|
9383
|
+
* The assistant turn an agent has produced but not yet stored: its tool
|
|
9384
|
+
* calls and any text before them, held until every tool result is in so
|
|
9385
|
+
* the pair lands adjacently. A host deriving an agent from inside one of
|
|
9386
|
+
* those tool calls can reproduce the turn in the child's framing.
|
|
9387
|
+
*/
|
|
9388
|
+
getPendingAssistantRound(agentName) {
|
|
9389
|
+
const blocks = this.pendingAssistantBlocks.get(agentName);
|
|
9390
|
+
return blocks ? structuredClone(blocks) : undefined;
|
|
9391
|
+
}
|
|
9392
|
+
/**
|
|
9393
|
+
* Bring an ended bounded job back as a new incarnation of the same
|
|
9394
|
+
* identity, on the context it left: an isolated agent's own namespace, or
|
|
9395
|
+
* a derived agent's own branch. Nothing is re-derived and nothing is
|
|
9396
|
+
* re-seeded — the agent continues from its last stored message, which is
|
|
9397
|
+
* the end of its last complete round (a round whose tool results never
|
|
9398
|
+
* all arrived was never stored).
|
|
9399
|
+
*
|
|
9400
|
+
* Use it for a job that was stopped, failed, or was interrupted by a
|
|
9401
|
+
* restart. Configuration is not persisted with an agent, so the caller
|
|
9402
|
+
* supplies it again; the role mapping a derived agent used is restored
|
|
9403
|
+
* from its record unless the config sets one. Relationships are kept
|
|
9404
|
+
* where the other agent still exists; a job whose spawner or result
|
|
9405
|
+
* recipient is gone resumes without that edge and can be reparented.
|
|
9406
|
+
*
|
|
9407
|
+
* External effects are not replayed and not undone: a tool the previous
|
|
9408
|
+
* incarnation ran before it ended has had its effect, and a call it never
|
|
9409
|
+
* received a result for may be made again by the model. Drive the result
|
|
9410
|
+
* with `runEphemeralToCompletion`.
|
|
9411
|
+
*/
|
|
9412
|
+
async resumeAgent(name, config) {
|
|
9413
|
+
if (this.resumesPending.has(name)) {
|
|
9414
|
+
throw new Error(`resumeAgent: "${name}" is already being resumed`);
|
|
9415
|
+
}
|
|
9416
|
+
if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) {
|
|
9417
|
+
throw new Error(`resumeAgent: "${name}" is still registered; only an ended agent resumes`);
|
|
9418
|
+
}
|
|
9419
|
+
const record = this.registry.inspect(name);
|
|
9420
|
+
if (!record?.ended)
|
|
9421
|
+
throw new Error(`resumeAgent: "${name}" is not a known ended agent`);
|
|
9422
|
+
if (record.lifetime.kind !== 'task') {
|
|
9423
|
+
throw new Error(`resumeAgent: "${name}" has a ${record.lifetime.kind} lifetime; only bounded jobs resume this way`);
|
|
9424
|
+
}
|
|
9425
|
+
const fullConfig = {
|
|
9426
|
+
...config,
|
|
9427
|
+
name,
|
|
9428
|
+
...(config.selfParticipants === undefined && record.selfParticipants
|
|
9429
|
+
? { selfParticipants: record.selfParticipants }
|
|
9430
|
+
: {}),
|
|
9431
|
+
...(config.presentAs === undefined && record.presentAs ? { presentAs: record.presentAs } : {}),
|
|
9432
|
+
};
|
|
9433
|
+
const spawnedBy = record.relationships.spawnedBy;
|
|
9434
|
+
const resultTo = record.relationships.resultTo;
|
|
9435
|
+
const keepSpawner = spawnedBy !== undefined && this.registry.has(spawnedBy);
|
|
9436
|
+
const spec = {
|
|
9437
|
+
name,
|
|
9438
|
+
kind: record.kind,
|
|
9439
|
+
model: fullConfig.model,
|
|
9440
|
+
roles: record.roles,
|
|
9441
|
+
lifetime: record.lifetime,
|
|
9442
|
+
...(record.activation ? { activation: record.activation } : {}),
|
|
9443
|
+
...(keepSpawner ? { spawnedBy, onParentEnd: record.onParentEnd } : {}),
|
|
9444
|
+
...(resultTo && this.registry.has(resultTo.to) ? { resultTo } : {}),
|
|
9445
|
+
...(record.inherit ? { inherit: record.inherit } : {}),
|
|
9446
|
+
...(fullConfig.selfParticipants?.length ? { selfParticipants: fullConfig.selfParticipants } : {}),
|
|
9447
|
+
...(fullConfig.presentAs && fullConfig.presentAs !== name ? { presentAs: fullConfig.presentAs } : {}),
|
|
9448
|
+
...(record.namespace ? { namespace: record.namespace } : {}),
|
|
9449
|
+
...(record.metadata ? { metadata: record.metadata } : {}),
|
|
9450
|
+
};
|
|
9451
|
+
if (this.beforeDerive) {
|
|
9452
|
+
const verdict = this.beforeDerive(spec);
|
|
9453
|
+
if (verdict && typeof verdict === 'object' && 'refuse' in verdict) {
|
|
9454
|
+
throw new Error(`${name} was not resumed: ${verdict.refuse}`);
|
|
9455
|
+
}
|
|
9456
|
+
}
|
|
9457
|
+
this.resumesPending.set(name, undefined);
|
|
9458
|
+
const release = { released: false, calls: new Set() };
|
|
9459
|
+
let contextManager;
|
|
9460
|
+
try {
|
|
9461
|
+
contextManager = await this.reopenAgentContext(record, config.strategy, this.ephemeralAuxiliaryMembraneFor(name, release));
|
|
9462
|
+
}
|
|
9463
|
+
catch (error) {
|
|
9464
|
+
this.resumesPending.delete(name);
|
|
8891
9465
|
throw error;
|
|
8892
9466
|
}
|
|
9467
|
+
this.trackManager(contextManager);
|
|
9468
|
+
release.manager = contextManager;
|
|
9469
|
+
let agent;
|
|
9470
|
+
try {
|
|
9471
|
+
if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) {
|
|
9472
|
+
throw new Error(`resumeAgent: "${name}" was registered while it was being resumed`);
|
|
9473
|
+
}
|
|
9474
|
+
agent = new Agent(fullConfig, contextManager, this.membrane);
|
|
9475
|
+
this.adoptToolResultGuard(agent);
|
|
9476
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
9477
|
+
}
|
|
9478
|
+
catch (error) {
|
|
9479
|
+
this.resumesPending.delete(name);
|
|
9480
|
+
this.closeManager(contextManager);
|
|
9481
|
+
throw error;
|
|
9482
|
+
}
|
|
9483
|
+
this.usedEphemeralAgentNames.set(name, 'run'); // a resumed job is an ephemeral run: no global fallback (takesGlobalFallback)
|
|
9484
|
+
this.ephemeralAgents.add(agent); // and its usage counts in the shared ephemeral entry (#558)
|
|
9485
|
+
this.ephemeralCandidates.set(agent, { contextManager, config: fullConfig, resume: true, after: record.incarnation, spec });
|
|
9486
|
+
this.managerReleases.set(agent, release);
|
|
9487
|
+
this.resumesPending.set(name, agent);
|
|
9488
|
+
const cleanup = () => {
|
|
9489
|
+
if (this.resumesPending.get(name) === agent)
|
|
9490
|
+
this.resumesPending.delete(name);
|
|
9491
|
+
this.releasedByCleanup.add(agent);
|
|
9492
|
+
this.releaseManager(agent);
|
|
9493
|
+
};
|
|
9494
|
+
return { agent, contextManager, cleanup };
|
|
9495
|
+
}
|
|
9496
|
+
/**
|
|
9497
|
+
* A context to read an agent's history from, without changing the live
|
|
9498
|
+
* system.
|
|
9499
|
+
*
|
|
9500
|
+
* - A live agent: a snapshot derived from its context — at the head, or
|
|
9501
|
+
* at `atSequence` for an earlier state. The snapshot is its own branch;
|
|
9502
|
+
* the agent keeps running and is not affected by anything done to it.
|
|
9503
|
+
* - An ended agent: the context it left, reopened where it lives.
|
|
9504
|
+
*
|
|
9505
|
+
* The returned manager uses a passthrough strategy: it shows what is
|
|
9506
|
+
* stored, raw. An ended agent's context is the real one — treat it as
|
|
9507
|
+
* read-only unless you mean to change what a later resume starts from.
|
|
9508
|
+
*/
|
|
9509
|
+
async inspectAgentContext(name, options = {}) {
|
|
9510
|
+
const live = this.registry.has(name) ? this.agents.get(name) : undefined;
|
|
9511
|
+
if (live) {
|
|
9512
|
+
const source = live.getContextManager();
|
|
9513
|
+
if (typeof source.derive !== 'function') {
|
|
9514
|
+
throw new Error('inspectAgentContext: a snapshot of a live agent needs a @animalabs/context-manager that provides ContextManager.derive');
|
|
9515
|
+
}
|
|
9516
|
+
const taken = new Set(this.store.listBranches().map((branch) => branch.name));
|
|
9517
|
+
let n = 1;
|
|
9518
|
+
while (taken.has(`dendrite/inspect/${name}/${n}`))
|
|
9519
|
+
n++;
|
|
9520
|
+
return source.derive({
|
|
9521
|
+
branch: `dendrite/inspect/${name}/${n}`,
|
|
9522
|
+
strategy: new PassthroughStrategy(),
|
|
9523
|
+
membrane: this.membrane,
|
|
9524
|
+
solve: 'fresh',
|
|
9525
|
+
...(options.atSequence !== undefined ? { atSequence: options.atSequence } : {}),
|
|
9526
|
+
});
|
|
9527
|
+
}
|
|
9528
|
+
const record = this.registry.inspect(name);
|
|
9529
|
+
if (!record)
|
|
9530
|
+
throw new Error(`inspectAgentContext: "${name}" is not a known agent`);
|
|
9531
|
+
if (options.atSequence !== undefined) {
|
|
9532
|
+
throw new Error('inspectAgentContext: atSequence applies to a live agent; an ended agent is read as it was left');
|
|
9533
|
+
}
|
|
9534
|
+
return this.reopenAgentContext(record, new PassthroughStrategy());
|
|
9535
|
+
}
|
|
9536
|
+
/** Open the context an ended agent left, wherever it lives. */
|
|
9537
|
+
async reopenAgentContext(record, strategy, membrane = this.membrane) {
|
|
9538
|
+
// The residents' view as createAgent gives it: held rows out, and another
|
|
9539
|
+
// agent's stored context injections out, since those are that agent's
|
|
9540
|
+
// context, not conversation (Weft's ninth-round probe: an ended reader
|
|
9541
|
+
// reopened for inspection showed a co-resident's injection it never saw live).
|
|
9542
|
+
const residentSees = (name) => (metadata) => RESIDENT_VIEW_FILTER({ metadata }) && !isAnotherAgentsInjection(metadata, name);
|
|
9543
|
+
// A context derived from a reader of the shared slot sees, as it did live
|
|
9544
|
+
// (deriveSharedContext, #291), what its source saw and its own stored
|
|
9545
|
+
// injections; its source's view is composed the same way, up the shared
|
|
9546
|
+
// sources the registry names, to the reader the first was derived from.
|
|
9547
|
+
const derivedSees = (from, walked = new Set()) => {
|
|
9548
|
+
const source = from.inherit?.mode === 'shared' ? from.inherit.from : undefined;
|
|
9549
|
+
if (source === undefined || walked.has(from.name))
|
|
9550
|
+
return residentSees(from.name);
|
|
9551
|
+
walked.add(from.name);
|
|
9552
|
+
const sourceRecord = this.registry.inspect(source);
|
|
9553
|
+
const sourceSees = sourceRecord ? derivedSees(sourceRecord, walked) : residentSees(source);
|
|
9554
|
+
return (metadata) => sourceSees(metadata) || isOwnInjection(metadata, from.name);
|
|
9555
|
+
};
|
|
9556
|
+
// The predicate is the manager's view filter, and is recorded beside it,
|
|
9557
|
+
// so main's checks of whether an agent sees a row (agentSees) ask what its
|
|
9558
|
+
// view holds when the reopened context is live.
|
|
9559
|
+
const seen = (contextManager, sees) => {
|
|
9560
|
+
this.storeSees.set(contextManager, sees);
|
|
9561
|
+
return contextManager;
|
|
9562
|
+
};
|
|
9563
|
+
if (record.inherit?.mode === 'shared') {
|
|
9564
|
+
const derivation = record.inherit.derivation;
|
|
9565
|
+
const reopen = ContextManager.reopenDerived;
|
|
9566
|
+
if (!derivation || typeof reopen !== 'function') {
|
|
9567
|
+
throw new Error(`Agent "${record.name}": its context is a derived branch, which this ` +
|
|
9568
|
+
'@animalabs/context-manager cannot reopen (ContextManager.reopenDerived)');
|
|
9569
|
+
}
|
|
9570
|
+
// A context derived from a reader of the shared slot keeps the view it had.
|
|
9571
|
+
const sees = derivation.slots?.messageNamespace === null ? derivedSees(record) : undefined;
|
|
9572
|
+
const reopened = await reopen.call(ContextManager, {
|
|
9573
|
+
store: this.store,
|
|
9574
|
+
derivation,
|
|
9575
|
+
strategy: strategy ?? new PassthroughStrategy(),
|
|
9576
|
+
membrane,
|
|
9577
|
+
...(sees ? { viewFilter: (message) => sees(message.metadata) } : {}),
|
|
9578
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
9579
|
+
});
|
|
9580
|
+
return sees ? seen(reopened, sees) : reopened;
|
|
9581
|
+
}
|
|
9582
|
+
if (!record.namespace) {
|
|
9583
|
+
throw new Error(`Agent "${record.name}": its record names no context to reopen`);
|
|
9584
|
+
}
|
|
9585
|
+
// A reader of the residents' shared slot (a resident dropped from the
|
|
9586
|
+
// configuration, say) is reopened as createAgent opened it: on the
|
|
9587
|
+
// shared slot, through the residents' view. Isolated, its namespace
|
|
9588
|
+
// holds none of what it read (nissa's #546 review, round 2).
|
|
9589
|
+
const sees = record.readsSharedSlot === true ? residentSees(record.name) : undefined;
|
|
9590
|
+
const opened = await ContextManager.open({
|
|
9591
|
+
store: this.store,
|
|
9592
|
+
namespace: record.namespace,
|
|
9593
|
+
...(sees ? { viewFilter: (message) => sees(message.metadata) } : { isolate: true }),
|
|
9594
|
+
strategy: strategy ?? new PassthroughStrategy(),
|
|
9595
|
+
membrane,
|
|
9596
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
9597
|
+
});
|
|
9598
|
+
return sees ? seen(opened, sees) : opened;
|
|
9599
|
+
}
|
|
9600
|
+
/** Open a derived agent's context on a branch of its own (see deriveAgent). */
|
|
9601
|
+
async deriveSharedContext(config, inherit, release) {
|
|
9602
|
+
const parent = this.agents.get(inherit.from);
|
|
9603
|
+
if (!parent) {
|
|
9604
|
+
throw new Error(`Agent "${config.name}": context source "${inherit.from}" is not a registered agent`);
|
|
9605
|
+
}
|
|
9606
|
+
if (this.surgeryHold) {
|
|
9607
|
+
throw new Error(`Agent "${config.name}" refused: the store is under live ${this.surgeryHold.verb} ` +
|
|
9608
|
+
`for ${this.surgeryHold.agentName} — retry when it completes`);
|
|
9609
|
+
}
|
|
9610
|
+
const source = parent.getContextManager();
|
|
9611
|
+
if (typeof source.derive !== 'function') {
|
|
9612
|
+
throw new Error(`Agent "${config.name}": shared context inheritance needs a @animalabs/context-manager ` +
|
|
9613
|
+
'that provides ContextManager.derive; use inherit.mode "copy" with this version');
|
|
9614
|
+
}
|
|
9615
|
+
// The branch is the child's durable state and outlives it. Names from an
|
|
9616
|
+
// earlier process may still own a branch of the same name.
|
|
9617
|
+
const taken = new Set(this.store.listBranches().map((branch) => branch.name));
|
|
9618
|
+
let ownBranch = `dendrite/${config.name}`;
|
|
9619
|
+
for (let n = 2; taken.has(ownBranch); n++)
|
|
9620
|
+
ownBranch = `dendrite/${config.name}~${n}`;
|
|
9621
|
+
// The fork's view: what its source sees, and its own stored context
|
|
9622
|
+
// injections. context-manager's derive would give it the source's filter
|
|
9623
|
+
// alone, which is tied to the source's name (isAnotherAgentsInjection), so
|
|
9624
|
+
// the fork's own injections were hidden from its compile while its
|
|
9625
|
+
// parent's showed (#291; Weft's eleventh note, on the real compile path).
|
|
9626
|
+
// The composed predicate goes to derive, so the first compile already
|
|
9627
|
+
// reads through it, and is recorded beside the manager (storeSees), so
|
|
9628
|
+
// main's checks of whether an agent sees a row (agentSees: a tool
|
|
9629
|
+
// boundary's injection, an arrival, a reader of an unread message) ask
|
|
9630
|
+
// what its compile shows. A source with no view recorded has no filter
|
|
9631
|
+
// (an isolated run's): it sees every row, so its fork keeps none, and
|
|
9632
|
+
// sees its own.
|
|
9633
|
+
const sourceSees = this.storeSees.get(source);
|
|
9634
|
+
const sees = sourceSees
|
|
9635
|
+
? (metadata) => sourceSees(metadata) || isOwnInjection(metadata, config.name)
|
|
9636
|
+
: undefined;
|
|
9637
|
+
const contextManager = await source.derive({
|
|
9638
|
+
branch: ownBranch,
|
|
9639
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
9640
|
+
// The run's auxiliary membrane, as the copy path's: its compression calls
|
|
9641
|
+
// are withdrawn once the run is released (Weft's ninth-round reading).
|
|
9642
|
+
membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
|
|
9643
|
+
solve: inherit.solve ?? 'reuse',
|
|
9644
|
+
inherit: { refusals: inherit.refusals === true },
|
|
9645
|
+
...(inherit.atSequence !== undefined ? { atSequence: inherit.atSequence } : {}),
|
|
9646
|
+
...(sees ? { viewFilter: (message) => sees(message.metadata) } : {}),
|
|
9647
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
9648
|
+
});
|
|
9649
|
+
if (sees)
|
|
9650
|
+
this.storeSees.set(contextManager, sees);
|
|
9651
|
+
const derivation = contextManager.getDerivation?.();
|
|
9652
|
+
return {
|
|
9653
|
+
contextManager,
|
|
9654
|
+
inherit: {
|
|
9655
|
+
...inherit,
|
|
9656
|
+
solve: inherit.solve ?? 'reuse',
|
|
9657
|
+
refusals: inherit.refusals === true,
|
|
9658
|
+
ownBranch,
|
|
9659
|
+
...(derivation
|
|
9660
|
+
? { branch: derivation.parentBranch, atSequence: derivation.atSequence, derivation }
|
|
9661
|
+
: {}),
|
|
9662
|
+
},
|
|
9663
|
+
namespace: this.registry.get(inherit.from)?.namespace ?? `subagent/${config.name}`,
|
|
9664
|
+
};
|
|
9665
|
+
}
|
|
9666
|
+
// ==========================================================================
|
|
9667
|
+
// Dendrite: agent registry, lifecycle and attributed communication
|
|
9668
|
+
// ==========================================================================
|
|
9669
|
+
//
|
|
9670
|
+
// One trust domain: there is no permission check on any of this. Any
|
|
9671
|
+
// caller may list, cancel, stop or reparent any agent. Context isolation
|
|
9672
|
+
// between agents is not process or filesystem isolation — agents sharing
|
|
9673
|
+
// tools, a shell or a workspace can affect the same external state.
|
|
9674
|
+
/** Flat list of registered agents with their roles, lifetime and relationships. */
|
|
9675
|
+
listAgents(options = {}) {
|
|
9676
|
+
return structuredClone(this.registry.list(options));
|
|
9677
|
+
}
|
|
9678
|
+
/** One agent's record: the live one, else its most recent ended one. */
|
|
9679
|
+
getAgentRecord(name) {
|
|
9680
|
+
const record = this.registry.inspect(name);
|
|
9681
|
+
return record ? structuredClone(record) : null;
|
|
9682
|
+
}
|
|
9683
|
+
/** The agent that owns default delivery, or null before any is declared. */
|
|
9684
|
+
getPrimaryAgentName() {
|
|
9685
|
+
return this.registry.primary();
|
|
9686
|
+
}
|
|
9687
|
+
/**
|
|
9688
|
+
* Activations in flight, each with the agent that owns the work. Derived
|
|
9689
|
+
* from the live turn state, so it cannot disagree with what is running.
|
|
9690
|
+
*/
|
|
9691
|
+
listActivations() {
|
|
9692
|
+
const activations = [];
|
|
9693
|
+
for (const [name, token] of this.activeTurnTokens) {
|
|
9694
|
+
const record = this.registry.get(name);
|
|
9695
|
+
if (!record)
|
|
9696
|
+
continue;
|
|
9697
|
+
const trigger = this.activeTurnTriggers.get(name);
|
|
9698
|
+
activations.push({
|
|
9699
|
+
id: `${name}#${record.incarnation}:${token}`,
|
|
9700
|
+
owner: { agent: name, incarnation: record.incarnation },
|
|
9701
|
+
...(trigger?.timestamp !== undefined ? { requestedAt: trigger.timestamp } : {}),
|
|
9702
|
+
...(trigger?.reason ? { reason: trigger.reason } : {}),
|
|
9703
|
+
...(trigger?.source ? { source: trigger.source } : {}),
|
|
9704
|
+
...(trigger?.channelId ? { channelId: trigger.channelId } : {}),
|
|
9705
|
+
});
|
|
9706
|
+
}
|
|
9707
|
+
return activations;
|
|
9708
|
+
}
|
|
9709
|
+
/**
|
|
9710
|
+
* Policy epochs currently in force (tune-out holds), read from the
|
|
9711
|
+
* coordinator's own durable state — not mirrored. An epoch is neither an
|
|
9712
|
+
* agent nor a task: ending one leaves both running.
|
|
9713
|
+
*/
|
|
9714
|
+
listPolicyEpochs() {
|
|
9715
|
+
if (!this.channelRegistry)
|
|
9716
|
+
return [];
|
|
9717
|
+
const owner = this.registry.primary();
|
|
9718
|
+
const epochs = [];
|
|
9719
|
+
for (const entry of this.channelRegistry.listChannelsRaw()) {
|
|
9720
|
+
const state = this.channelRegistry.getTuneOutState(entry.serverId, entry.descriptor.id);
|
|
9721
|
+
if (!state)
|
|
9722
|
+
continue;
|
|
9723
|
+
epochs.push({
|
|
9724
|
+
id: state.params.epochId,
|
|
9725
|
+
kind: 'tune-out',
|
|
9726
|
+
scope: { serverId: entry.serverId, channelId: entry.descriptor.id },
|
|
9727
|
+
...(owner ? { owner } : {}),
|
|
9728
|
+
...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}),
|
|
9729
|
+
startedAtSequence: state.params.startedAtSequence,
|
|
9730
|
+
...(state.params.expiresAtMs !== undefined ? { expiresAtMs: state.params.expiresAtMs } : {}),
|
|
9731
|
+
});
|
|
9732
|
+
}
|
|
9733
|
+
const scope = this.tuneOutCoordinator?.getAllExcept();
|
|
9734
|
+
if (scope) {
|
|
9735
|
+
epochs.push({
|
|
9736
|
+
id: scope.params.epochId,
|
|
9737
|
+
kind: 'tune-out',
|
|
9738
|
+
scope: { kind: 'all-except', except: scope.params.except },
|
|
9739
|
+
...(owner ? { owner } : {}),
|
|
9740
|
+
...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}),
|
|
9741
|
+
startedAtSequence: scope.params.startedAtSequence,
|
|
9742
|
+
expiresAtMs: scope.params.expiresAtMs,
|
|
9743
|
+
});
|
|
9744
|
+
}
|
|
9745
|
+
return epochs;
|
|
9746
|
+
}
|
|
9747
|
+
/**
|
|
9748
|
+
* Cancel an agent's activation in flight, leaving the agent registered.
|
|
9749
|
+
* For a bounded job, whose lifetime is that one run, this ends the job.
|
|
9750
|
+
* Returns false when nothing was running.
|
|
9751
|
+
*/
|
|
9752
|
+
cancelActivation(name, options = {}) {
|
|
9753
|
+
if (!this.registry.has(name))
|
|
9754
|
+
throw new Error(`cancelActivation: "${name}" is not a registered agent`);
|
|
9755
|
+
const run = this.ephemeralRuns.get(name);
|
|
9756
|
+
if (run) {
|
|
9757
|
+
run.endReason ??= 'stopped';
|
|
9758
|
+
run.endedBy ??= options.by;
|
|
9759
|
+
// The awaiting caller is told who stopped the job and why, whether or
|
|
9760
|
+
// not a stream was in flight: settled first, so the aborted stream's own
|
|
9761
|
+
// settle ('Stream cancelled') finds the run already settled. The caller's
|
|
9762
|
+
// teardown runs after this call, as for stopAgent.
|
|
9763
|
+
run.settle.reject(new AgentStoppedError(name, 'stopped', options.by, options.reason));
|
|
9764
|
+
}
|
|
9765
|
+
const cancelled = this.abortInference(name, options.reason ?? 'cancelled');
|
|
9766
|
+
if (cancelled || run) {
|
|
9767
|
+
this.emitTrace({
|
|
9768
|
+
type: 'dendrite:activation-cancelled',
|
|
9769
|
+
agentName: name,
|
|
9770
|
+
...(options.by ? { by: options.by } : {}),
|
|
9771
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
9772
|
+
});
|
|
9773
|
+
}
|
|
9774
|
+
return cancelled || run !== undefined;
|
|
9775
|
+
}
|
|
9776
|
+
/**
|
|
9777
|
+
* End an agent: cancel its run, remove it from the framework, and settle
|
|
9778
|
+
* what it spawned — attention tenants end with it; task work is orphaned,
|
|
9779
|
+
* notified and offered for reparenting, and keeps running.
|
|
9780
|
+
*
|
|
9781
|
+
* The default-delivery owner cannot be stopped (the framework needs
|
|
9782
|
+
* exactly one); cancel its activation instead. A persistent agent stopped
|
|
9783
|
+
* here is declared by configuration and returns at the next boot.
|
|
9784
|
+
*/
|
|
9785
|
+
stopAgent(name, options = {}) {
|
|
9786
|
+
const record = this.registry.get(name);
|
|
9787
|
+
if (!record)
|
|
9788
|
+
throw new Error(`stopAgent: "${name}" is not a registered agent`);
|
|
9789
|
+
if (record.roles.defaultDelivery) {
|
|
9790
|
+
throw new Error(`stopAgent: "${name}" owns default delivery and cannot be stopped; use cancelActivation to end its current run`);
|
|
9791
|
+
}
|
|
9792
|
+
const outcome = this.registry.end(name, 'stopped', options.by);
|
|
9793
|
+
this.teardownEndedAgent(outcome.ended, options.reason);
|
|
9794
|
+
this.settleEndedAgent(outcome);
|
|
9795
|
+
return structuredClone(outcome);
|
|
9796
|
+
}
|
|
9797
|
+
/**
|
|
9798
|
+
* Give an agent a new parent. Clears its orphan state; a result route
|
|
9799
|
+
* whose recipient is gone follows the new parent, and the mail it already
|
|
9800
|
+
* produced for the lost recipient, results and messages, is delivered to
|
|
9801
|
+
* the new one.
|
|
9802
|
+
*/
|
|
9803
|
+
reparentAgent(name, newParent, options = {}) {
|
|
9804
|
+
// An orphan that finished before it was adopted: its record stays as it
|
|
9805
|
+
// ended, and the mail it held for a recipient that is gone goes to the
|
|
9806
|
+
// new parent.
|
|
9807
|
+
if (!this.registry.has(name) && this.registry.inspect(name)?.ended) {
|
|
9808
|
+
const ended = this.registry.readdressHeldMail(name, newParent);
|
|
9809
|
+
this.emitTrace({
|
|
9810
|
+
type: 'dendrite:agent-reparented',
|
|
9811
|
+
agentName: name,
|
|
9812
|
+
parent: newParent,
|
|
9813
|
+
...(options.by ? { by: options.by } : {}),
|
|
9814
|
+
});
|
|
9815
|
+
for (const mail of ended.deliverable)
|
|
9816
|
+
this.attemptMailDelivery(mail);
|
|
9817
|
+
return structuredClone(ended.record);
|
|
9818
|
+
}
|
|
9819
|
+
const { record, deliverable } = this.registry.reparent(name, newParent);
|
|
9820
|
+
this.emitTrace({
|
|
9821
|
+
type: 'dendrite:agent-reparented',
|
|
9822
|
+
agentName: name,
|
|
9823
|
+
parent: newParent,
|
|
9824
|
+
...(options.by ? { by: options.by } : {}),
|
|
9825
|
+
});
|
|
9826
|
+
this.lifecycleNotice(name, `[${NOTICE_TAG.agentLifecycle}: ${newParent} is now your parent` +
|
|
9827
|
+
(record.relationships.resultTo?.to === newParent ? ' and will receive your result' : '') +
|
|
9828
|
+
'.]', { event: 'reparented', parent: newParent });
|
|
9829
|
+
for (const mail of deliverable)
|
|
9830
|
+
this.attemptMailDelivery(mail);
|
|
9831
|
+
return structuredClone(record);
|
|
9832
|
+
}
|
|
9833
|
+
/**
|
|
9834
|
+
* An explicit message from one agent to another: the sender's verbatim
|
|
9835
|
+
* content, in the recipient's context under the SENDER's name, with the
|
|
9836
|
+
* sender's identity and incarnation and the causal chain in metadata.
|
|
9837
|
+
* Never an unmarked insertion into the recipient's own voice.
|
|
9838
|
+
*/
|
|
9839
|
+
sendAgentMessage(from, to, content, options = {}) {
|
|
9840
|
+
if (!this.registry.inspect(from))
|
|
9841
|
+
throw new Error(`sendAgentMessage: sender "${from}" is not a known agent`);
|
|
9842
|
+
if (!this.registry.inspect(to))
|
|
9843
|
+
throw new Error(`sendAgentMessage: recipient "${to}" is not a known agent`);
|
|
9844
|
+
this.registry.noteMessagePath(from, to);
|
|
9845
|
+
const mail = this.registry.holdMail({
|
|
9846
|
+
kind: 'message',
|
|
9847
|
+
from: this.registry.ref(from),
|
|
9848
|
+
to,
|
|
9849
|
+
content,
|
|
9850
|
+
...(options.causedBy?.length ? { causedBy: options.causedBy } : {}),
|
|
9851
|
+
});
|
|
9852
|
+
return { mailId: mail.id, delivered: this.attemptMailDelivery(mail, options.wake ?? true) };
|
|
9853
|
+
}
|
|
9854
|
+
/**
|
|
9855
|
+
* Return a bounded job's result along the agent's declared result route.
|
|
9856
|
+
* The result is recorded durably before delivery is attempted, so it is
|
|
9857
|
+
* neither lost nor delivered twice across a restart; if the recipient is
|
|
9858
|
+
* gone it is held as attributed mail until the agent is reparented.
|
|
9859
|
+
*
|
|
9860
|
+
* May be called after the agent's run has ended — its record stays
|
|
9861
|
+
* inspectable. Refused for a `tool-result` route: there the awaiting
|
|
9862
|
+
* caller already holds the result and delivers it itself.
|
|
9863
|
+
*/
|
|
9864
|
+
deliverAgentResult(from, content, options = {}) {
|
|
9865
|
+
const record = this.registry.inspect(from);
|
|
9866
|
+
if (!record)
|
|
9867
|
+
throw new Error(`deliverAgentResult: "${from}" is not a known agent`);
|
|
9868
|
+
const route = record.relationships.resultTo;
|
|
9869
|
+
if (!route)
|
|
9870
|
+
throw new Error(`deliverAgentResult: "${from}" has no result route`);
|
|
9871
|
+
if (route.as !== 'message') {
|
|
9872
|
+
throw new Error(`deliverAgentResult: "${from}" returns its result as a ${route.as}; the caller awaiting the run delivers it`);
|
|
9873
|
+
}
|
|
9874
|
+
const mail = this.registry.holdMail({
|
|
9875
|
+
kind: 'result',
|
|
9876
|
+
from: { agent: from, incarnation: record.incarnation },
|
|
9877
|
+
to: route.to,
|
|
9878
|
+
content,
|
|
9879
|
+
...(options.causedBy?.length ? { causedBy: options.causedBy } : {}),
|
|
9880
|
+
});
|
|
9881
|
+
// A finished job's output survives a hard exit from here on: the hold is
|
|
9882
|
+
// in the registry's recovery file as it is written (persistAgentRegistry),
|
|
9883
|
+
// and holdMail failed this call if it couldn't be. The release, once the
|
|
9884
|
+
// message has landed, waits for the store's sync (releaseLandedMail).
|
|
9885
|
+
return { mailId: mail.id, delivered: this.attemptMailDelivery(mail), to: route.to };
|
|
9886
|
+
}
|
|
9887
|
+
/** Communications not yet handed to their recipient, and why. */
|
|
9888
|
+
listHeldMail(filter = {}) {
|
|
9889
|
+
return structuredClone(this.registry.listMail(filter));
|
|
9890
|
+
}
|
|
9891
|
+
/**
|
|
9892
|
+
* Try to hand one held item to its recipient. True when the message is in
|
|
9893
|
+
* (or queued for) the recipient's context; false when it stays held
|
|
9894
|
+
* because nobody can receive it. The held copy is released by
|
|
9895
|
+
* `addMessage` at the moment the message enters the store.
|
|
9896
|
+
*/
|
|
9897
|
+
attemptMailDelivery(mail, wake = true) {
|
|
9898
|
+
const recipient = this.registry.has(mail.to) ? this.agents.get(mail.to) : undefined;
|
|
9899
|
+
if (!recipient) {
|
|
9900
|
+
const newlyHeld = mail.heldBecause !== 'recipient-gone';
|
|
9901
|
+
this.registry.markMailHeld(mail.id, 'recipient-gone');
|
|
9902
|
+
// A result its sender delivered after it had ended (one delivered while
|
|
9903
|
+
// it ran is told of when it ends, settleEndedAgent).
|
|
9904
|
+
const sender = this.registry.inspect(mail.from.agent);
|
|
9905
|
+
if (newlyHeld && mail.kind === 'result' && sender?.ended)
|
|
9906
|
+
this.tellResultHeld(sender, mail.id);
|
|
9907
|
+
this.emitTrace({
|
|
9908
|
+
type: 'dendrite:mail-held',
|
|
9909
|
+
mailId: mail.id,
|
|
9910
|
+
kind: mail.kind,
|
|
9911
|
+
from: mail.from.agent,
|
|
9912
|
+
to: mail.to,
|
|
9913
|
+
reason: 'recipient-gone',
|
|
9914
|
+
});
|
|
9915
|
+
return false;
|
|
9916
|
+
}
|
|
9917
|
+
const placement = {};
|
|
9918
|
+
this.addMessage(
|
|
9919
|
+
// The sender's own name: its words stay its own.
|
|
9920
|
+
recipient.isOwnParticipant(mail.from.agent) && mail.from.agent !== recipient.name
|
|
9921
|
+
? `${mail.from.agent} (mail)`
|
|
9922
|
+
: mail.from.agent, mail.content, {
|
|
9923
|
+
kind: `agent-${mail.kind}`,
|
|
9924
|
+
dendrite: {
|
|
9925
|
+
mailId: mail.id,
|
|
9926
|
+
kind: mail.kind,
|
|
9927
|
+
from: mail.from,
|
|
9928
|
+
// When the sender produced it — distinct from when it is read. A
|
|
9929
|
+
// result held across a restart arrives later than it was made,
|
|
9930
|
+
// and the reader should be able to tell.
|
|
9931
|
+
producedAt: mail.createdAt,
|
|
9932
|
+
...(mail.causedBy?.length ? { causedBy: mail.causedBy } : {}),
|
|
9933
|
+
},
|
|
9934
|
+
}, { forAgent: mail.to, placement });
|
|
9935
|
+
if (!placement.messageId) {
|
|
9936
|
+
// Queued for the recipient's next boundary. `mail-delivered` fires
|
|
9937
|
+
// when the message actually enters its store (releaseLandedMail):
|
|
9938
|
+
// published is not delivered.
|
|
9939
|
+
this.emitTrace({
|
|
9940
|
+
type: 'dendrite:mail-deferred',
|
|
9941
|
+
mailId: mail.id,
|
|
9942
|
+
kind: mail.kind,
|
|
9943
|
+
from: mail.from.agent,
|
|
9944
|
+
to: mail.to,
|
|
9945
|
+
});
|
|
9946
|
+
}
|
|
9947
|
+
if (wake) {
|
|
9948
|
+
this.pendingRequests.push({
|
|
9949
|
+
agentName: mail.to,
|
|
9950
|
+
reason: `agent-${mail.kind}:${mail.from.agent}`,
|
|
9951
|
+
source: 'dendrite',
|
|
9952
|
+
timestamp: Date.now(),
|
|
9953
|
+
});
|
|
9954
|
+
}
|
|
9955
|
+
return true;
|
|
9956
|
+
}
|
|
9957
|
+
/**
|
|
9958
|
+
* An ended orphan's result held for a recipient that is gone: its first
|
|
9959
|
+
* adoption candidate is told it exists and how it arrives, since nothing
|
|
9960
|
+
* else would surface it (reparentAgent delivers it).
|
|
9961
|
+
*/
|
|
9962
|
+
tellResultHeld(sender, mailId) {
|
|
9963
|
+
const candidate = sender.orphaned?.candidates[0];
|
|
9964
|
+
if (!candidate || !this.announceOrphans)
|
|
9965
|
+
return;
|
|
9966
|
+
this.lifecycleNotice(candidate, `[${NOTICE_TAG.agentLifecycle}: "${sender.name}" has ended after ${sender.orphaned.formerParent.agent} did, ` +
|
|
9967
|
+
`so its result has nowhere to go. The result is held; reparenting "${sender.name}" to you delivers it.]`,
|
|
9968
|
+
// `heldMailId`, not `mailId`: a row carrying dendrite.mailId is read
|
|
9969
|
+
// as that mail having landed, which would release it.
|
|
9970
|
+
{ event: 'result-held', agent: sender.name, heldMailId: mailId, formerParent: sender.orphaned.formerParent });
|
|
9971
|
+
}
|
|
9972
|
+
/** A host-framed lifecycle fact in an agent's context. Never voiced as anyone. */
|
|
9973
|
+
lifecycleNotice(agentName, text, detail) {
|
|
9974
|
+
if (!this.agents.has(agentName))
|
|
9975
|
+
return;
|
|
9976
|
+
this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'agent-lifecycle', dendrite: detail }, { forAgent: agentName });
|
|
9977
|
+
}
|
|
9978
|
+
/**
|
|
9979
|
+
* The creation ("consent") event: names what kind of agent this is, what
|
|
9980
|
+
* ends it and what happens to it when its spawner ends. A derived agent's
|
|
9981
|
+
* creation is also told to the agent it was derived from (#270, Linn:
|
|
9982
|
+
* "told when the body changes" as a property, not a courtesy): one
|
|
9983
|
+
* host-framed line in the source's context, built from the same facts as
|
|
9984
|
+
* the trace, with no wake; a host's derivationNotice can replace the line
|
|
9985
|
+
* or send none. Both derivation paths announce after the
|
|
9986
|
+
* fork's branch or copy exists, so a fork never inherits the notice about
|
|
9987
|
+
* itself; a source that has ended is told nothing (lifecycleNotice).
|
|
9988
|
+
*/
|
|
9989
|
+
announceAgentCreated(record) {
|
|
9990
|
+
this.emitTrace({
|
|
9991
|
+
type: 'dendrite:agent-created',
|
|
9992
|
+
agentName: record.name,
|
|
9993
|
+
kind: record.kind,
|
|
9994
|
+
...(record.model ? { model: record.model } : {}),
|
|
9995
|
+
incarnation: record.incarnation,
|
|
9996
|
+
lifetime: record.lifetime,
|
|
9997
|
+
...(record.relationships.spawnedBy
|
|
9998
|
+
? { spawnedBy: record.relationships.spawnedBy, onParentEnd: record.onParentEnd }
|
|
9999
|
+
: {}),
|
|
10000
|
+
...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}),
|
|
10001
|
+
...(record.inherit ? { inherit: record.inherit } : {}),
|
|
10002
|
+
});
|
|
10003
|
+
const from = record.inherit?.from;
|
|
10004
|
+
if (!from || from === record.name)
|
|
10005
|
+
return;
|
|
10006
|
+
const detail = {
|
|
10007
|
+
event: 'derived',
|
|
10008
|
+
agent: record.name,
|
|
10009
|
+
kind: record.kind,
|
|
10010
|
+
incarnation: record.incarnation,
|
|
10011
|
+
...(record.model ? { model: record.model } : {}),
|
|
10012
|
+
inherit: record.inherit,
|
|
10013
|
+
...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}),
|
|
10014
|
+
lifetime: record.lifetime,
|
|
10015
|
+
};
|
|
10016
|
+
// A resumption is rare and always said. A first incarnation is tallied:
|
|
10017
|
+
// past the threshold, a reader loop that forks a hundred times a session
|
|
10018
|
+
// would fill its source's context with stamps (Linn, 2026-10-15), so
|
|
10019
|
+
// the source hears a count line every `threshold` forks instead, with
|
|
10020
|
+
// the latest described; every derivation still emits its trace.
|
|
10021
|
+
const threshold = this.collapseDerivationNoticesAfter;
|
|
10022
|
+
const tell = (defaultText, extra = {}) => {
|
|
10023
|
+
try {
|
|
10024
|
+
const text = this.derivationNoticeHook ? this.derivationNoticeHook(record, from, defaultText) : defaultText;
|
|
10025
|
+
if (text !== null)
|
|
10026
|
+
this.lifecycleNotice(from, text, { ...detail, ...extra });
|
|
10027
|
+
}
|
|
10028
|
+
catch (error) {
|
|
10029
|
+
console.error(`[dendrite] ${record.name} was created, but ${from} could not be told:`, error);
|
|
10030
|
+
}
|
|
10031
|
+
};
|
|
10032
|
+
if (record.incarnation > 1 || threshold <= 0) {
|
|
10033
|
+
tell(this.derivationNotice(record, from));
|
|
10034
|
+
return;
|
|
10035
|
+
}
|
|
10036
|
+
let byKind = this.derivationTallies.get(from);
|
|
10037
|
+
if (!byKind)
|
|
10038
|
+
this.derivationTallies.set(from, (byKind = new Map()));
|
|
10039
|
+
let tally = byKind.get(record.kind);
|
|
10040
|
+
if (!tally)
|
|
10041
|
+
byKind.set(record.kind, (tally = { count: 0, since: Date.now() }));
|
|
10042
|
+
tally.count++;
|
|
10043
|
+
if (tally.count <= threshold) {
|
|
10044
|
+
tell(this.derivationNotice(record, from));
|
|
10045
|
+
}
|
|
10046
|
+
else if ((tally.count - threshold) % threshold === 0) {
|
|
10047
|
+
const since = new Date(tally.since).toISOString().slice(11, 16) + ' UTC';
|
|
10048
|
+
tell(`[${NOTICE_TAG.agentLifecycle}: ${tally.count} ${record.kind}s have been derived from you since ${since} ` +
|
|
10049
|
+
`(${threshold} since the last note); the latest, ${record.name}, is ${this.derivationDescription(record, from)}.]`, { collapsed: { count: tally.count, sinceLastNote: threshold } });
|
|
10050
|
+
}
|
|
10051
|
+
}
|
|
10052
|
+
/** The derivation notice's text, addressed to the agent `from` the record was derived. */
|
|
10053
|
+
derivationNotice(record, from) {
|
|
10054
|
+
const made = record.incarnation > 1
|
|
10055
|
+
? `was resumed (incarnation ${record.incarnation}) as`
|
|
10056
|
+
: 'was derived from you as';
|
|
10057
|
+
return `[${NOTICE_TAG.agentLifecycle}: ${record.name} ${made} ${this.derivationDescription(record, from)}.]`;
|
|
10058
|
+
}
|
|
10059
|
+
/** What a derived agent is, to the agent it was derived from: "a task-fork on your weights, …; …; …". */
|
|
10060
|
+
derivationDescription(record, from) {
|
|
10061
|
+
const sourceModel = this.agentConfigs.get(from)?.model ?? this.registry.get(from)?.model;
|
|
10062
|
+
const weights = !record.model
|
|
10063
|
+
? ''
|
|
10064
|
+
: record.model === sourceModel
|
|
10065
|
+
? ` on your weights (${record.model})`
|
|
10066
|
+
: ` on ${record.model}`;
|
|
10067
|
+
const inherited = record.inherit?.mode === 'shared'
|
|
10068
|
+
? 'reading your context up to this point'
|
|
10069
|
+
: 'with a copy of your context';
|
|
10070
|
+
const route = record.relationships.resultTo;
|
|
10071
|
+
const home = this.channelOfHome(record.homeChannel);
|
|
10072
|
+
const result = !route
|
|
10073
|
+
? home
|
|
10074
|
+
? `it speaks for itself in ${this.channelShown(home)}`
|
|
10075
|
+
: 'it returns nothing to you'
|
|
10076
|
+
: route.to === from
|
|
10077
|
+
? `its result returns to you as ${route.as === 'message' ? 'a message' : 'a tool result'}`
|
|
10078
|
+
: `its result goes to ${route.to}`;
|
|
10079
|
+
const lifetime = record.lifetime;
|
|
10080
|
+
const minutes = (ms) => (ms < 60_000 ? `${Math.round(ms / 1_000)} s` : `${Math.round(ms / 60_000)} min`);
|
|
10081
|
+
const ends = lifetime.kind === 'task'
|
|
10082
|
+
? 'it ends when its task does' +
|
|
10083
|
+
(lifetime.deadlineMs !== undefined || lifetime.idleTimeoutMs !== undefined
|
|
10084
|
+
? ` (${[
|
|
10085
|
+
lifetime.deadlineMs !== undefined ? `deadline ${minutes(lifetime.deadlineMs)}` : '',
|
|
10086
|
+
lifetime.idleTimeoutMs !== undefined ? `idle ${minutes(lifetime.idleTimeoutMs)}` : '',
|
|
10087
|
+
].filter(Boolean).join(', ')})`
|
|
10088
|
+
: '')
|
|
10089
|
+
: lifetime.kind === 'idle'
|
|
10090
|
+
? `it ends after ${minutes(lifetime.idleTtlMs)} idle`
|
|
10091
|
+
: 'it persists';
|
|
10092
|
+
const parentEnd = record.relationships.spawnedBy === from
|
|
10093
|
+
? record.onParentEnd === 'end' ? ', and ends with you' : ', and is orphaned if you end'
|
|
10094
|
+
: '';
|
|
10095
|
+
return `a ${record.kind}${weights}, ${inherited}; ${result}; ${ends}${parentEnd}`;
|
|
10096
|
+
}
|
|
10097
|
+
/**
|
|
10098
|
+
* Remove an ended agent's live object from the framework. A bounded run
|
|
10099
|
+
* is ended through its awaiting caller (whose teardown owns the
|
|
10100
|
+
* name-keyed state); any other agent is torn down here.
|
|
10101
|
+
*/
|
|
10102
|
+
teardownEndedAgent(record, detail) {
|
|
10103
|
+
const name = record.name;
|
|
10104
|
+
const reason = record.ended?.reason ?? 'stopped';
|
|
10105
|
+
const run = this.ephemeralRuns.get(name);
|
|
10106
|
+
if (run) {
|
|
10107
|
+
run.endReason = reason;
|
|
10108
|
+
run.endedBy = record.ended?.by;
|
|
10109
|
+
run.settle.reject(new AgentStoppedError(name, reason, record.ended?.by, detail));
|
|
10110
|
+
return;
|
|
10111
|
+
}
|
|
10112
|
+
const agent = this.agents.get(name);
|
|
10113
|
+
if (!agent)
|
|
10114
|
+
return;
|
|
10115
|
+
this.releaseAgentRuntime(agent, this.channelOfHome(record.homeChannel), undefined, record.lifetime.kind === 'persistent');
|
|
10116
|
+
}
|
|
10117
|
+
/**
|
|
10118
|
+
* Everything the process holds for a live, non-ephemeral agent that has
|
|
10119
|
+
* ended, released once, on every path an agent ends by — stopAgent, the
|
|
10120
|
+
* idle reap of a conversation fork, an end that cascades from a parent's
|
|
10121
|
+
* (nissa's #546 finding 5: a stop that skipped the window release dropped a
|
|
10122
|
+
* write queued for the agent at the next flush, and a failure notice it
|
|
10123
|
+
* never read reached no one). The window release delivers what the agent
|
|
10124
|
+
* never read to the primary, now or after a resume, as the idle reap did.
|
|
10125
|
+
*/
|
|
10126
|
+
releaseAgentRuntime(agent, home, why = 'its agent ended', declared = false) {
|
|
10127
|
+
const name = agent.name;
|
|
10128
|
+
if (home && this.conversationRouter?.getBinding(home)?.agentName === name) {
|
|
10129
|
+
this.conversationRouter.unbind(home);
|
|
10130
|
+
}
|
|
10131
|
+
this.closingConversationAgents.delete(name);
|
|
10132
|
+
// A continuation of its turn still on its way goes with it.
|
|
10133
|
+
this.dropContinuations(name, { kind: 'dropped', why });
|
|
10134
|
+
// A batch its guard still holds is settled as `aborted`: the stream's
|
|
10135
|
+
// own finally does neither once the agent has gone.
|
|
10136
|
+
if (agent.toolResultGuard.hasPending) {
|
|
10137
|
+
try {
|
|
10138
|
+
agent.toolResultGuard.abandon('aborted');
|
|
10139
|
+
}
|
|
10140
|
+
catch (error) {
|
|
10141
|
+
console.error(`[tool-result-guard] agent=${name} failed to record a batch the fork left:`, error);
|
|
10142
|
+
}
|
|
10143
|
+
}
|
|
10144
|
+
// Cancel before deregistration: a late stream event is discarded by
|
|
10145
|
+
// driveStream's Agent-identity check once the name no longer maps to it.
|
|
10146
|
+
if (this.activeStreams.has(name)) {
|
|
10147
|
+
try {
|
|
10148
|
+
agent.cancelStream();
|
|
10149
|
+
}
|
|
10150
|
+
catch (error) {
|
|
10151
|
+
console.error(`[dendrite] agent=${name} its stream's cancel failed as it ended; it is released all the same:`, error);
|
|
10152
|
+
}
|
|
10153
|
+
}
|
|
10154
|
+
this.activeStreams.delete(name);
|
|
10155
|
+
this.pendingAssistantBlocks.delete(name);
|
|
10156
|
+
this.eventGate?.onInferenceEnded(name);
|
|
10157
|
+
// Its sleep and self-wake end with it (the gate's sleep holds back every
|
|
10158
|
+
// agent's wakes), and neither wake goes to a name that's gone.
|
|
10159
|
+
this.eventGate?.agentGone(name);
|
|
10160
|
+
this.agents.delete(name);
|
|
10161
|
+
this.agentConfigs.delete(name);
|
|
10162
|
+
this.thinkingLossIncidents.forget(name);
|
|
10163
|
+
this.reasoningLoss.forget(name);
|
|
10164
|
+
// main's manager release: the context manager an ephemeral run or a fork
|
|
10165
|
+
// opened is released with the agent (releaseManager).
|
|
10166
|
+
this.releaseManager(agent);
|
|
10167
|
+
this.toolImageLedgers.delete(name);
|
|
10168
|
+
this.evictTurnCheckpoints(name);
|
|
10169
|
+
this.activeTurnTokens.delete(name);
|
|
10170
|
+
this.activeTurnTriggers.delete(name);
|
|
10171
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
10172
|
+
this.pendingRequests = this.pendingRequests.filter((request) => request.agentName !== name);
|
|
10173
|
+
this.releaseWindowNowOrAfterResume(agent);
|
|
10174
|
+
this.turnRouting.delete(name);
|
|
10175
|
+
this.proseSpeechChains.delete(name);
|
|
10176
|
+
this.openLogical.delete(name);
|
|
10177
|
+
// What it was last told of where its speech goes goes with it, kept
|
|
10178
|
+
// only for an agent declared by configuration, which comes back under
|
|
10179
|
+
// its name at the next boot: so the stored baselines are those of
|
|
10180
|
+
// agents that are live or come back.
|
|
10181
|
+
if (!declared)
|
|
10182
|
+
this.forgetAnnouncedLocus(name);
|
|
10183
|
+
// Its scripts go with it, and leave the map once they've ended: no later
|
|
10184
|
+
// run of the name trims them.
|
|
10185
|
+
this.codeExecution?.scripts.agentGone(name);
|
|
10186
|
+
// A provider-wait hold (nonOwningProviderWait) goes with the agent: left,
|
|
10187
|
+
// its timer would requeue held wakes for an agent that is gone, and a wait
|
|
10188
|
+
// held until released would re-arm for the name indefinitely. The recorded
|
|
10189
|
+
// wait stays, and binds the name if its channel binds again.
|
|
10190
|
+
const cooldown = this.providerAccelerationCooldowns.get(name);
|
|
10191
|
+
if (cooldown) {
|
|
10192
|
+
clearTimeout(cooldown.timer);
|
|
10193
|
+
this.providerAccelerationCooldowns.delete(name);
|
|
10194
|
+
console.error(`[provider-wait] agent=${name} ended while held` +
|
|
10195
|
+
`${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` +
|
|
10196
|
+
`${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`);
|
|
10197
|
+
}
|
|
10198
|
+
// main's per-name turn state goes too (#846's re-arm count and run of
|
|
10199
|
+
// stated waits, #840's exhausted turn), so a name bound again (a
|
|
10200
|
+
// channel's next conversation, a resume) starts afresh; a run of stated
|
|
10201
|
+
// waits ends here as at a turn's end, alerted if it ran long
|
|
10202
|
+
// (endStatedWaitRun).
|
|
10203
|
+
this.turnRearms.delete(name);
|
|
10204
|
+
this.exhaustingRearms.delete(name);
|
|
10205
|
+
this.exhaustedTurns.delete(name);
|
|
10206
|
+
this.endStatedWaitRun(name);
|
|
10207
|
+
if (name === this.subconsciousAgentName) {
|
|
10208
|
+
this.tuneOutCoordinator?.stopReader();
|
|
10209
|
+
this.subconsciousAgentName = null;
|
|
10210
|
+
}
|
|
10211
|
+
}
|
|
10212
|
+
/**
|
|
10213
|
+
* Carry out what the registry decided when an agent ended: tear down the
|
|
10214
|
+
* attention tenants that end with it, and tell each orphan — and the
|
|
10215
|
+
* first agent that could adopt it — what happened.
|
|
10216
|
+
*/
|
|
10217
|
+
settleEndedAgent(outcome) {
|
|
10218
|
+
if (!outcome)
|
|
10219
|
+
return;
|
|
10220
|
+
for (const record of [...outcome.cascaded, outcome.ended]) {
|
|
10221
|
+
this.emitTrace({
|
|
10222
|
+
type: 'dendrite:agent-ended',
|
|
10223
|
+
agentName: record.name,
|
|
10224
|
+
kind: record.kind,
|
|
10225
|
+
incarnation: record.incarnation,
|
|
10226
|
+
reason: record.ended?.reason ?? 'stopped',
|
|
10227
|
+
...(record.ended?.by ? { by: record.ended.by } : {}),
|
|
10228
|
+
...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}),
|
|
10229
|
+
});
|
|
10230
|
+
}
|
|
10231
|
+
for (const record of outcome.cascaded)
|
|
10232
|
+
this.teardownEndedAgent(record);
|
|
10233
|
+
// An orphan that ends with a result it delivered while it ran still held
|
|
10234
|
+
// for a recipient that is gone: its first candidate hears of it now.
|
|
10235
|
+
for (const record of [...outcome.cascaded, outcome.ended]) {
|
|
10236
|
+
if (!record.orphaned)
|
|
10237
|
+
continue;
|
|
10238
|
+
const held = this.registry.listMail({ from: record.name })
|
|
10239
|
+
.find((mail) => mail.kind === 'result' && mail.heldBecause === 'recipient-gone');
|
|
10240
|
+
if (held)
|
|
10241
|
+
this.tellResultHeld(record, held.id);
|
|
10242
|
+
}
|
|
10243
|
+
const parent = outcome.ended;
|
|
10244
|
+
for (const { record, candidates } of outcome.orphaned) {
|
|
10245
|
+
this.announceOrphaned(record, candidates, record.orphaned?.formerParent.agent ?? parent.name, record.orphaned?.reason ?? parent.ended?.reason ?? 'stopped');
|
|
10246
|
+
}
|
|
10247
|
+
}
|
|
10248
|
+
/** An agent whose spawner has ended, and its first adoption candidate, are told; the trace says so. */
|
|
10249
|
+
announceOrphaned(record, candidates, former, reason) {
|
|
10250
|
+
this.emitTrace({
|
|
10251
|
+
type: 'dendrite:agent-orphaned',
|
|
10252
|
+
agentName: record.name,
|
|
10253
|
+
formerParent: former,
|
|
10254
|
+
reason,
|
|
10255
|
+
candidates,
|
|
10256
|
+
});
|
|
10257
|
+
const route = record.relationships.resultTo;
|
|
10258
|
+
const resultHeld = route?.as === 'message' && !this.registry.has(route.to);
|
|
10259
|
+
this.lifecycleNotice(record.name, `[${NOTICE_TAG.agentLifecycle}: ${former}, the agent that started you, has ended (${reason}). ` +
|
|
10260
|
+
'Your own work is not cancelled.' +
|
|
10261
|
+
(resultHeld ? ' Your result will be held until another agent becomes its recipient.' : '') +
|
|
10262
|
+
']', { event: 'orphaned', formerParent: record.orphaned?.formerParent, reason, candidates });
|
|
10263
|
+
const candidate = candidates[0];
|
|
10264
|
+
if (candidate && this.announceOrphans) {
|
|
10265
|
+
this.lifecycleNotice(candidate, `[${NOTICE_TAG.agentLifecycle}: ${former} has ended (${reason}) while its ${record.kind} agent ` +
|
|
10266
|
+
`"${record.name}" was still running. "${record.name}" now has no parent` +
|
|
10267
|
+
(resultHeld ? ' and nowhere to return its result' : '') +
|
|
10268
|
+
'; it can be reparented to you.]', { event: 'adoption-offered', agent: record.name, formerParent: record.orphaned?.formerParent, reason });
|
|
10269
|
+
}
|
|
10270
|
+
}
|
|
10271
|
+
/** The registry's snapshot: the recovery file first (atomic, fsynced; it
|
|
10272
|
+
* wins at the next boot), then the branch-local state, kept for inspection
|
|
10273
|
+
* and for stores read by older code. Either write failing fails the call
|
|
10274
|
+
* that changed the registry (nissa's #546 finding 2): a change that isn't
|
|
10275
|
+
* durable isn't a success. */
|
|
10276
|
+
persistAgentRegistry(snapshot) {
|
|
10277
|
+
if (this.agentRegistryPath)
|
|
10278
|
+
this.writeRecoveryFile(this.agentRegistryPath, snapshot);
|
|
10279
|
+
this.store.setStateJson(AGENT_REGISTRY_STATE_ID, snapshot);
|
|
10280
|
+
}
|
|
10281
|
+
/** An ended record: appended to the branch-local log, and the recovery
|
|
10282
|
+
* file rewritten with the newest records (as many as a restart reads). */
|
|
10283
|
+
appendEndedAgentRecord(record) {
|
|
10284
|
+
this.rewriteEndedRecovery([...this.endedRecovery, record]);
|
|
10285
|
+
this.store.appendToStateJson(AGENT_REGISTRY_ENDED_ID, record);
|
|
10286
|
+
}
|
|
10287
|
+
/**
|
|
10288
|
+
* A requested change was put back in the registry's memory: the recovery
|
|
10289
|
+
* files, each written before the branch-local store, are rewritten from it
|
|
10290
|
+
* (the snapshot's and the ended log's), so neither keeps the change the
|
|
10291
|
+
* caller was told failed. The branch-local snapshot needs no rewrite: it is
|
|
10292
|
+
* the last write a change makes, so a change that failed never reached it.
|
|
10293
|
+
* The branch-local ended log is append-only, so an end appended before the
|
|
10294
|
+
* failure stays there. It is read at boot only where no ended recovery
|
|
10295
|
+
* file exists (or the rewrite of that file failed too); the restore then
|
|
10296
|
+
* drops a bounded agent it lists as ended for the reason recorded, with no
|
|
10297
|
+
* restart notice to its recipient, while a persistent agent waits to be
|
|
10298
|
+
* re-declared as it would anyway. A rewrite that fails is reported, the
|
|
10299
|
+
* next is still made, and the caller's own error goes on.
|
|
10300
|
+
*/
|
|
10301
|
+
rewriteAgentRegistryAfterRollback() {
|
|
10302
|
+
const rewrites = [
|
|
10303
|
+
['the ended log recovery file', 'the next end written', () => this.rewriteEndedRecovery(this.registry.endedHistory())],
|
|
10304
|
+
...(this.agentRegistryPath
|
|
10305
|
+
? [['the registry recovery file', 'the next registry write', () => this.writeRecoveryFile(this.agentRegistryPath, this.registry.snapshot())]]
|
|
10306
|
+
: []),
|
|
10307
|
+
];
|
|
10308
|
+
for (const [what, next, rewrite] of rewrites) {
|
|
10309
|
+
try {
|
|
10310
|
+
rewrite();
|
|
10311
|
+
}
|
|
10312
|
+
catch (error) {
|
|
10313
|
+
console.error(`[dendrite] after a registry change failed and was put back in memory, ${what} could not be rewritten to match; ${next} rewrites it:`, error);
|
|
10314
|
+
}
|
|
10315
|
+
}
|
|
10316
|
+
}
|
|
10317
|
+
/**
|
|
10318
|
+
* An ended record past the registry's memory, from the branch-local ended
|
|
10319
|
+
* log. A scan of the whole log takes tens of milliseconds at a few thousand
|
|
10320
|
+
* ends, and a name nobody used (a tool call's typo) is a miss, so the log is
|
|
10321
|
+
* indexed by name once, at the first lookup, and each later lookup indexes
|
|
10322
|
+
* only what was appended since. A log that changed under the index (a
|
|
10323
|
+
* branch switch) is indexed again: one that shrank, one whose last indexed
|
|
10324
|
+
* entry is no longer the one indexed, or one whose entry no longer carries
|
|
10325
|
+
* the name indexed for it.
|
|
10326
|
+
*/
|
|
10327
|
+
findEndedAgentRecord(name) {
|
|
10328
|
+
return this.findInEndedLog('name', name);
|
|
10329
|
+
}
|
|
10330
|
+
/**
|
|
10331
|
+
* The ended log's latest record whose own context a namespace holds. A
|
|
10332
|
+
* shared fork's record names its parent's namespace, where it owns only a
|
|
10333
|
+
* branch, so it is indexed by name alone.
|
|
10334
|
+
*/
|
|
10335
|
+
findInEndedLog(by, value) {
|
|
10336
|
+
const byKey = by === 'name' ? this.endedLogIndex : this.endedLogNamespaceIndex;
|
|
10337
|
+
const reset = () => {
|
|
10338
|
+
this.endedLogIndex.clear();
|
|
10339
|
+
this.endedLogNamespaceIndex.clear();
|
|
10340
|
+
this.endedLogIndexed = 0;
|
|
10341
|
+
};
|
|
10342
|
+
const read = (index) => this.store.getStateItemJson(AGENT_REGISTRY_ENDED_ID, index);
|
|
10343
|
+
const mark = (record) => (record ? `${record.name}#${record.incarnation}@${record.ended?.at}` : '');
|
|
10344
|
+
// A store that can't be read fails the lookup rather than answering
|
|
10345
|
+
// "never ended", which would let a namespace check pass.
|
|
10346
|
+
const length = this.store.getStateLen(AGENT_REGISTRY_ENDED_ID) ?? 0;
|
|
10347
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
10348
|
+
if (length < this.endedLogIndexed
|
|
10349
|
+
|| (this.endedLogIndexed > 0 && mark(read(this.endedLogIndexed - 1)) !== this.endedLogTail)) {
|
|
10350
|
+
reset();
|
|
10351
|
+
}
|
|
10352
|
+
for (let index = this.endedLogIndexed; index < length; index++) {
|
|
10353
|
+
const record = read(index);
|
|
10354
|
+
if (!record?.ended || typeof record.name !== 'string')
|
|
10355
|
+
continue;
|
|
10356
|
+
this.endedLogIndex.set(record.name, index);
|
|
10357
|
+
if (typeof record.namespace === 'string' && record.inherit?.mode !== 'shared') {
|
|
10358
|
+
this.endedLogNamespaceIndex.set(record.namespace, index);
|
|
10359
|
+
}
|
|
10360
|
+
}
|
|
10361
|
+
this.endedLogIndexed = length;
|
|
10362
|
+
this.endedLogTail = length > 0 ? mark(read(length - 1)) : '';
|
|
10363
|
+
const at = byKey.get(value);
|
|
10364
|
+
if (at === undefined)
|
|
10365
|
+
return undefined;
|
|
10366
|
+
const record = read(at);
|
|
10367
|
+
if (record?.ended && record[by] === value)
|
|
10368
|
+
return record;
|
|
10369
|
+
reset();
|
|
10370
|
+
}
|
|
10371
|
+
return undefined;
|
|
10372
|
+
}
|
|
10373
|
+
/** Another agent whose context a namespace holds: opened in this process, in the registry, or in the ended log. */
|
|
10374
|
+
namespaceHolder(namespace, except) {
|
|
10375
|
+
const opened = this.namespaceOwners.get(namespace);
|
|
10376
|
+
if (opened !== undefined && opened !== except)
|
|
10377
|
+
return opened;
|
|
10378
|
+
const held = this.registry.namespaceHolder(namespace, except);
|
|
10379
|
+
if (held)
|
|
10380
|
+
return held;
|
|
10381
|
+
const ended = this.findInEndedLog('namespace', namespace);
|
|
10382
|
+
return ended && ended.name !== except ? ended.name : undefined;
|
|
10383
|
+
}
|
|
10384
|
+
/** The ended log's recovery file, rewritten as the newest records (as many as a restart reads). */
|
|
10385
|
+
rewriteEndedRecovery(ended) {
|
|
10386
|
+
this.endedRecovery = ended.slice(-AGENT_REGISTRY_ENDED_RELOAD);
|
|
10387
|
+
if (this.agentRegistryEndedPath)
|
|
10388
|
+
this.writeRecoveryFile(this.agentRegistryEndedPath, { version: 1, ended: this.endedRecovery });
|
|
10389
|
+
}
|
|
10390
|
+
/**
|
|
10391
|
+
* Load the previous process's registry. Returns the bounded work it
|
|
10392
|
+
* interrupted. A registry that can't be read, or that reads as anything
|
|
10393
|
+
* but a version-1 registry, refuses the start and names the state (nissa's
|
|
10394
|
+
* #546 finding 1, main's rule for the framework's own state): taken as
|
|
10395
|
+
* empty, the results it holds for delivery and the records of the work it
|
|
10396
|
+
* ran would be overwritten by this boot's first registration.
|
|
10397
|
+
*/
|
|
10398
|
+
restoreAgentRegistry() {
|
|
10399
|
+
const refuse = (what, problem, cause) => new Error(`the agent registry (${what}) ${problem}. The framework doesn't start without it: taken as empty, the results ` +
|
|
10400
|
+
'it holds for delivery and the records of the work it ran would be overwritten at this boot. ' +
|
|
10401
|
+
(cause === undefined
|
|
10402
|
+
? "It's unchanged, and the framework starts once it holds a version-1 registry, such as " +
|
|
10403
|
+
'{"version":1,"records":[],"mail":[],"incarnations":{}} for an empty one.'
|
|
10404
|
+
: "It's unchanged, and the framework starts once it can be read."), cause === undefined ? undefined : { cause });
|
|
10405
|
+
let snapshot;
|
|
10406
|
+
let snapshotFrom = this.agentRegistryPath ?? AGENT_REGISTRY_STATE_ID;
|
|
10407
|
+
// Only a registry that was never written is absent: a recovery file that
|
|
10408
|
+
// exists is the registry, whatever it holds, so one that reads as `null`
|
|
10409
|
+
// is refused like any other that isn't a version-1 registry.
|
|
10410
|
+
let absent = false;
|
|
10411
|
+
try {
|
|
10412
|
+
snapshot = this.agentRegistryPath ? this.readRecoveryFile(this.agentRegistryPath) : undefined;
|
|
10413
|
+
if (snapshot === undefined) {
|
|
10414
|
+
snapshotFrom = AGENT_REGISTRY_STATE_ID;
|
|
10415
|
+
snapshot = this.store.getStateJson(AGENT_REGISTRY_STATE_ID) ?? null;
|
|
10416
|
+
absent = snapshot === null;
|
|
10417
|
+
}
|
|
10418
|
+
}
|
|
10419
|
+
catch (error) {
|
|
10420
|
+
throw refuse(snapshotFrom, "can't be read", error);
|
|
10421
|
+
}
|
|
10422
|
+
if (!absent && (snapshot === null || typeof snapshot !== 'object' || snapshot.version !== 1)) {
|
|
10423
|
+
const read = snapshot === null ? 'null' : `version ${JSON.stringify(snapshot.version)}`;
|
|
10424
|
+
throw refuse(snapshotFrom, `reads as something other than a version-1 registry (${read})`);
|
|
10425
|
+
}
|
|
10426
|
+
if (!absent) {
|
|
10427
|
+
const { records, mail, incarnations } = snapshot;
|
|
10428
|
+
if (!Array.isArray(records) || !Array.isArray(mail)) {
|
|
10429
|
+
throw refuse(snapshotFrom, 'reads as a version-1 registry whose records or mail are not lists');
|
|
10430
|
+
}
|
|
10431
|
+
if (incarnations === null || typeof incarnations !== 'object' || Array.isArray(incarnations)) {
|
|
10432
|
+
throw refuse(snapshotFrom, 'reads as a version-1 registry with no map of incarnations, without which every agent it names would start again at incarnation 1');
|
|
10433
|
+
}
|
|
10434
|
+
}
|
|
10435
|
+
let ended;
|
|
10436
|
+
let endedFrom = this.agentRegistryEndedPath ?? AGENT_REGISTRY_ENDED_ID;
|
|
10437
|
+
try {
|
|
10438
|
+
const file = this.agentRegistryEndedPath ? this.readRecoveryFile(this.agentRegistryEndedPath) : undefined;
|
|
10439
|
+
if (file !== undefined) {
|
|
10440
|
+
ended = file?.ended;
|
|
10441
|
+
}
|
|
10442
|
+
else {
|
|
10443
|
+
endedFrom = AGENT_REGISTRY_ENDED_ID;
|
|
10444
|
+
const tail = this.store.getStateTail(AGENT_REGISTRY_ENDED_ID, AGENT_REGISTRY_ENDED_RELOAD);
|
|
10445
|
+
ended = tail ? JSON.parse(tail.toString('utf8')) : [];
|
|
10446
|
+
}
|
|
10447
|
+
}
|
|
10448
|
+
catch (error) {
|
|
10449
|
+
throw refuse(endedFrom, "has an ended log that can't be read", error);
|
|
10450
|
+
}
|
|
10451
|
+
if (!Array.isArray(ended))
|
|
10452
|
+
throw refuse(endedFrom, 'has an ended log that reads as something other than a list of records');
|
|
10453
|
+
this.endedRecovery = ended.slice(-AGENT_REGISTRY_ENDED_RELOAD);
|
|
10454
|
+
return this.registry.restore(snapshot, ended).interrupted;
|
|
10455
|
+
}
|
|
10456
|
+
/**
|
|
10457
|
+
* Boot reconciliation, once configuration has declared its agents.
|
|
10458
|
+
* Restart behaviour follows from lifetime: persistent agents were just
|
|
10459
|
+
* re-created; everything else that was live was interrupted, and the
|
|
10460
|
+
* recipient of an interrupted job's result is told so. Results held from
|
|
10461
|
+
* before the restart are delivered now, exactly once.
|
|
10462
|
+
*/
|
|
10463
|
+
reconcileAgentRegistry(interrupted) {
|
|
10464
|
+
for (const outcome of this.registry.reconcile().unconfigured)
|
|
10465
|
+
this.settleEndedAgent(outcome);
|
|
10466
|
+
const byRecipient = new Map();
|
|
10467
|
+
for (const record of interrupted) {
|
|
10468
|
+
this.emitTrace({
|
|
10469
|
+
type: 'dendrite:agent-ended',
|
|
10470
|
+
agentName: record.name,
|
|
10471
|
+
kind: record.kind,
|
|
10472
|
+
incarnation: record.incarnation,
|
|
10473
|
+
reason: 'host-restart',
|
|
10474
|
+
...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}),
|
|
10475
|
+
});
|
|
10476
|
+
const route = record.relationships.resultTo;
|
|
10477
|
+
if (route?.as !== 'message' || !this.agents.has(route.to))
|
|
10478
|
+
continue;
|
|
10479
|
+
// A result it had already handed over needs no apology.
|
|
10480
|
+
if (this.registry.listMail({ from: record.name }).some((mail) => mail.kind === 'result'))
|
|
10481
|
+
continue;
|
|
10482
|
+
byRecipient.set(route.to, [...(byRecipient.get(route.to) ?? []), record]);
|
|
10483
|
+
}
|
|
10484
|
+
for (const [recipient, all] of byRecipient) {
|
|
10485
|
+
// The interrupted records stay in the snapshot until this notice is
|
|
10486
|
+
// durable and their ends are written (settleInterrupted below), so a
|
|
10487
|
+
// crash after it landed, or an end whose write failed, comes here
|
|
10488
|
+
// again: a record an earlier interrupted notice to this recipient names
|
|
10489
|
+
// (its key lists each name, incarnation and creation time, since a
|
|
10490
|
+
// single-use name that aged out of memory starts again at incarnation
|
|
10491
|
+
// 1), in the recipient's store or the restored deferred queue, has been
|
|
10492
|
+
// told, however many of that notice's records have ended since.
|
|
10493
|
+
const toldKeys = new Set();
|
|
10494
|
+
const noteTold = (metadata) => {
|
|
10495
|
+
const key = metadata?.dendrite?.noticeKey;
|
|
10496
|
+
if (typeof key === 'string')
|
|
10497
|
+
for (const one of key.split(','))
|
|
10498
|
+
toldKeys.add(one);
|
|
10499
|
+
};
|
|
10500
|
+
for (const message of this.agents.get(recipient).getContextManager()
|
|
10501
|
+
.queryMessages({ metadata: { 'dendrite.event': 'interrupted' } }).messages)
|
|
10502
|
+
noteTold(message.metadata);
|
|
10503
|
+
for (const message of this.deferredMessages)
|
|
10504
|
+
noteTold(message.metadata);
|
|
10505
|
+
const keyOf = (record) => `${record.name}#${record.incarnation}@${record.createdAt}`;
|
|
10506
|
+
const records = all.filter((record) => !toldKeys.has(keyOf(record)));
|
|
10507
|
+
if (records.length === 0)
|
|
10508
|
+
continue;
|
|
10509
|
+
const noticeKey = records.map(keyOf).sort().join(',');
|
|
10510
|
+
const names = records.map((record) => `"${record.name}"`).join(', ');
|
|
10511
|
+
this.lifecycleNotice(recipient, `[${NOTICE_TAG.agentLifecycle}: the host restarted while ${names} ${records.length === 1 ? 'was' : 'were'} ` +
|
|
10512
|
+
`still working for you. ${records.length === 1 ? 'It was' : 'They were'} ended without a result.]`, { event: 'interrupted', agents: records.map((record) => record.name), reason: 'host-restart', noticeKey });
|
|
10513
|
+
this.pendingRequests.push({
|
|
10514
|
+
agentName: recipient,
|
|
10515
|
+
reason: 'agent-interrupted',
|
|
10516
|
+
source: 'dendrite',
|
|
10517
|
+
timestamp: Date.now(),
|
|
10518
|
+
});
|
|
10519
|
+
}
|
|
10520
|
+
// The notices are durable once the store has synced (a quiesced host's
|
|
10521
|
+
// are in the deferred queue's recovery file already); only then are the
|
|
10522
|
+
// interrupted records' ends written and the snapshot let go of them.
|
|
10523
|
+
if (interrupted.length > 0) {
|
|
10524
|
+
try {
|
|
10525
|
+
this.store.sync();
|
|
10526
|
+
this.registry.settleInterrupted();
|
|
10527
|
+
}
|
|
10528
|
+
catch (error) {
|
|
10529
|
+
console.error('[dendrite] the restart notices could not be synced; the interrupted records stay in the registry, and the next boot tells their recipients again unless the notice is found:', error);
|
|
10530
|
+
}
|
|
10531
|
+
}
|
|
10532
|
+
for (const mail of this.registry.listMail())
|
|
10533
|
+
this.deliverHeldMail(mail);
|
|
10534
|
+
}
|
|
10535
|
+
/**
|
|
10536
|
+
* Deliver held mail that hasn't landed. Its copy may have reached the
|
|
10537
|
+
* recipient's store in the same sync that the release missed, and then it
|
|
10538
|
+
* is released instead; and a write the deferred queue still holds for it
|
|
10539
|
+
* lands at the next flush and releases it then (releaseLandedMail). Either
|
|
10540
|
+
* way a second delivery would put it in the window twice under one mail id.
|
|
10541
|
+
*/
|
|
10542
|
+
deliverHeldMail(mail, wake = true) {
|
|
10543
|
+
const recipient = this.agents.get(mail.to);
|
|
10544
|
+
if (recipient) {
|
|
10545
|
+
const already = recipient.getContextManager().queryMessages({ metadata: { 'dendrite.mailId': mail.id } });
|
|
10546
|
+
if (already.messages.length > 0) {
|
|
10547
|
+
this.registry.releaseMail(mail.id);
|
|
10548
|
+
return;
|
|
10549
|
+
}
|
|
10550
|
+
}
|
|
10551
|
+
const queued = this.deferredMessages.some((m) => m.metadata?.dendrite?.mailId === mail.id);
|
|
10552
|
+
if (queued)
|
|
10553
|
+
return;
|
|
10554
|
+
this.attemptMailDelivery(mail, wake);
|
|
8893
10555
|
}
|
|
8894
10556
|
createDeferred() {
|
|
8895
10557
|
let resolve;
|
|
@@ -9037,7 +10699,8 @@ export class AgentFramework {
|
|
|
9037
10699
|
// Only a fresh object returned by createEphemeralAgent may enter this path.
|
|
9038
10700
|
// Never overwrite a resident/conversation owner or a concurrent run: their
|
|
9039
10701
|
// cleanup is name-keyed and could cancel/deregister the legitimate owner.
|
|
9040
|
-
|
|
10702
|
+
const candidate = this.ephemeralCandidates.get(agent);
|
|
10703
|
+
if (!candidate || candidate.contextManager !== contextManager) {
|
|
9041
10704
|
throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
|
|
9042
10705
|
}
|
|
9043
10706
|
// A live surgery holds the whole store: an agent admitted now would
|
|
@@ -9050,11 +10713,29 @@ export class AgentFramework {
|
|
|
9050
10713
|
// Consume before any await/registration: the exact (Agent, ContextManager)
|
|
9051
10714
|
// generation is one-shot even if startup later fails.
|
|
9052
10715
|
this.ephemeralCandidates.delete(agent);
|
|
10716
|
+
if (candidate.resume && this.resumesPending.get(agent.name) === agent)
|
|
10717
|
+
this.resumesPending.delete(agent.name);
|
|
9053
10718
|
if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
|
|
9054
10719
|
throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
|
|
9055
10720
|
}
|
|
10721
|
+
// Admit it to the registry first: a spec the registry refuses (spawner
|
|
10722
|
+
// or result recipient gone since creation) must not leave a live agent.
|
|
10723
|
+
const registered = this.registry.register(candidate.spec, { resume: candidate.resume === true, after: candidate.after });
|
|
10724
|
+
this.announceAgentCreated(registered);
|
|
9056
10725
|
// Register temporarily so the event loop can drive it
|
|
9057
10726
|
this.agents.set(agent.name, agent);
|
|
10727
|
+
// Kept while the run is live so the agent can itself be derived from.
|
|
10728
|
+
this.agentConfigs.set(agent.name, candidate.config);
|
|
10729
|
+
// A spawner that ended after creating it: it registered as that
|
|
10730
|
+
// spawner's orphan, and is told so, as is its first candidate.
|
|
10731
|
+
if (registered.orphaned) {
|
|
10732
|
+
this.announceOrphaned(registered, registered.orphaned.candidates, registered.orphaned.formerParent.agent, registered.orphaned.reason);
|
|
10733
|
+
}
|
|
10734
|
+
// Mail held for this name (a resumed incarnation's, sent while it was
|
|
10735
|
+
// ended) reaches it now rather than at the next boot. Its run is
|
|
10736
|
+
// starting, so it wakes nothing.
|
|
10737
|
+
for (const mail of this.registry.listMail({ to: agent.name }))
|
|
10738
|
+
this.deliverHeldMail(mail, false);
|
|
9058
10739
|
const run = {
|
|
9059
10740
|
settle: this.createDeferred(),
|
|
9060
10741
|
inferenceStarted: false,
|
|
@@ -9062,14 +10743,39 @@ export class AgentFramework {
|
|
|
9062
10743
|
toolCallsCount: 0,
|
|
9063
10744
|
};
|
|
9064
10745
|
this.ephemeralRuns.set(agent.name, run);
|
|
10746
|
+
const lifetime = candidate.spec.lifetime;
|
|
9065
10747
|
const STARTUP_TIMEOUT_MS = watchdogs?.startupTimeoutMs ?? 30_000;
|
|
9066
10748
|
// After inference has started, give it 15 minutes of activity-bounded
|
|
9067
10749
|
// life. The stream driver refreshes the deadline as it makes progress;
|
|
9068
|
-
// sustained silence trips it.
|
|
9069
|
-
|
|
10750
|
+
// sustained silence trips it. An explicit watchdog wins; otherwise the
|
|
10751
|
+
// spec's task lifetime is the bound.
|
|
10752
|
+
const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs
|
|
10753
|
+
?? (lifetime.kind === 'task' ? lifetime.idleTimeoutMs : undefined)
|
|
10754
|
+
?? 15 * 60_000;
|
|
9070
10755
|
const IDLE_POLL_MS = watchdogs?.idlePollMs ?? 30_000;
|
|
9071
10756
|
let startupWatchdog = null;
|
|
9072
10757
|
let completionWatchdog = null;
|
|
10758
|
+
let deadlineWatchdog = null;
|
|
10759
|
+
// A task lifetime's wall-clock deadline: the identity must end, however
|
|
10760
|
+
// lively its stream is.
|
|
10761
|
+
// A timer holds at most MAX_TIMER_MS, so a longer deadline waits in
|
|
10762
|
+
// steps, each re-armed until the deadline is due.
|
|
10763
|
+
const deadline = new Promise((_, reject) => {
|
|
10764
|
+
if (lifetime.kind !== 'task' || lifetime.deadlineMs === undefined)
|
|
10765
|
+
return;
|
|
10766
|
+
const deadlineMs = lifetime.deadlineMs;
|
|
10767
|
+
const dueAt = Date.now() + deadlineMs;
|
|
10768
|
+
const arm = () => {
|
|
10769
|
+
deadlineWatchdog = setTimeout(() => {
|
|
10770
|
+
if (Date.now() < dueAt)
|
|
10771
|
+
return arm();
|
|
10772
|
+
run.endReason = 'deadline';
|
|
10773
|
+
reject(new AgentStoppedError(agent.name, 'deadline', undefined, `${deadlineMs}ms elapsed`));
|
|
10774
|
+
}, Math.max(0, Math.min(dueAt - Date.now(), MAX_TIMER_MS)));
|
|
10775
|
+
deadlineWatchdog.unref?.();
|
|
10776
|
+
};
|
|
10777
|
+
arm();
|
|
10778
|
+
});
|
|
9073
10779
|
// Whether the completion watchdog ended the run as stalled, for a
|
|
9074
10780
|
// tool-result batch the run leaves behind (see the finally).
|
|
9075
10781
|
let stalled = false;
|
|
@@ -9115,7 +10821,9 @@ export class AgentFramework {
|
|
|
9115
10821
|
run.settle.promise,
|
|
9116
10822
|
startupTimeout,
|
|
9117
10823
|
completionIdleTimeout,
|
|
10824
|
+
deadline,
|
|
9118
10825
|
]);
|
|
10826
|
+
run.endReason ??= 'completed';
|
|
9119
10827
|
return { speech: result.speech, toolCallsCount: result.toolCallsCount };
|
|
9120
10828
|
}
|
|
9121
10829
|
finally {
|
|
@@ -9123,6 +10831,8 @@ export class AgentFramework {
|
|
|
9123
10831
|
clearTimeout(startupWatchdog);
|
|
9124
10832
|
if (completionWatchdog)
|
|
9125
10833
|
clearInterval(completionWatchdog);
|
|
10834
|
+
if (deadlineWatchdog)
|
|
10835
|
+
clearTimeout(deadlineWatchdog);
|
|
9126
10836
|
// A batch still pending here has nothing left to settle it: once the
|
|
9127
10837
|
// run is deregistered, driveStream's finally abandons nothing (it does
|
|
9128
10838
|
// only while the agent is registered), and no later turn comes. The
|
|
@@ -9182,6 +10892,7 @@ export class AgentFramework {
|
|
|
9182
10892
|
this.turnRouting.delete(agent.name);
|
|
9183
10893
|
this.proseSpeechChains.delete(agent.name);
|
|
9184
10894
|
this.openLogical.delete(agent.name);
|
|
10895
|
+
this.forgetAnnouncedLocus(agent.name);
|
|
9185
10896
|
// The late stream finalizer intentionally refuses name-keyed cleanup once
|
|
9186
10897
|
// deregistered, so disposal must release EventGate liveness itself.
|
|
9187
10898
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
@@ -9193,6 +10904,9 @@ export class AgentFramework {
|
|
|
9193
10904
|
this.releaseWindowNowOrAfterResume(agent);
|
|
9194
10905
|
}
|
|
9195
10906
|
this.toolImageLedgers.delete(agent.name);
|
|
10907
|
+
if (this.agentConfigs.get(agent.name) === candidate.config)
|
|
10908
|
+
this.agentConfigs.delete(agent.name);
|
|
10909
|
+
this.toolSurfaceOwners.delete(agent.name);
|
|
9196
10910
|
// Its scripts end with it: no turn of its is left for them to outlive.
|
|
9197
10911
|
this.codeExecution?.scripts.agentGone(agent.name);
|
|
9198
10912
|
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
@@ -9210,6 +10924,22 @@ export class AgentFramework {
|
|
|
9210
10924
|
// A continuation of its turn still on its way goes too: the run, and
|
|
9211
10925
|
// with it the turn, has ended above.
|
|
9212
10926
|
this.dropContinuations(agent.name, null);
|
|
10927
|
+
if (run.endReason === 'host-stop') {
|
|
10928
|
+
// Cut off by stop(): left live for the next boot to end as interrupted.
|
|
10929
|
+
const record = this.registry.inspect(agent.name);
|
|
10930
|
+
this.emitTrace({
|
|
10931
|
+
type: 'dendrite:agent-interrupted',
|
|
10932
|
+
agentName: agent.name,
|
|
10933
|
+
kind: record?.kind ?? candidate.spec.kind,
|
|
10934
|
+
incarnation: record?.incarnation ?? 0,
|
|
10935
|
+
reason: 'host-stop',
|
|
10936
|
+
});
|
|
10937
|
+
}
|
|
10938
|
+
else {
|
|
10939
|
+
// The identity ends with its run. Whatever it spawned is settled by
|
|
10940
|
+
// the registry's rules (attention tenants end, task work is orphaned).
|
|
10941
|
+
this.settleEndedAgent(this.registry.end(agent.name, run.endReason ?? 'failed', run.endedBy, { record: true }));
|
|
10942
|
+
}
|
|
9213
10943
|
}
|
|
9214
10944
|
}
|
|
9215
10945
|
/**
|
|
@@ -9240,12 +10970,22 @@ export class AgentFramework {
|
|
|
9240
10970
|
'its messages will reach your subconscious where that\'s known. ' +
|
|
9241
10971
|
'Calling tune_out again for a channel already tuned out keeps its ' +
|
|
9242
10972
|
'settings, and, where this host can open the channel, tries the open ' +
|
|
9243
|
-
'again if it isn\'t open.'
|
|
10973
|
+
'again if it isn\'t open. Without a subconscious it is a silent hold: ' +
|
|
10974
|
+
'nothing summarizes or wakes you; the backlog is delivered when you ' +
|
|
10975
|
+
'cancel. scope "all-except" holds EVERY channel (DMs and new channels ' +
|
|
10976
|
+
'included) except channelId, for durationSeconds (required); your plain ' +
|
|
10977
|
+
'speech lands in that channel meanwhile; the reader pauses rather than ' +
|
|
10978
|
+
'cancels when its wake budget is spent. mode "check" reports what is ' +
|
|
10979
|
+
'held without releasing it (`peek` shows one channel\'s newest held lines).',
|
|
9244
10980
|
inputSchema: {
|
|
9245
10981
|
type: 'object',
|
|
9246
10982
|
properties: {
|
|
9247
|
-
channelId: { type: 'string' },
|
|
9248
|
-
|
|
10983
|
+
channelId: { type: 'string', description: 'The channel to tune out — or, with scope "all-except", the one channel to keep.' },
|
|
10984
|
+
serverId: { type: 'string', description: 'The channel\'s server, where the channel id exists on several servers; channelId also takes its server-qualified id ("serverId:channelId").' },
|
|
10985
|
+
mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' },
|
|
10986
|
+
scope: { type: 'string', enum: ['channel', 'all-except'], description: 'Default: channel.' },
|
|
10987
|
+
peek: { type: 'string', description: 'mode "check": a held channel whose newest held lines to show (nothing is released).' },
|
|
10988
|
+
peekLimit: { type: 'number', description: 'mode "check": how many lines to peek (default 5, max 50).' },
|
|
9249
10989
|
cadenceSeconds: {
|
|
9250
10990
|
type: 'number',
|
|
9251
10991
|
description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
|
|
@@ -9265,7 +11005,31 @@ export class AgentFramework {
|
|
|
9265
11005
|
'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
|
|
9266
11006
|
},
|
|
9267
11007
|
},
|
|
9268
|
-
required: [
|
|
11008
|
+
required: [],
|
|
11009
|
+
},
|
|
11010
|
+
};
|
|
11011
|
+
/** `focus`: the resident-facing alias for `tune_out` with scope "all-except" (#232 decision 2). */
|
|
11012
|
+
static FOCUS_TOOL = {
|
|
11013
|
+
name: 'focus',
|
|
11014
|
+
description: 'Focus on one channel for a bounded time: every other channel — DMs ' +
|
|
11015
|
+
'and channels that appear later included — is held and stops waking ' +
|
|
11016
|
+
'you; your plain speech lands in the focused channel. The same hold as ' +
|
|
11017
|
+
'tune_out with scope "all-except". mode "cancel" releases the held ' +
|
|
11018
|
+
'backlog per channel; mode "check" reports what is held.',
|
|
11019
|
+
inputSchema: {
|
|
11020
|
+
type: 'object',
|
|
11021
|
+
properties: {
|
|
11022
|
+
channelId: { type: 'string', description: 'The one channel to keep.' },
|
|
11023
|
+
serverId: { type: 'string' },
|
|
11024
|
+
durationSeconds: { type: 'number', description: 'Required to enter; floors at 60.' },
|
|
11025
|
+
mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' },
|
|
11026
|
+
cadenceSeconds: { type: 'number' },
|
|
11027
|
+
backlogCap: { type: 'number', description: 'Per held channel, at release.' },
|
|
11028
|
+
maxWakes: { type: 'number' },
|
|
11029
|
+
peek: { type: 'string' },
|
|
11030
|
+
peekLimit: { type: 'number' },
|
|
11031
|
+
},
|
|
11032
|
+
required: [],
|
|
9269
11033
|
},
|
|
9270
11034
|
};
|
|
9271
11035
|
/** Synthesized sleep/wake tool definitions (present when a gate is wired). */
|
|
@@ -11053,17 +12817,20 @@ export class AgentFramework {
|
|
|
11053
12817
|
}
|
|
11054
12818
|
disposeEmptiedForks(failures) {
|
|
11055
12819
|
const disposals = [];
|
|
11056
|
-
|
|
12820
|
+
// The forks are the registry's conversation-fork records; a fork's home is
|
|
12821
|
+
// the channel on its server (homeOf), which the router's binding is keyed by.
|
|
12822
|
+
const forks = this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ agentName: r.name, home: this.homeOf(r.name) }));
|
|
12823
|
+
for (const { agentName, home } of forks) {
|
|
11057
12824
|
const agent = this.agents.get(agentName);
|
|
11058
12825
|
if (!agent || agent.getContextManager().getMessageCount() > 0)
|
|
11059
12826
|
continue;
|
|
11060
12827
|
const heldWrites = this.deferredMessages.filter((msg) => (msg.forAgent ?? this.primaryAgentName) === agentName).length;
|
|
11061
12828
|
let cleanupFailed;
|
|
11062
12829
|
try {
|
|
11063
|
-
if (this.conversationRouter
|
|
11064
|
-
this.conversationRouter.unbind(
|
|
12830
|
+
if (home && this.conversationRouter && this.conversationRouter.getBinding(home)?.agentName === agentName) {
|
|
12831
|
+
this.conversationRouter.unbind(home);
|
|
11065
12832
|
}
|
|
11066
|
-
this.disposeConversationAgent(agentName);
|
|
12833
|
+
this.disposeConversationAgent(agentName, 'context-emptied');
|
|
11067
12834
|
}
|
|
11068
12835
|
catch (error) {
|
|
11069
12836
|
cleanupFailed = failureDescription(error);
|
|
@@ -11918,7 +13685,12 @@ export class AgentFramework {
|
|
|
11918
13685
|
}
|
|
11919
13686
|
}
|
|
11920
13687
|
async createAgent(config) {
|
|
11921
|
-
|
|
13688
|
+
// The residents' view (RESIDENT_VIEW_FILTER: held rows out, read by
|
|
13689
|
+
// heldFromResidents) composed with this agent's own: another agent's
|
|
13690
|
+
// stored context injections out. Main's boundary and arrival checks read
|
|
13691
|
+
// the same predicate (storeSees, agentSees), so they and the compiled
|
|
13692
|
+
// view agree.
|
|
13693
|
+
const sees = (metadata) => !heldFromResidents(metadata)
|
|
11922
13694
|
&& !isAnotherAgentsInjection(metadata, config.name);
|
|
11923
13695
|
// Create context manager for this agent
|
|
11924
13696
|
const contextManager = await ContextManager.open({
|
|
@@ -11939,7 +13711,8 @@ export class AgentFramework {
|
|
|
11939
13711
|
// fetch_history, and audit.
|
|
11940
13712
|
// Another agent's stored context injections (stored-injections.ts) are
|
|
11941
13713
|
// that agent's context, not conversation: they stay out of this view,
|
|
11942
|
-
// and so out of its folds, as the residents share this slot.
|
|
13714
|
+
// and so out of its folds, as the residents share this slot. The
|
|
13715
|
+
// resident filter keeps held (tuned-out) rows out as before (`sees`).
|
|
11943
13716
|
viewFilter: (message) => sees(message.metadata),
|
|
11944
13717
|
});
|
|
11945
13718
|
this.trackManager(contextManager);
|
|
@@ -11948,6 +13721,8 @@ export class AgentFramework {
|
|
|
11948
13721
|
this.adoptToolResultGuard(agent);
|
|
11949
13722
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
11950
13723
|
this.sharedSlotAgents.add(agent);
|
|
13724
|
+
// The first resident declared owns default delivery.
|
|
13725
|
+
this.announceAgentCreated(this.registry.register({ ...residentSpec(config.name, { primary: !this.primaryAgentName }), model: config.model }));
|
|
11951
13726
|
let restoredSettings;
|
|
11952
13727
|
try {
|
|
11953
13728
|
restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
@@ -12050,6 +13825,10 @@ export class AgentFramework {
|
|
|
12050
13825
|
this.adoptToolResultGuard(agent);
|
|
12051
13826
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
12052
13827
|
this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
|
|
13828
|
+
// An attention tenant of the resident it serves: reads the shared slot
|
|
13829
|
+
// through its merged view (held traffic included), never a broadcast
|
|
13830
|
+
// recipient, and outlives policy epochs.
|
|
13831
|
+
this.announceAgentCreated(this.registry.register({ ...subconsciousSpec(name, primaryName), model: agentConfig.model }));
|
|
12053
13832
|
this.agents.set(name, agent);
|
|
12054
13833
|
this.agentConfigs.set(name, agentConfig);
|
|
12055
13834
|
this.subconsciousAgentName = name;
|
|
@@ -12057,6 +13836,188 @@ export class AgentFramework {
|
|
|
12057
13836
|
this.subconsciousConfig = cfg;
|
|
12058
13837
|
return agent;
|
|
12059
13838
|
}
|
|
13839
|
+
/**
|
|
13840
|
+
* `subconscious.reader: 'forks'`: no persistent agent. The coordinator
|
|
13841
|
+
* asks for a fork per invocation (see invokeReaderFork); the config is
|
|
13842
|
+
* kept for the voice block, the speech switch and the strategy factory.
|
|
13843
|
+
*/
|
|
13844
|
+
configureReaderForks(cfg) {
|
|
13845
|
+
const primaryName = this.primaryAgentName;
|
|
13846
|
+
const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
|
|
13847
|
+
if (!primaryName || !primaryConfig) {
|
|
13848
|
+
throw new Error('subconscious requires a primary agent to attend to');
|
|
13849
|
+
}
|
|
13850
|
+
const strategy = primaryConfig.strategy;
|
|
13851
|
+
if (!cfg.strategyFactory && strategy && !(strategy instanceof PassthroughStrategy)) {
|
|
13852
|
+
throw new Error(`subconscious.reader "forks": the primary agent "${primaryName}" runs a ${strategy.name} strategy, ` +
|
|
13853
|
+
'so a reader fork needs `subconscious.strategyFactory` returning a fresh instance of the same ' +
|
|
13854
|
+
'class and configuration to reuse its fold state and rendering');
|
|
13855
|
+
}
|
|
13856
|
+
// Whose weights read the resident's prefix is never defaulted. The
|
|
13857
|
+
// resident's own model is the copy; another is allowed when stated, so
|
|
13858
|
+
// the resident's consent can name it (architecture channel, 10-09).
|
|
13859
|
+
if (typeof cfg.model !== 'string' || cfg.model.length === 0) {
|
|
13860
|
+
throw new Error(`subconscious.reader "forks": a reader fork holds the whole prefix of "${primaryName}", so \`model\` ` +
|
|
13861
|
+
`must say whose weights run it — "${primaryConfig.model}" for a copy of the resident, or another model ` +
|
|
13862
|
+
'by name. It is not defaulted');
|
|
13863
|
+
}
|
|
13864
|
+
if (typeof cfg.voice !== 'string' || cfg.voice.length === 0) {
|
|
13865
|
+
throw new Error('subconscious.reader "forks": `voice` (the reader\'s framing block) is required');
|
|
13866
|
+
}
|
|
13867
|
+
this.readerForks = true;
|
|
13868
|
+
this.subconsciousConfig = cfg;
|
|
13869
|
+
}
|
|
13870
|
+
/**
|
|
13871
|
+
* One reader invocation: derive a fork of the resident at its head with
|
|
13872
|
+
* the resident's refusals, hand it the held traffic since the last look
|
|
13873
|
+
* as ordinary framing, and run it. It reports through `deliver_summary`
|
|
13874
|
+
* (attributed mail), may cancel the hold, and ends when its turn ends.
|
|
13875
|
+
*/
|
|
13876
|
+
invokeReaderFork(invocation) {
|
|
13877
|
+
const primary = this.primaryAgentName;
|
|
13878
|
+
const cfg = this.subconsciousConfig;
|
|
13879
|
+
if (!primary || cfg?.reader !== 'forks' || !this.agents.has(primary))
|
|
13880
|
+
return;
|
|
13881
|
+
const name = `reader/${primary}/${invocation.epochId.slice(0, 8)}/${++this.readerForkCounter}`;
|
|
13882
|
+
void (async () => {
|
|
13883
|
+
try {
|
|
13884
|
+
// The queued messages are rows of the backlog a framing reads, after
|
|
13885
|
+
// the stored ones, so any framing that shows `backlog` shows them.
|
|
13886
|
+
const backlog = [...invocation.backlog, ...this.queuedReaderRows(primary, invocation.queued)];
|
|
13887
|
+
const content = (cfg.framing ?? defaultReaderFraming)({
|
|
13888
|
+
resident: primary,
|
|
13889
|
+
fork: name,
|
|
13890
|
+
voice: cfg.voice,
|
|
13891
|
+
channelId: invocation.channelId,
|
|
13892
|
+
epochId: invocation.epochId,
|
|
13893
|
+
trigger: invocation.trigger,
|
|
13894
|
+
notice: invocation.notice,
|
|
13895
|
+
dispositions: invocation.dispositions,
|
|
13896
|
+
backlog,
|
|
13897
|
+
queuedCount: invocation.queued.length,
|
|
13898
|
+
});
|
|
13899
|
+
const fork = await this.deriveAgent({
|
|
13900
|
+
name,
|
|
13901
|
+
from: primary,
|
|
13902
|
+
kind: 'subconscious-fork',
|
|
13903
|
+
...(cfg.strategyFactory ? { strategy: cfg.strategyFactory() } : {}),
|
|
13904
|
+
// Whose weights, as the configuration states it; its report is a
|
|
13905
|
+
// tool call, so bare prose never routes anywhere.
|
|
13906
|
+
config: {
|
|
13907
|
+
model: cfg.model,
|
|
13908
|
+
proseRouting: 'disabled',
|
|
13909
|
+
},
|
|
13910
|
+
inheritRefusals: true,
|
|
13911
|
+
onParentEnd: 'end',
|
|
13912
|
+
resultTo: { to: primary, as: 'message' },
|
|
13913
|
+
idleTimeoutMs: cfg.forkIdleTimeoutMs ?? 10 * 60_000,
|
|
13914
|
+
framing: [{
|
|
13915
|
+
participant: 'user',
|
|
13916
|
+
content,
|
|
13917
|
+
metadata: {
|
|
13918
|
+
system: true,
|
|
13919
|
+
kind: 'tune-out-reader-framing',
|
|
13920
|
+
channelId: invocation.channelId,
|
|
13921
|
+
epochId: invocation.epochId,
|
|
13922
|
+
trigger: invocation.trigger,
|
|
13923
|
+
heldMessageIds: backlog.map((m) => m.id),
|
|
13924
|
+
},
|
|
13925
|
+
}],
|
|
13926
|
+
metadata: { epochId: invocation.epochId, channelId: invocation.channelId, trigger: invocation.trigger },
|
|
13927
|
+
});
|
|
13928
|
+
this.emitTrace({
|
|
13929
|
+
type: 'tune-out:reader-fork',
|
|
13930
|
+
agentName: name,
|
|
13931
|
+
serverId: invocation.serverId,
|
|
13932
|
+
channelId: invocation.channelId,
|
|
13933
|
+
epochId: invocation.epochId,
|
|
13934
|
+
trigger: invocation.trigger,
|
|
13935
|
+
held: invocation.backlog.length + invocation.queued.length,
|
|
13936
|
+
queued: invocation.queued.length,
|
|
13937
|
+
});
|
|
13938
|
+
const outcome = await this.runEphemeralToCompletion(fork.agent, fork.contextManager);
|
|
13939
|
+
// The report is the fork's whole job. A reader that ends in prose
|
|
13940
|
+
// without `deliver_summary` has still reported — its final prose is
|
|
13941
|
+
// delivered as its report, under its own name, instead of vanishing
|
|
13942
|
+
// into proseRouting=disabled (first live run, 2026-10-12, finding A).
|
|
13943
|
+
const prose = outcome.speech.trim();
|
|
13944
|
+
if (prose && !this.tuneOutCoordinator?.readerReported(name)) {
|
|
13945
|
+
this.deliverFromReader(name, [{ type: 'text', text: prose }], {
|
|
13946
|
+
kind: 'subconscious-summary',
|
|
13947
|
+
channelId: invocation.channelId,
|
|
13948
|
+
epochId: invocation.epochId,
|
|
13949
|
+
report: 'prose',
|
|
13950
|
+
});
|
|
13951
|
+
this.emitTrace({
|
|
13952
|
+
type: 'tune-out:reader-prose-delivered',
|
|
13953
|
+
agentName: name,
|
|
13954
|
+
serverId: invocation.serverId,
|
|
13955
|
+
channelId: invocation.channelId,
|
|
13956
|
+
epochId: invocation.epochId,
|
|
13957
|
+
chars: prose.length,
|
|
13958
|
+
});
|
|
13959
|
+
}
|
|
13960
|
+
}
|
|
13961
|
+
catch (error) {
|
|
13962
|
+
console.error(`[tune-out] reader fork ${name} failed:`, error instanceof Error ? error.message : error);
|
|
13963
|
+
// A fork that failed before it reported never read what it was
|
|
13964
|
+
// handed: those messages go back to the channel's backlog, so the
|
|
13965
|
+
// next reader for it is handed them instead of no reader ever being.
|
|
13966
|
+
const reported = this.tuneOutCoordinator?.readerReported(name) ?? false;
|
|
13967
|
+
const returned = reported ? 0 : (this.tuneOutCoordinator?.readerFailed(invocation) ?? 0);
|
|
13968
|
+
this.emitTrace({
|
|
13969
|
+
type: 'tune-out:reader-fork-failed',
|
|
13970
|
+
agentName: name,
|
|
13971
|
+
serverId: invocation.serverId,
|
|
13972
|
+
channelId: invocation.channelId,
|
|
13973
|
+
epochId: invocation.epochId,
|
|
13974
|
+
trigger: invocation.trigger,
|
|
13975
|
+
error: error instanceof Error ? error.message : String(error),
|
|
13976
|
+
reported,
|
|
13977
|
+
returned,
|
|
13978
|
+
});
|
|
13979
|
+
}
|
|
13980
|
+
finally {
|
|
13981
|
+
this.tuneOutCoordinator?.forgetReader(name);
|
|
13982
|
+
}
|
|
13983
|
+
})();
|
|
13984
|
+
}
|
|
13985
|
+
/**
|
|
13986
|
+
* Held messages still queued behind the resident's turn, as rows of a
|
|
13987
|
+
* reader fork's backlog: each named by its held handle, as module rows name
|
|
13988
|
+
* it until it lands (heldRows), at a provisional sequence past the
|
|
13989
|
+
* resident's last stored message, and timed when it was first held.
|
|
13990
|
+
*/
|
|
13991
|
+
queuedReaderRows(primary, queued) {
|
|
13992
|
+
if (queued.length === 0)
|
|
13993
|
+
return [];
|
|
13994
|
+
const cm = this.agents.get(primary).getContextManager();
|
|
13995
|
+
const count = cm.getMessageCount();
|
|
13996
|
+
const last = count > 0 ? cm.getMessageWindow(count - 1, 1, { resolveBlobs: false }).messages[0]?.sequence ?? 0 : 0;
|
|
13997
|
+
return queued.map((m, i) => ({
|
|
13998
|
+
id: heldHandle(m.writeId),
|
|
13999
|
+
sequence: last + 1 + i,
|
|
14000
|
+
participant: m.participant,
|
|
14001
|
+
content: m.content,
|
|
14002
|
+
metadata: m.metadata,
|
|
14003
|
+
timestamp: new Date(m.heldAt ?? Date.now()),
|
|
14004
|
+
}));
|
|
14005
|
+
}
|
|
14006
|
+
/** A reader fork's own words to the resident: attributed mail when it has a route, else under its name. */
|
|
14007
|
+
deliverFromReader(reader, content, metadata) {
|
|
14008
|
+
const route = this.registry.inspect(reader)?.relationships.resultTo;
|
|
14009
|
+
if (route?.as === 'message') {
|
|
14010
|
+
const mail = this.registry.holdMail({
|
|
14011
|
+
kind: 'result',
|
|
14012
|
+
from: this.registry.ref(reader),
|
|
14013
|
+
to: route.to,
|
|
14014
|
+
content,
|
|
14015
|
+
});
|
|
14016
|
+
this.attemptMailDelivery(mail);
|
|
14017
|
+
return;
|
|
14018
|
+
}
|
|
14019
|
+
this.addMessage(reader, content, metadata);
|
|
14020
|
+
}
|
|
12060
14021
|
async runLoop() {
|
|
12061
14022
|
// A loop ends with its lifecycle. A stop() whose budget ran out while
|
|
12062
14023
|
// this loop was in a call (a module's onProcess) returns before the call
|
|
@@ -12344,7 +14305,7 @@ export class AgentFramework {
|
|
|
12344
14305
|
// stand between a tool's call and its results, so this clock
|
|
12345
14306
|
// reads the time it's written (clock-rows.ts).
|
|
12346
14307
|
const clock = this.storeDueClock(agent, 'tool-boundary');
|
|
12347
|
-
const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent
|
|
14308
|
+
const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent));
|
|
12348
14309
|
// Stored in queue order, cut at the first that can't be stored: it
|
|
12349
14310
|
// and what follows wait for the turn's end, and only what was
|
|
12350
14311
|
// stored is injected (the round's results go on either way). One
|
|
@@ -12356,10 +14317,17 @@ export class AgentFramework {
|
|
|
12356
14317
|
// counts as stored, as what landed of it (writeDrainedInOrder).
|
|
12357
14318
|
const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
|
|
12358
14319
|
for (const [msg, { id, content }] of stored) {
|
|
14320
|
+
this.releaseLandedMail(msg.metadata, agent.name);
|
|
12359
14321
|
this.noteSpokenTo(agent.name, msg.metadata);
|
|
12360
14322
|
// Every other resident reads what this boundary stores; for its
|
|
12361
14323
|
// own agent, the boundary weighs what it injects below.
|
|
12362
14324
|
this.noteForOtherResidents(agent, msg.metadata, id);
|
|
14325
|
+
// A message the agent's own view leaves out is stored and not
|
|
14326
|
+
// injected: a held message (the reader has it; admission's
|
|
14327
|
+
// predicate, heldFromResidents, is in every resident's view
|
|
14328
|
+
// filter) or another agent's stored context injection. Injecting
|
|
14329
|
+
// it here would put in the live turn what the next compile
|
|
14330
|
+
// hides (#232 gap 5; main's #853 reads the view itself).
|
|
12363
14331
|
if (!this.agentSees(agent, msg.metadata))
|
|
12364
14332
|
continue;
|
|
12365
14333
|
heard.push({ msg, id, content });
|
|
@@ -12481,6 +14449,38 @@ export class AgentFramework {
|
|
|
12481
14449
|
+ agent.maxTokens
|
|
12482
14450
|
: 0;
|
|
12483
14451
|
const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
|
|
14452
|
+
// Under `speech.locus: 'delivery'` incoming traffic never moves the
|
|
14453
|
+
// pin — so when someone addresses the agent mid-turn from another
|
|
14454
|
+
// channel, the agent must be told who and from where, or a neutral
|
|
14455
|
+
// message reads as part of the conversation it landed in and gets
|
|
14456
|
+
// answered there (first live run, 2026-10-12, finding C). One
|
|
14457
|
+
// notice per channel per turn; it rides the same injection batch.
|
|
14458
|
+
// The turn's own mode: a silent turn routes no prose, whatever the
|
|
14459
|
+
// agent's mode, so its boundary neither notices nor re-pins. The
|
|
14460
|
+
// trigger is this stream's: its start holds the agent's turn token
|
|
14461
|
+
// while the agent waits on this round's tools.
|
|
14462
|
+
const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
|
|
14463
|
+
&& !this.activeTurnTriggers.get(agent.name)?.suppressProse;
|
|
14464
|
+
if (this.speechLocus === 'delivery' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
|
|
14465
|
+
const routing = this.routingOf(agent.name);
|
|
14466
|
+
for (const inj of [...midTurnInjections]) {
|
|
14467
|
+
const m = inj.metadata;
|
|
14468
|
+
if (!isConversationalInjection(inj.metadata))
|
|
14469
|
+
continue;
|
|
14470
|
+
if (typeof m?.channelId !== 'string')
|
|
14471
|
+
continue;
|
|
14472
|
+
if (!isAddressedMessage(m.tags))
|
|
14473
|
+
continue;
|
|
14474
|
+
// Its channel as a re-pin reads it (repinChannel): on the
|
|
14475
|
+
// message's own server.
|
|
14476
|
+
const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined);
|
|
14477
|
+
if (!channel)
|
|
14478
|
+
continue;
|
|
14479
|
+
const notice = this.addressedElsewhereNotice(agent, routing, channel, authorName(m));
|
|
14480
|
+
if (notice)
|
|
14481
|
+
midTurnInjections.push(notice);
|
|
14482
|
+
}
|
|
14483
|
+
}
|
|
12484
14484
|
// Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
|
|
12485
14485
|
// when someone ADDRESSES the agent mid-turn from another channel
|
|
12486
14486
|
// (mention / reply / DM — the same chat:addressed signal the
|
|
@@ -12511,9 +14511,7 @@ export class AgentFramework {
|
|
|
12511
14511
|
// agent's mode, so its boundary neither re-pins nor notes. The
|
|
12512
14512
|
// trigger is this stream's: its start holds the agent's turn token
|
|
12513
14513
|
// while the agent waits on this round's tools.
|
|
12514
|
-
|
|
12515
|
-
&& !this.activeTurnTriggers.get(agent.name)?.suppressProse;
|
|
12516
|
-
if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
|
|
14514
|
+
if (this.speechLocus === 'latest-inbound' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
|
|
12517
14515
|
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
12518
14516
|
// - chat:addressed — someone explicitly spoke TO the agent
|
|
12519
14517
|
// (mention / reply / DM);
|
|
@@ -12893,10 +14891,11 @@ export class AgentFramework {
|
|
|
12893
14891
|
if (!decision.trigger)
|
|
12894
14892
|
return;
|
|
12895
14893
|
}
|
|
12896
|
-
//
|
|
12897
|
-
//
|
|
14894
|
+
// A broadcast request reaches the agents whose role says they take
|
|
14895
|
+
// untargeted inbound; channel-bound and bounded-job agents are driven
|
|
14896
|
+
// by their own channel or task, not by framework-wide events.
|
|
12898
14897
|
const targetAgents = response.requestInference === true
|
|
12899
|
-
?
|
|
14898
|
+
? this.registry.untargetedRecipients()
|
|
12900
14899
|
: response.requestInference;
|
|
12901
14900
|
for (const agentName of targetAgents) {
|
|
12902
14901
|
const agent = this.agents.get(agentName);
|
|
@@ -12942,6 +14941,24 @@ export class AgentFramework {
|
|
|
12942
14941
|
* Convert an incoming MCPL channel message to a context message.
|
|
12943
14942
|
* This replaces the old MCPLModule.onProcess() message conversion.
|
|
12944
14943
|
*/
|
|
14944
|
+
/**
|
|
14945
|
+
* A delivered inbound message becomes the latest delivered inbound (the
|
|
14946
|
+
* channel context's reply anchor; the legacy speech fallback) through the
|
|
14947
|
+
* registry's noteDelivered — at once for a plain channels/incoming, whose
|
|
14948
|
+
* acceptance the registry answers synchronously; parked for a coalesced one
|
|
14949
|
+
* until its acceptance is acknowledged (see parkedAnchors).
|
|
14950
|
+
*/
|
|
14951
|
+
noteDeliveredOrPark(event) {
|
|
14952
|
+
// On the server the message came from (noteDelivered).
|
|
14953
|
+
const anchor = { channel: { serverId: event.serverId, channelId: event.channelId }, messageId: event.messageId, threadId: event.threadId, tags: event.tags, firstView: event.firstView === true };
|
|
14954
|
+
if (event.coalescingSubject && typeof event.eventId === 'string') {
|
|
14955
|
+
this.parkedAnchors.set(`${event.serverId}:${event.eventId}`, anchor);
|
|
14956
|
+
if (this.parkedAnchors.size > 1000)
|
|
14957
|
+
this.parkedAnchors.delete(this.parkedAnchors.keys().next().value);
|
|
14958
|
+
return;
|
|
14959
|
+
}
|
|
14960
|
+
this.channelRegistry?.noteDelivered(anchor.channel, anchor.messageId, anchor.threadId, anchor.tags, anchor.firstView);
|
|
14961
|
+
}
|
|
12945
14962
|
async handleMcplChannelIncoming(event) {
|
|
12946
14963
|
// context-manager won't store content that doesn't match ContentBlock
|
|
12947
14964
|
// (malformedContentPlaces names where). Content from the wire can't be
|
|
@@ -13007,6 +15024,49 @@ export class AgentFramework {
|
|
|
13007
15024
|
// before any host decoration. The stored copy's own digest is taken at
|
|
13008
15025
|
// each storage site below, over exactly the blocks stored there.
|
|
13009
15026
|
metadata.sourceBodyDigest = sourceBodyDigest(event.content);
|
|
15027
|
+
// Stored as the model reads it: `author: body`. The wire formatter
|
|
15028
|
+
// writes the participant, and channel traffic is stored under the
|
|
15029
|
+
// anonymous `user`, so a body stored bare reaches the model as
|
|
15030
|
+
// `user: <text>` with the author nowhere (Weft's rig, 2026-10-13). A
|
|
15031
|
+
// server that renders the name into the text itself is left as is.
|
|
15032
|
+
const content = presentAuthor({ participant: 'user', content: event.content, metadata }).content;
|
|
15033
|
+
// Admission (Dendrite §D, #232 root cause A): decided once, here, before
|
|
15034
|
+
// anything downstream acts — the hold, the wake gate, who may be woken,
|
|
15035
|
+
// whether this counts as the latest delivered inbound. Every path below
|
|
15036
|
+
// consumes `decision`; none re-asks the policy.
|
|
15037
|
+
const addressed = isAddressedMessage(event.tags);
|
|
15038
|
+
const decision = this.admitInbound({
|
|
15039
|
+
lane: 'channel',
|
|
15040
|
+
serverId: event.serverId,
|
|
15041
|
+
channelId: event.channelId,
|
|
15042
|
+
messageId: event.messageId,
|
|
15043
|
+
authorId: event.author?.id,
|
|
15044
|
+
tags: event.tags,
|
|
15045
|
+
addressed,
|
|
15046
|
+
vetoed: event.triggerInference === false,
|
|
15047
|
+
targetAgents: event.targetAgents,
|
|
15048
|
+
gateContent: event.content
|
|
15049
|
+
.filter((b) => b.type === 'text')
|
|
15050
|
+
.map((b) => b.text)
|
|
15051
|
+
.join('\n'),
|
|
15052
|
+
gateMetadata: {
|
|
15053
|
+
...event.metadata,
|
|
15054
|
+
eventType: 'mcpl:channel-incoming',
|
|
15055
|
+
serverId: event.serverId,
|
|
15056
|
+
channelId: event.channelId,
|
|
15057
|
+
messageId: event.messageId,
|
|
15058
|
+
threadId: event.threadId,
|
|
15059
|
+
author: event.author,
|
|
15060
|
+
...(event.tags ? { tags: event.tags } : {}),
|
|
15061
|
+
},
|
|
15062
|
+
});
|
|
15063
|
+
metadata.triggered = decision.wake;
|
|
15064
|
+
if (decision.held) {
|
|
15065
|
+
// The admission record rides the message; the hold's own bookkeeping
|
|
15066
|
+
// (cadence tally, suppressed-mention ack, reader wake) follows.
|
|
15067
|
+
metadata.tuneOut = { epochId: decision.held.epochId };
|
|
15068
|
+
this.tuneOutCoordinator?.recordHeld(event.serverId, event.channelId, event.messageId, event.tags, event.author?.id, decision.gatePass);
|
|
15069
|
+
}
|
|
13010
15070
|
// Per-channel conversation routing: messages go to the channel's fork
|
|
13011
15071
|
// agent (spawned from the template on first qualifying message), never
|
|
13012
15072
|
// to the primary conversation.
|
|
@@ -13017,14 +15077,20 @@ export class AgentFramework {
|
|
|
13017
15077
|
const target = this.agents.get(event.deliverTo);
|
|
13018
15078
|
if (!target)
|
|
13019
15079
|
return undefined;
|
|
13020
|
-
if (this.conversationRouter && this.
|
|
15080
|
+
if (this.conversationRouter && this.registry.homeChannel(target.name) !== undefined) {
|
|
13021
15081
|
metadata.triggered = event.triggerInference ?? false;
|
|
13022
15082
|
// A fork's channel messages are stored past its deferral; a clock goes
|
|
13023
15083
|
// ahead of one only while the fork has no turn alive (storeDueClock).
|
|
13024
15084
|
if (!this.turnAlive(target) && takesClockBefore('user', event.content, metadata, target.name)) {
|
|
13025
15085
|
this.storeDueClock(target, 'event');
|
|
13026
15086
|
}
|
|
13027
|
-
|
|
15087
|
+
// The stored copy carries the author as the fork reads them (presentAuthor), and its digest is of that copy.
|
|
15088
|
+
const placement = this.writeToFork(target, content, AgentFramework.withStoredDigest(metadata, content));
|
|
15089
|
+
// Delivered to the fork, so it is the latest delivered inbound too
|
|
15090
|
+
// (the channel context's `incoming`; the fallback locus under the
|
|
15091
|
+
// legacy rule), as a resident's delivery is below.
|
|
15092
|
+
if (decision.locus)
|
|
15093
|
+
this.noteDeliveredOrPark(event);
|
|
13028
15094
|
// A correction may target an older engagement. Refresh only the
|
|
13029
15095
|
// binding whose agent actually received the message.
|
|
13030
15096
|
const channel = { serverId: event.serverId, channelId: event.channelId };
|
|
@@ -13032,12 +15098,12 @@ export class AgentFramework {
|
|
|
13032
15098
|
this.conversationRouter.touch(channel);
|
|
13033
15099
|
}
|
|
13034
15100
|
this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' });
|
|
13035
|
-
if (
|
|
15101
|
+
if (decision.wake) {
|
|
13036
15102
|
this.pendingRequests.push({
|
|
13037
15103
|
agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
|
|
13038
15104
|
channelId: event.channelId, channelServerId: event.serverId,
|
|
13039
15105
|
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
13040
|
-
addressed
|
|
15106
|
+
addressed,
|
|
13041
15107
|
...systemNoticeMark(event.tags),
|
|
13042
15108
|
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
13043
15109
|
});
|
|
@@ -13046,14 +15112,14 @@ export class AgentFramework {
|
|
|
13046
15112
|
}
|
|
13047
15113
|
if (this.conversationRouter)
|
|
13048
15114
|
return undefined; // the trunk never holds channel traffic
|
|
13049
|
-
|
|
15115
|
+
decision.recipients = [target.name];
|
|
13050
15116
|
}
|
|
13051
15117
|
else if (this.conversationRouter) {
|
|
13052
|
-
return this.routeConversationIncoming(event, metadata);
|
|
15118
|
+
return this.routeConversationIncoming({ ...event, content }, metadata, decision);
|
|
13053
15119
|
}
|
|
13054
15120
|
// Addressed-while-closed invitation — parity with the push-event path
|
|
13055
15121
|
// (channels.publish surfaces like portal-mcpl deliver mentions here).
|
|
13056
|
-
const incomingContent = [...
|
|
15122
|
+
const incomingContent = [...content];
|
|
13057
15123
|
{
|
|
13058
15124
|
const invitation = this.buildClosedChannelInvitation({
|
|
13059
15125
|
serverId: event.serverId,
|
|
@@ -13070,37 +15136,28 @@ export class AgentFramework {
|
|
|
13070
15136
|
missedCharacters: typeof event.metadata?.missedCharacters === 'number'
|
|
13071
15137
|
? event.metadata.missedCharacters
|
|
13072
15138
|
: undefined,
|
|
13073
|
-
|
|
15139
|
+
// A correction's one agent (deliverTo) is the agent whose window the
|
|
15140
|
+
// invitation goes into, so its mode is the one read (main's #852):
|
|
15141
|
+
// admission recorded it as the recipients, where main's code
|
|
15142
|
+
// rewrites the event's targetAgents.
|
|
15143
|
+
targetAgents: event.deliverTo ? decision.recipients : event.targetAgents,
|
|
13074
15144
|
});
|
|
13075
15145
|
if (invitation) {
|
|
13076
15146
|
incomingContent.push(invitation.block);
|
|
13077
15147
|
Object.assign(metadata, invitation.metadataPatch);
|
|
13078
15148
|
}
|
|
13079
15149
|
}
|
|
13080
|
-
//
|
|
13081
|
-
//
|
|
13082
|
-
//
|
|
13083
|
-
//
|
|
13084
|
-
const divert = this.tuneOutCoordinator?.onIncoming(event.serverId, event.channelId, event.messageId, event.tags, event.author?.id,
|
|
13085
|
-
// The resident's gate verdict rides the event; it preconditions
|
|
13086
|
-
// subconscious wakes (gate composes, it is not replaced).
|
|
13087
|
-
event.triggerInference !== false) ?? null;
|
|
13088
|
-
if (divert) {
|
|
13089
|
-
metadata.tuneOut = { epochId: divert.epochId };
|
|
13090
|
-
}
|
|
15150
|
+
// Captured where it arrived: a held message is stored with its admission
|
|
15151
|
+
// record and never enters a resident's view; a delivered one is also
|
|
15152
|
+
// the latest delivered inbound (the channel context's `incoming`, and
|
|
15153
|
+
// the fallback locus under the legacy rule) — a held one never is.
|
|
13091
15154
|
const placement = { agent: '' };
|
|
13092
15155
|
const id = this.addMessage('user', incomingContent, AgentFramework.withStoredDigest(metadata, incomingContent), { placement, bypassDeferralFor: event.assemblingFor });
|
|
13093
15156
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
13094
|
-
if (
|
|
13095
|
-
|
|
13096
|
-
|
|
13097
|
-
|
|
13098
|
-
// keeps the historical broadcast. (targetAgents was declared on
|
|
13099
|
-
// McplChannelIncomingEvent from the start but never honored here —
|
|
13100
|
-
// tune-out's wake routing is the first setter.)
|
|
13101
|
-
const targetAgents = event.targetAgents
|
|
13102
|
-
?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
|
|
13103
|
-
for (const agentName of targetAgents) {
|
|
15157
|
+
if (decision.locus)
|
|
15158
|
+
this.noteDeliveredOrPark(event);
|
|
15159
|
+
if (decision.wake) {
|
|
15160
|
+
for (const agentName of decision.recipients) {
|
|
13104
15161
|
if (!this.agents.has(agentName))
|
|
13105
15162
|
continue;
|
|
13106
15163
|
this.pendingRequests.push({
|
|
@@ -13131,7 +15188,7 @@ export class AgentFramework {
|
|
|
13131
15188
|
* agent first when the bind policy matches. Unrouted messages are dropped —
|
|
13132
15189
|
* the template ("trunk") is a dormant warm checkpoint, not a listener.
|
|
13133
15190
|
*/
|
|
13134
|
-
async routeConversationIncoming(event, messageMetadata) {
|
|
15191
|
+
async routeConversationIncoming(event, messageMetadata, admission) {
|
|
13135
15192
|
const router = this.conversationRouter;
|
|
13136
15193
|
const channel = { serverId: event.serverId, channelId: event.channelId };
|
|
13137
15194
|
const descriptor = this.channelRegistry?.getDescriptor(channel);
|
|
@@ -13191,15 +15248,20 @@ export class AgentFramework {
|
|
|
13191
15248
|
return;
|
|
13192
15249
|
}
|
|
13193
15250
|
}
|
|
13194
|
-
//
|
|
13195
|
-
//
|
|
13196
|
-
const trigger = decision.trigger &&
|
|
15251
|
+
// The admission decision (server veto, wake gate) composes with the
|
|
15252
|
+
// router's own trigger policy.
|
|
15253
|
+
const trigger = decision.trigger && admission.wake;
|
|
13197
15254
|
messageMetadata.triggered = trigger;
|
|
13198
15255
|
// As for a correction above: a clock ahead only while no turn is alive.
|
|
13199
15256
|
if (!this.turnAlive(agent) && takesClockBefore('user', event.content, messageMetadata, agent.name)) {
|
|
13200
15257
|
this.storeDueClock(agent, 'event');
|
|
13201
15258
|
}
|
|
13202
15259
|
const placement = this.writeToFork(agent, event.content, AgentFramework.withStoredDigest(messageMetadata, event.content));
|
|
15260
|
+
// Delivered to the channel's fork: the latest delivered inbound, as for a
|
|
15261
|
+
// resident (the channel context's `incoming`; the fallback locus under
|
|
15262
|
+
// the legacy rule). A held or dropped message never is.
|
|
15263
|
+
if (admission.locus)
|
|
15264
|
+
this.noteDeliveredOrPark(event);
|
|
13203
15265
|
router.touch(channel);
|
|
13204
15266
|
this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' });
|
|
13205
15267
|
if (trigger) {
|
|
@@ -13212,7 +15274,7 @@ export class AgentFramework {
|
|
|
13212
15274
|
// triggering channel too so the trunk/active path stays consistent.
|
|
13213
15275
|
channelId: event.channelId, channelServerId: event.serverId,
|
|
13214
15276
|
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
13215
|
-
addressed: isAddressedMessage(event.tags
|
|
15277
|
+
addressed: isAddressedMessage(event.tags),
|
|
13216
15278
|
...systemNoticeMark(event.tags),
|
|
13217
15279
|
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
13218
15280
|
});
|
|
@@ -13350,10 +15412,17 @@ export class AgentFramework {
|
|
|
13350
15412
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
13351
15413
|
this.adoptToolResultGuard(agent);
|
|
13352
15414
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
15415
|
+
this.announceAgentCreated(this.registry.register({
|
|
15416
|
+
...conversationForkSpec(name, {
|
|
15417
|
+
template: router.templateAgent,
|
|
15418
|
+
homeChannel: home,
|
|
15419
|
+
idleTtlMs: router.idleTtlMs,
|
|
15420
|
+
}),
|
|
15421
|
+
model: config.model,
|
|
15422
|
+
}));
|
|
13353
15423
|
this.managerReleases.set(agent, release);
|
|
13354
15424
|
this.agents.set(name, agent);
|
|
13355
15425
|
this.agentConfigs.set(name, config);
|
|
13356
|
-
this.conversationAgentHomes.set(name, home);
|
|
13357
15426
|
return agent;
|
|
13358
15427
|
}
|
|
13359
15428
|
catch (error) {
|
|
@@ -13496,63 +15565,28 @@ export class AgentFramework {
|
|
|
13496
15565
|
* stop() has ended binds nothing (spawnConversationAgent). The fork's
|
|
13497
15566
|
* context stays in Chronicle for investigation.
|
|
13498
15567
|
*/
|
|
13499
|
-
disposeConversationAgent(agentName) {
|
|
13500
|
-
this.
|
|
13501
|
-
// A continuation of its turn still on its way goes with it.
|
|
13502
|
-
this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
|
|
13503
|
-
const home = this.conversationAgentHomes.get(agentName);
|
|
15568
|
+
disposeConversationAgent(agentName, reason = 'idle-ttl') {
|
|
15569
|
+
const home = this.homeOf(agentName);
|
|
13504
15570
|
const agent = this.agents.get(agentName);
|
|
13505
|
-
//
|
|
13506
|
-
//
|
|
13507
|
-
// activeStreams and pendingAssistantBlocks entries, as a run's end does
|
|
13508
|
-
// (runEphemeralToCompletion), and settles a batch its guard still holds,
|
|
13509
|
-
// withheld as `aborted`: the stream's own finally does neither once the
|
|
13510
|
-
// fork has gone. A fork the idle sweep ends has no stream entries left,
|
|
13511
|
-
// and a batch still pending there is one whose successor never ran,
|
|
13512
|
-
// which a run's end settles too.
|
|
13513
|
-
if (agent?.toolResultGuard.hasPending) {
|
|
13514
|
-
try {
|
|
13515
|
-
agent.toolResultGuard.abandon('aborted');
|
|
13516
|
-
}
|
|
13517
|
-
catch (error) {
|
|
13518
|
-
console.error(`[tool-result-guard] agent=${agentName} failed to record a batch the fork left:`, error);
|
|
13519
|
-
}
|
|
13520
|
-
}
|
|
13521
|
-
this.activeStreams.delete(agentName);
|
|
13522
|
-
this.pendingAssistantBlocks.delete(agentName);
|
|
13523
|
-
this.agents.delete(agentName);
|
|
13524
|
-
this.thinkingLossIncidents.forget(agentName);
|
|
13525
|
-
this.reasoningLoss.forget(agentName);
|
|
13526
|
-
// Its pending self-wake ends with it, rather than wake a name that's gone.
|
|
13527
|
-
this.eventGate?.agentGone(agentName);
|
|
15571
|
+
// The one release every ended agent gets (releaseAgentRuntime), then the
|
|
15572
|
+
// registry's end, whose outcome settles what the fork spawned.
|
|
13528
15573
|
if (agent) {
|
|
13529
|
-
this.
|
|
13530
|
-
|
|
13531
|
-
|
|
13532
|
-
|
|
13533
|
-
|
|
13534
|
-
|
|
13535
|
-
|
|
13536
|
-
|
|
13537
|
-
|
|
13538
|
-
|
|
13539
|
-
|
|
13540
|
-
this.
|
|
13541
|
-
|
|
13542
|
-
|
|
13543
|
-
this.codeExecution?.scripts.agentGone(agentName);
|
|
13544
|
-
// A provider-wait hold (nonOwningProviderWait) goes with the agent: left,
|
|
13545
|
-
// its timer would requeue held wakes for an agent that is gone, and a wait
|
|
13546
|
-
// held until released would re-arm for the name indefinitely. The recorded
|
|
13547
|
-
// wait stays, and binds the name if its channel binds again.
|
|
13548
|
-
const cooldown = this.providerAccelerationCooldowns.get(agentName);
|
|
13549
|
-
if (cooldown) {
|
|
13550
|
-
clearTimeout(cooldown.timer);
|
|
13551
|
-
this.providerAccelerationCooldowns.delete(agentName);
|
|
13552
|
-
console.error(`[provider-wait] agent=${agentName} disposed while held` +
|
|
13553
|
-
`${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` +
|
|
13554
|
-
`${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`);
|
|
15574
|
+
this.releaseAgentRuntime(agent, home, 'its agent was disposed');
|
|
15575
|
+
}
|
|
15576
|
+
else {
|
|
15577
|
+
// Nothing live under the name: only what the process keeps by name.
|
|
15578
|
+
this.closingConversationAgents.delete(agentName);
|
|
15579
|
+
this.thinkingLossIncidents.forget(agentName);
|
|
15580
|
+
this.reasoningLoss.forget(agentName);
|
|
15581
|
+
// A continuation of its turn still on its way goes with it.
|
|
15582
|
+
this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
|
|
15583
|
+
this.turnRouting.delete(agentName);
|
|
15584
|
+
this.proseSpeechChains.delete(agentName);
|
|
15585
|
+
this.openLogical.delete(agentName);
|
|
15586
|
+
this.forgetAnnouncedLocus(agentName);
|
|
15587
|
+
this.codeExecution?.scripts.agentGone(agentName);
|
|
13555
15588
|
}
|
|
15589
|
+
this.settleEndedAgent(this.registry.end(agentName, reason, 'framework', { record: true }));
|
|
13556
15590
|
this.emitTrace({
|
|
13557
15591
|
type: 'mcpl:conversation-disposed',
|
|
13558
15592
|
agentName,
|
|
@@ -13570,7 +15604,7 @@ export class AgentFramework {
|
|
|
13570
15604
|
* its own fork.
|
|
13571
15605
|
*/
|
|
13572
15606
|
scopeInjectionsForAgent(agentName, injections) {
|
|
13573
|
-
const home = this.
|
|
15607
|
+
const home = this.homeOf(agentName);
|
|
13574
15608
|
const registry = this.channelRegistry;
|
|
13575
15609
|
if (!home || !registry || injections.length === 0) {
|
|
13576
15610
|
return injections;
|
|
@@ -13614,7 +15648,7 @@ export class AgentFramework {
|
|
|
13614
15648
|
// Agent vanished (external reset) — nothing to close, just make sure
|
|
13615
15649
|
// its bookkeeping doesn't linger.
|
|
13616
15650
|
this.agentConfigs.delete(binding.agentName);
|
|
13617
|
-
this.
|
|
15651
|
+
this.settleEndedAgent(this.registry.end(binding.agentName, 'idle-ttl', 'framework', { record: true }));
|
|
13618
15652
|
continue;
|
|
13619
15653
|
}
|
|
13620
15654
|
this.writeToFork(agent, [{ type: 'text', text: this.conversationRouter.closurePrompt }], { channelId: binding.channelId, conversationClosure: true });
|
|
@@ -13640,7 +15674,7 @@ export class AgentFramework {
|
|
|
13640
15674
|
if (!this.hasTurn(agentName) &&
|
|
13641
15675
|
!this.activeStreams.has(agentName) &&
|
|
13642
15676
|
!this.pendingRequests.some((r) => r.agentName === agentName)) {
|
|
13643
|
-
this.disposeConversationAgent(agentName);
|
|
15677
|
+
this.disposeConversationAgent(agentName, 'idle-ttl');
|
|
13644
15678
|
}
|
|
13645
15679
|
}
|
|
13646
15680
|
}
|
|
@@ -14111,8 +16145,11 @@ export class AgentFramework {
|
|
|
14111
16145
|
this.coalescingRecentReceipts = restored.recent;
|
|
14112
16146
|
this.pushCoalescer = restored.coalescer;
|
|
14113
16147
|
// Batches restored from a snapshot still owe the agent a wake (§14 v41).
|
|
14114
|
-
for (const occ of restored.coalescer.pendingBatchOccurrences())
|
|
14115
|
-
void this.wakeForCoalescedBatch(occ)
|
|
16148
|
+
for (const occ of restored.coalescer.pendingBatchOccurrences()) {
|
|
16149
|
+
void this.wakeForCoalescedBatch(occ).catch((error) => {
|
|
16150
|
+
console.error(`[coalescing] ${occ.serverId}: the wake a restored batch (${occ.eventId}) owes its agent failed:`, error);
|
|
16151
|
+
});
|
|
16152
|
+
}
|
|
14116
16153
|
}
|
|
14117
16154
|
/**
|
|
14118
16155
|
* Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
|
|
@@ -14225,6 +16262,13 @@ export class AgentFramework {
|
|
|
14225
16262
|
// counts what was stored before a restart as shown: every agent's
|
|
14226
16263
|
// shown watermark starts at the head at boot.
|
|
14227
16264
|
const firstView = this.pushCoalescer.receiptFirstView(serverId, this.coalescingBinding(serverId), message.eventId);
|
|
16265
|
+
// Acknowledged: the anchor the delivery parked is committed now (and a
|
|
16266
|
+
// retry answered from the receipt commits its first attempt's).
|
|
16267
|
+
const parked = this.parkedAnchors.get(`${serverId}:${message.eventId}`);
|
|
16268
|
+
if (parked) {
|
|
16269
|
+
this.parkedAnchors.delete(`${serverId}:${message.eventId}`);
|
|
16270
|
+
this.channelRegistry?.noteDelivered(parked.channel, parked.messageId, parked.threadId, parked.tags, parked.firstView || firstView === true);
|
|
16271
|
+
}
|
|
14228
16272
|
return { messageId: message.messageId, accepted: true, coalesce: result.coalesce, ...(firstView ? { firstView: true } : {}) };
|
|
14229
16273
|
});
|
|
14230
16274
|
}
|
|
@@ -14367,7 +16411,7 @@ export class AgentFramework {
|
|
|
14367
16411
|
return [];
|
|
14368
16412
|
}
|
|
14369
16413
|
}
|
|
14370
|
-
return
|
|
16414
|
+
return this.registry.untargetedRecipients();
|
|
14371
16415
|
}
|
|
14372
16416
|
/** Deliver through the lane's ordinary path; report where it landed. */
|
|
14373
16417
|
async deliverCoalesced(occ, materialized, assemblingFor) {
|
|
@@ -14448,11 +16492,59 @@ export class AgentFramework {
|
|
|
14448
16492
|
const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
14449
16493
|
if (occ.event.lane !== 'push' || !occ.event.event.triggerInference)
|
|
14450
16494
|
return;
|
|
14451
|
-
const
|
|
16495
|
+
const push = occ.event.event;
|
|
16496
|
+
const origin = push.origin;
|
|
14452
16497
|
// The wake carries the batch's channel so the turn freezes its reply
|
|
14453
16498
|
// locus there (the materialized delivery at assembly wakes nobody).
|
|
14454
16499
|
const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
|
|
14455
16500
|
const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
|
|
16501
|
+
// A deferred batch is admitted when it asks for its wake: the hold and
|
|
16502
|
+
// the gate decide now; its materialized delivery at assembly is vetoed
|
|
16503
|
+
// (`triggerInference: false`) and so is not shown to the gate again.
|
|
16504
|
+
const decision = this.admitInbound({
|
|
16505
|
+
lane: 'push',
|
|
16506
|
+
serverId: occ.serverId,
|
|
16507
|
+
channelId,
|
|
16508
|
+
messageId: occ.eventId,
|
|
16509
|
+
authorId,
|
|
16510
|
+
tags: occ.tags,
|
|
16511
|
+
addressed: isAddressedMessage(occ.tags),
|
|
16512
|
+
vetoed: false,
|
|
16513
|
+
targetAgents: push.targetAgents,
|
|
16514
|
+
gateContent: (push.content ?? [])
|
|
16515
|
+
.filter((b) => b.type === 'text')
|
|
16516
|
+
.map((b) => b.text)
|
|
16517
|
+
.join('\n'),
|
|
16518
|
+
gateMetadata: {
|
|
16519
|
+
...(origin ?? {}),
|
|
16520
|
+
serverId: occ.serverId,
|
|
16521
|
+
featureSet: push.featureSet,
|
|
16522
|
+
eventId: occ.eventId,
|
|
16523
|
+
eventType: 'mcpl:push-event',
|
|
16524
|
+
...(occ.tags ? { tags: occ.tags } : {}),
|
|
16525
|
+
},
|
|
16526
|
+
});
|
|
16527
|
+
if (decision.held && !plain && this.pushCoalescer) {
|
|
16528
|
+
// A held batch wakes nobody, so no turn's assembly would store it while
|
|
16529
|
+
// the hold lasts, and readers of held traffic and the release would
|
|
16530
|
+
// never see it. It's stored now, stamped with its hold as a plain push
|
|
16531
|
+
// under the hold is, and consumed, so the turn after the hold doesn't
|
|
16532
|
+
// take it again. Outside the coalescer's lock: this runs inside it.
|
|
16533
|
+
// A hold that ended before the batch was stored left it stored unheld:
|
|
16534
|
+
// it then takes the wake an unheld push gets, every recipient's.
|
|
16535
|
+
const coalescer = this.pushCoalescer;
|
|
16536
|
+
void this.coalescedAudience(occ)
|
|
16537
|
+
.then((audience) => (audience[0] !== undefined ? coalescer.materializeNow(audience[0], subject) : false))
|
|
16538
|
+
.then(async (stored) => {
|
|
16539
|
+
if (stored && !(channelId !== undefined && this.tuneOutCoordinator?.holdFor(occ.serverId, channelId))) {
|
|
16540
|
+
await this.queueCoalescedWakes(occ, true);
|
|
16541
|
+
}
|
|
16542
|
+
})
|
|
16543
|
+
.catch((err) => console.error(`[coalescing] a held batch for ${logValue(subject)} could not be stored:`, err));
|
|
16544
|
+
return;
|
|
16545
|
+
}
|
|
16546
|
+
if (!decision.wake)
|
|
16547
|
+
return;
|
|
14456
16548
|
for (const agentName of await this.coalescedAudience(occ)) {
|
|
14457
16549
|
this.pendingRequests.push({
|
|
14458
16550
|
agentName,
|
|
@@ -14461,7 +16553,7 @@ export class AgentFramework {
|
|
|
14461
16553
|
timestamp: Date.now(),
|
|
14462
16554
|
channelId, ...(channelId !== undefined ? { channelServerId: occ.serverId } : {}),
|
|
14463
16555
|
counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
|
|
14464
|
-
addressed: isAddressedMessage(occ.tags
|
|
16556
|
+
addressed: isAddressedMessage(occ.tags),
|
|
14465
16557
|
// No systemNotice mark here: a render folds the batch's every
|
|
14466
16558
|
// occurrence, and only its latest's tags are at hand, so marking it
|
|
14467
16559
|
// would hide a folded message's channel whenever a notice came last.
|
|
@@ -14562,9 +16654,13 @@ export class AgentFramework {
|
|
|
14562
16654
|
// not the agent that happened to be asked). A reader is one whose view
|
|
14563
16655
|
// filter keeps the message: a tune-out-diverted message never enters a
|
|
14564
16656
|
// resident's compiled view, and only the subconscious reads it through
|
|
14565
|
-
// its merged view — no subconscious, nobody reads it.
|
|
14566
|
-
|
|
14567
|
-
|
|
16657
|
+
// its merged view — no subconscious, nobody reads it. The slot's readers
|
|
16658
|
+
// are the registry's (residents and the subconscious), each asked its own
|
|
16659
|
+
// view (agentSees, main's #853).
|
|
16660
|
+
const readers = (this.registry.readsSharedSlot(agent.name)
|
|
16661
|
+
? this.registry.sharedSlotReaders()
|
|
16662
|
+
.map((name) => this.agents.get(name))
|
|
16663
|
+
.filter((reader) => reader !== undefined)
|
|
14568
16664
|
: [agent]).filter((a) => this.agentSees(a, message.metadata));
|
|
14569
16665
|
const branch = cm.currentBranch().name;
|
|
14570
16666
|
const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
|
|
@@ -14715,6 +16811,37 @@ export class AgentFramework {
|
|
|
14715
16811
|
// marker with an empty payload — arbitrary MCPL servers cannot hide
|
|
14716
16812
|
// content merely by setting `origin.silent`.
|
|
14717
16813
|
const silentHeartbeat = isSilentHeartbeatMarker({ ...event, content });
|
|
16814
|
+
// Admission (Dendrite §D): the same decision as the channel lane. A DM
|
|
16815
|
+
// arrives here, so this is where tuning out a DM takes effect (#232 gap
|
|
16816
|
+
// 1). An event with no channel provenance cannot be held.
|
|
16817
|
+
const decision = this.admitInbound({
|
|
16818
|
+
lane: 'push',
|
|
16819
|
+
serverId: event.serverId,
|
|
16820
|
+
channelId: triggerChannel?.channelId,
|
|
16821
|
+
messageId: event.eventId,
|
|
16822
|
+
authorId: typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined,
|
|
16823
|
+
tags: event.tags,
|
|
16824
|
+
addressed: isAddressedMessage(event.tags),
|
|
16825
|
+
vetoed: event.triggerInference === false,
|
|
16826
|
+
targetAgents: event.targetAgents,
|
|
16827
|
+
gateContent: content
|
|
16828
|
+
.filter((b) => b.type === 'text')
|
|
16829
|
+
.map((b) => b.text)
|
|
16830
|
+
.join('\n'),
|
|
16831
|
+
gateMetadata: {
|
|
16832
|
+
...(event.origin ?? {}),
|
|
16833
|
+
serverId: event.serverId,
|
|
16834
|
+
featureSet: event.featureSet,
|
|
16835
|
+
eventId: event.eventId,
|
|
16836
|
+
eventType: 'mcpl:push-event',
|
|
16837
|
+
...(event.tags ? { tags: event.tags } : {}),
|
|
16838
|
+
},
|
|
16839
|
+
});
|
|
16840
|
+
metadata.triggered = decision.wake;
|
|
16841
|
+
if (decision.held && triggerChannel) {
|
|
16842
|
+
metadata.tuneOut = { epochId: decision.held.epochId };
|
|
16843
|
+
this.tuneOutCoordinator?.recordHeld(event.serverId, triggerChannel.channelId, event.eventId, event.tags, typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined, decision.gatePass);
|
|
16844
|
+
}
|
|
14718
16845
|
const placement = { agent: '' };
|
|
14719
16846
|
if (!silentHeartbeat) {
|
|
14720
16847
|
const id = this.addMessage('user', content, AgentFramework.withStoredDigest(metadata, content), { placement, bypassDeferralFor: event.assemblingFor });
|
|
@@ -14723,11 +16850,8 @@ export class AgentFramework {
|
|
|
14723
16850
|
else {
|
|
14724
16851
|
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`);
|
|
14725
16852
|
}
|
|
14726
|
-
if (
|
|
14727
|
-
|
|
14728
|
-
const targetAgents = event.targetAgents
|
|
14729
|
-
?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
14730
|
-
for (const agentName of targetAgents) {
|
|
16853
|
+
if (decision.wake) {
|
|
16854
|
+
for (const agentName of decision.recipients) {
|
|
14731
16855
|
this.pendingRequests.push({
|
|
14732
16856
|
agentName,
|
|
14733
16857
|
reason: 'mcpl:push-event',
|
|
@@ -14737,7 +16861,7 @@ export class AgentFramework {
|
|
|
14737
16861
|
// DMs and addressed-while-closed messages arrive as push events;
|
|
14738
16862
|
// they must outrank ambient chatter in a batched wake's locus
|
|
14739
16863
|
// selection just like their channels/incoming counterparts.
|
|
14740
|
-
addressed: isAddressedMessage(event.tags
|
|
16864
|
+
addressed: isAddressedMessage(event.tags),
|
|
14741
16865
|
...ambientMark(event.tags),
|
|
14742
16866
|
...systemNoticeMark(event.tags),
|
|
14743
16867
|
...(silentHeartbeat ? {
|
|
@@ -14763,11 +16887,11 @@ export class AgentFramework {
|
|
|
14763
16887
|
* a fork is that fork's conversation, so no other agent takes it.
|
|
14764
16888
|
*/
|
|
14765
16889
|
mayTakeGateRoute(agentName, route) {
|
|
14766
|
-
const home = this.
|
|
16890
|
+
const home = this.homeOf(agentName);
|
|
14767
16891
|
if (home !== undefined)
|
|
14768
16892
|
return sameChannel(home, route);
|
|
14769
|
-
for (const
|
|
14770
|
-
if (sameChannel(
|
|
16893
|
+
for (const record of this.registry.list()) {
|
|
16894
|
+
if (sameChannel(this.channelOfHome(record.homeChannel), route))
|
|
14771
16895
|
return false;
|
|
14772
16896
|
}
|
|
14773
16897
|
return true;
|
|
@@ -14786,6 +16910,35 @@ export class AgentFramework {
|
|
|
14786
16910
|
const named = this.channelRegistry?.channelsNamed(channelId) ?? [];
|
|
14787
16911
|
return named.length === 1 ? named[0] : undefined;
|
|
14788
16912
|
}
|
|
16913
|
+
/**
|
|
16914
|
+
* A live agent's home channel, from its registry record (channelOfHome):
|
|
16915
|
+
* defined for a conversation fork, and only for one. Undefined for an agent
|
|
16916
|
+
* with none, and for one that has ended.
|
|
16917
|
+
*/
|
|
16918
|
+
homeOf(agentName) {
|
|
16919
|
+
return this.channelOfHome(this.registry.homeChannel(agentName));
|
|
16920
|
+
}
|
|
16921
|
+
/**
|
|
16922
|
+
* The channel a registry record's `homeChannel` names: the channel on its
|
|
16923
|
+
* server. A bare channel id, as a record written before homes carried
|
|
16924
|
+
* their server keeps, is read as a wake that names its channel without its
|
|
16925
|
+
* server is (channelNamed): the one registered channel it names, read when
|
|
16926
|
+
* it's used, since the servers registered at that moment are what it can
|
|
16927
|
+
* mean. An id that names no one registered channel, as one no server
|
|
16928
|
+
* registers now or two servers do, is the home on no server (an empty
|
|
16929
|
+
* serverId, which channelNamed reads as none given), so the agent keeps a
|
|
16930
|
+
* home. A path that reads the pair finds no server registering it, and
|
|
16931
|
+
* refuses it; one that reads the name reads what the name means then: none
|
|
16932
|
+
* or two channels, which it refuses, or, once one server alone registers
|
|
16933
|
+
* the id, that server's channel.
|
|
16934
|
+
*/
|
|
16935
|
+
channelOfHome(home) {
|
|
16936
|
+
if (home === undefined)
|
|
16937
|
+
return undefined;
|
|
16938
|
+
if (typeof home !== 'string')
|
|
16939
|
+
return home;
|
|
16940
|
+
return this.channelNamed(home) ?? { serverId: '', channelId: home };
|
|
16941
|
+
}
|
|
14789
16942
|
/**
|
|
14790
16943
|
* Explicit mode's typing channel for one start of a turn, which has no
|
|
14791
16944
|
* locus, read once as the start opens (driveStream then shows what the
|
|
@@ -15358,6 +17511,43 @@ export class AgentFramework {
|
|
|
15358
17511
|
await this.startAgentStream(agent, start);
|
|
15359
17512
|
}
|
|
15360
17513
|
}
|
|
17514
|
+
/**
|
|
17515
|
+
* `channel_publish` with no channel goes where the turn's plain speech
|
|
17516
|
+
* would: the frozen turn locus (the waking delivery's channel, or one
|
|
17517
|
+
* opened this turn), then the registry's own resolution. Under
|
|
17518
|
+
* `speech.locus: 'delivery'` the registry alone has no default, and the
|
|
17519
|
+
* tool failed on the very turn its own delivery woke (first live run,
|
|
17520
|
+
* 2026-10-12). Both dispatch paths — the stream driver's and
|
|
17521
|
+
* executeToolCall's — pass through here. Only a call made inside the turn
|
|
17522
|
+
* (callingTurnOf) takes the turn's locus: one made outside it, as a script
|
|
17523
|
+
* left running after its turn makes or a host's executeToolCall naming the
|
|
17524
|
+
* agent, takes none of the turn's channels. A conversation fork's home
|
|
17525
|
+
* isn't the turn's but the fork's own, so such a call from a fork naming
|
|
17526
|
+
* no channel still goes there (homeOf), as the fork's dispatched calls do
|
|
17527
|
+
* (dispatchChannelToolCall); anyone else's takes the registry's own
|
|
17528
|
+
* default.
|
|
17529
|
+
*/
|
|
17530
|
+
publishInputWithTurnLocus(input, agentName, callingTurn) {
|
|
17531
|
+
const given = input?.channelId;
|
|
17532
|
+
if (typeof given === 'string')
|
|
17533
|
+
return input;
|
|
17534
|
+
const locus = callingTurn
|
|
17535
|
+
? callingTurn.locus ?? this.channelRegistry?.resolveLocus(agentName) ?? null
|
|
17536
|
+
: this.homeOf(agentName) ?? null;
|
|
17537
|
+
if (!locus)
|
|
17538
|
+
return input;
|
|
17539
|
+
// The pair: a serverId beside a channelId names that server's channel.
|
|
17540
|
+
// A serverId given with no channelId names the locus's id on that
|
|
17541
|
+
// server, as the registry reads one given beside its own default: the
|
|
17542
|
+
// default's id on the server given (handleToolPublish). So a fork's call
|
|
17543
|
+
// naming another server and no channel reaches that server's channel
|
|
17544
|
+
// with its home's id, where one is registered: its dispatched calls are
|
|
17545
|
+
// refused that (dispatchChannelToolCall), and executeToolCall applies
|
|
17546
|
+
// none of that guard, on main as here.
|
|
17547
|
+
const record = input ?? {};
|
|
17548
|
+
const serverId = typeof record.serverId === 'string' && record.serverId ? record.serverId : locus.serverId;
|
|
17549
|
+
return { ...record, channelId: locus.channelId, serverId };
|
|
17550
|
+
}
|
|
15361
17551
|
/**
|
|
15362
17552
|
* Stamps for the rows the agent's reply stores in its current turn: its
|
|
15363
17553
|
* assistant responses, its tool_result rows (through `storeResults`), and
|
|
@@ -16107,7 +18297,7 @@ export class AgentFramework {
|
|
|
16107
18297
|
const registry = this.channelRegistry;
|
|
16108
18298
|
if (!registry)
|
|
16109
18299
|
return refuse('No channels are configured, so there is nowhere to resend to.');
|
|
16110
|
-
const home = this.
|
|
18300
|
+
const home = this.homeOf(agentName);
|
|
16111
18301
|
let spec = typeof input.destination === 'string' ? input.destination.trim() : '';
|
|
16112
18302
|
if (present(input.destination) && typeof input.destination !== 'string') {
|
|
16113
18303
|
return refuse('destination must be "#channel", "@person" or a channel id.');
|
|
@@ -16525,17 +18715,58 @@ export class AgentFramework {
|
|
|
16525
18715
|
console.error('noticeHeartbeatTurn: failed to record the notice:', err);
|
|
16526
18716
|
}
|
|
16527
18717
|
}
|
|
18718
|
+
rememberAnnouncedLocus(agentName, locus) {
|
|
18719
|
+
this.lastAnnouncedLocus.set(agentName, locus);
|
|
18720
|
+
this.writeAnnouncedLocus(agentName, locus ? { serverId: locus.serverId, channelId: locus.channelId } : null);
|
|
18721
|
+
}
|
|
18722
|
+
forgetAnnouncedLocus(agentName) {
|
|
18723
|
+
this.lastAnnouncedLocus.delete(agentName);
|
|
18724
|
+
this.writeAnnouncedLocus(agentName, undefined);
|
|
18725
|
+
}
|
|
18726
|
+
/** The stored baseline's entry for the agent set (rememberAnnouncedLocus),
|
|
18727
|
+
* or removed (undefined) when the agent ends without coming back. */
|
|
18728
|
+
writeAnnouncedLocus(agentName, locus) {
|
|
18729
|
+
try {
|
|
18730
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
18731
|
+
const state = (data && typeof data === 'object' ? data : {});
|
|
18732
|
+
const loci = { ...(state.announcedLoci ?? {}) };
|
|
18733
|
+
if (locus === undefined) {
|
|
18734
|
+
if (!Object.hasOwn(loci, agentName))
|
|
18735
|
+
return;
|
|
18736
|
+
delete loci[agentName];
|
|
18737
|
+
}
|
|
18738
|
+
else {
|
|
18739
|
+
loci[agentName] = locus;
|
|
18740
|
+
}
|
|
18741
|
+
state.announcedLoci = loci;
|
|
18742
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
18743
|
+
}
|
|
18744
|
+
catch (err) {
|
|
18745
|
+
console.error(`[routing] ${agentName}: the announced locus wasn't ${locus === undefined ? 'removed from' : 'written to'} the store:`, err);
|
|
18746
|
+
}
|
|
18747
|
+
}
|
|
16528
18748
|
/**
|
|
16529
18749
|
* Announce-on-change locus notice (turn-frozen routing). Appends a durable
|
|
16530
18750
|
* `[routing]` window message when the turn's effective outbound locus
|
|
16531
18751
|
* differs from the last one announced — the agent must never have to guess
|
|
16532
18752
|
* where its plain prose lands. Durable append (never retracted) keeps the
|
|
16533
|
-
* KV prefix stable; change-only keeps it out of steady-state turns.
|
|
16534
|
-
*
|
|
16535
|
-
* no destination" is the unremarkable
|
|
18753
|
+
* KV prefix stable; change-only keeps it out of steady-state turns. An
|
|
18754
|
+
* agent never told of a locus, here or before a restart, hears only of a
|
|
18755
|
+
* non-null one ("prose has no destination" is the unremarkable start).
|
|
18756
|
+
* Never triggers
|
|
16536
18757
|
* inference; never locus-eligible itself (system + no channel metadata).
|
|
16537
18758
|
*/
|
|
16538
18759
|
announceLocusIfChanged(agentName, locus) {
|
|
18760
|
+
if (!this.lastAnnouncedLocus.has(agentName)) {
|
|
18761
|
+
try {
|
|
18762
|
+
const stored = this.store.getStateJson(FRAMEWORK_STATE_ID)?.announcedLoci;
|
|
18763
|
+
if (stored && Object.hasOwn(stored, agentName))
|
|
18764
|
+
this.lastAnnouncedLocus.set(agentName, stored[agentName] ?? null);
|
|
18765
|
+
}
|
|
18766
|
+
catch (err) {
|
|
18767
|
+
console.error(`[routing] ${agentName}: the announced locus couldn't be read back, so this turn compares with none:`, err);
|
|
18768
|
+
}
|
|
18769
|
+
}
|
|
16539
18770
|
const hasBaseline = this.lastAnnouncedLocus.has(agentName);
|
|
16540
18771
|
const prev = this.lastAnnouncedLocus.get(agentName) ?? null;
|
|
16541
18772
|
if (hasBaseline ? sameChannel(prev, locus) : locus === null) {
|
|
@@ -16565,7 +18796,7 @@ export class AgentFramework {
|
|
|
16565
18796
|
const id = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'routing-notice' });
|
|
16566
18797
|
// The baseline is what the agent was told: it moves once the notice is
|
|
16567
18798
|
// stored, so a notice that fails is announced again at the next turn.
|
|
16568
|
-
this.
|
|
18799
|
+
this.rememberAnnouncedLocus(agentName, locus);
|
|
16569
18800
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
16570
18801
|
console.error(`[routing] ${agentName}: locus ${prev ? qualifiedChannelName(prev) : '(none)'} -> ${locus ? qualifiedChannelName(locus) : '(none)'} (announced in window)`);
|
|
16571
18802
|
}
|
|
@@ -16581,7 +18812,11 @@ export class AgentFramework {
|
|
|
16581
18812
|
* as the agent's first view) that addressed the agent, or that follows up
|
|
16582
18813
|
* in a channel the turn sent into (TurnRouting.engaged). Undefined for any
|
|
16583
18814
|
* other message. A tool boundary weighs the messages it injects by it,
|
|
16584
|
-
* and noteArrival the ones no boundary of the turn weighs.
|
|
18815
|
+
* and noteArrival the ones no boundary of the turn weighs. The rule is
|
|
18816
|
+
* pure; what is done with its answer is `speech.locus`'s: under
|
|
18817
|
+
* `'latest-inbound'` the locus moves, under `'delivery'` (the default) the
|
|
18818
|
+
* agent is told it was addressed from elsewhere and the locus stays
|
|
18819
|
+
* (addressedElsewhereNotice).
|
|
16585
18820
|
*/
|
|
16586
18821
|
repinChannel(metadata, routing) {
|
|
16587
18822
|
if (!isConversationalInjection(metadata))
|
|
@@ -16595,7 +18830,7 @@ export class AgentFramework {
|
|
|
16595
18830
|
const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined);
|
|
16596
18831
|
if (!channel)
|
|
16597
18832
|
return undefined;
|
|
16598
|
-
if (isAddressedMessage(m.tags
|
|
18833
|
+
if (isAddressedMessage(m.tags))
|
|
16599
18834
|
return channel;
|
|
16600
18835
|
return routing.engaged.has(destinationKey(channel)) ? channel : undefined;
|
|
16601
18836
|
}
|
|
@@ -16839,8 +19074,10 @@ export class AgentFramework {
|
|
|
16839
19074
|
* Weigh a message that no tool boundary of the agent's open logical turn
|
|
16840
19075
|
* will weigh: one that would re-pin the locus becomes one of the turn's
|
|
16841
19076
|
* arrivals (TurnRouting.arrivals). A message the agent's own view leaves
|
|
16842
|
-
* out, such as one
|
|
16843
|
-
*
|
|
19077
|
+
* out (agentSees), such as one held from the residents (heldFromResidents,
|
|
19078
|
+
* the admission stage's one predicate, which every resident's view filter
|
|
19079
|
+
* reads) or another agent's stored context injection, never enters its
|
|
19080
|
+
* compiled view, so the agent won't see it, and it moves nothing.
|
|
16844
19081
|
*/
|
|
16845
19082
|
noteArrival(agentName, metadata, messageId) {
|
|
16846
19083
|
const open = this.openLogical.get(agentName);
|
|
@@ -16852,17 +19089,19 @@ export class AgentFramework {
|
|
|
16852
19089
|
if (isConversationalInjection(metadata))
|
|
16853
19090
|
(open.heard ??= new Map()).set(messageId, ++lastHeardAt);
|
|
16854
19091
|
const channel = this.repinChannel(metadata, open);
|
|
16855
|
-
if (channel)
|
|
16856
|
-
|
|
19092
|
+
if (!channel)
|
|
19093
|
+
return;
|
|
19094
|
+
const tags = metadata?.tags;
|
|
19095
|
+
open.arrivals.push({ messageId, channel, addressed: isAddressedMessage(tags) });
|
|
16857
19096
|
}
|
|
16858
19097
|
/**
|
|
16859
19098
|
* Whether `agent`'s store is the residents' one message slot, which every
|
|
16860
|
-
* ordinary resident reads (
|
|
19099
|
+
* ordinary resident reads (`readsSharedSlot` in the registry). The subconscious reads that
|
|
16861
19100
|
* slot through a merged view, but its own store is isolated, as a fork's
|
|
16862
19101
|
* or an ephemeral agent's is.
|
|
16863
19102
|
*/
|
|
16864
19103
|
isOrdinaryResident(agent) {
|
|
16865
|
-
return this.
|
|
19104
|
+
return this.registry.readsSharedSlot(agent.name) && agent.name !== this.subconsciousAgentName;
|
|
16866
19105
|
}
|
|
16867
19106
|
/**
|
|
16868
19107
|
* Weigh a message just stored in an ordinary resident's store for every
|
|
@@ -16873,8 +19112,9 @@ export class AgentFramework {
|
|
|
16873
19112
|
noteForOtherResidents(agent, metadata, messageId) {
|
|
16874
19113
|
if (!this.isOrdinaryResident(agent))
|
|
16875
19114
|
return;
|
|
16876
|
-
for (const
|
|
16877
|
-
|
|
19115
|
+
for (const name of this.registry.sharedSlotReaders()) {
|
|
19116
|
+
const reader = this.agents.get(name);
|
|
19117
|
+
if (reader && reader !== agent && this.isOrdinaryResident(reader)) {
|
|
16878
19118
|
this.noteSpokenTo(reader.name, metadata);
|
|
16879
19119
|
this.noteArrival(reader.name, metadata, messageId);
|
|
16880
19120
|
}
|
|
@@ -16951,7 +19191,7 @@ export class AgentFramework {
|
|
|
16951
19191
|
const metadata = { system: true, kind: 'routing-notice' };
|
|
16952
19192
|
try {
|
|
16953
19193
|
const id = agent.getContextManager().addMessage('user', content, metadata);
|
|
16954
|
-
this.
|
|
19194
|
+
this.rememberAnnouncedLocus(agent.name, channel);
|
|
16955
19195
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
16956
19196
|
console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ? qualifiedChannelName(prevPin) : '(none)'} -> ${qualifiedChannelName(channel)}`);
|
|
16957
19197
|
return { participant: 'user', content, metadata, id };
|
|
@@ -16961,6 +19201,73 @@ export class AgentFramework {
|
|
|
16961
19201
|
return undefined;
|
|
16962
19202
|
}
|
|
16963
19203
|
}
|
|
19204
|
+
/**
|
|
19205
|
+
* Under `speech.locus: 'delivery'`: tell the agent that someone addressed
|
|
19206
|
+
* it from another channel while its turn was busy, since incoming traffic
|
|
19207
|
+
* never moves the locus and a neutral message carries nothing that names
|
|
19208
|
+
* its channel (first live run, 2026-10-12, finding C). Once per channel
|
|
19209
|
+
* per turn (TurnRouting.addressedElsewhere); nothing for the turn's own
|
|
19210
|
+
* locus. Stored durably; returned as stored for a live stream to inject
|
|
19211
|
+
* with its boundary's other messages. Undefined when nothing was stored.
|
|
19212
|
+
*/
|
|
19213
|
+
addressedElsewhereNotice(agent, routing, channel, who, when = 'while you were busy') {
|
|
19214
|
+
const pin = routing.locus ?? null;
|
|
19215
|
+
const key = destinationKey(channel);
|
|
19216
|
+
if (sameChannel(channel, pin) || routing.addressedElsewhere.has(key))
|
|
19217
|
+
return undefined;
|
|
19218
|
+
const where = pin ? `still lands in ${this.channelShown(pin)}` : 'stays in your archive (no channel)';
|
|
19219
|
+
const content = [{
|
|
19220
|
+
type: 'text',
|
|
19221
|
+
text:
|
|
19222
|
+
// The author's name is a value someone supplied (main's #852: one
|
|
19223
|
+
// line, its notice openings marked); the channel is shown as
|
|
19224
|
+
// channelShown gives it.
|
|
19225
|
+
`[${NOTICE_TAG.routing}] ${suppliedValue(who)} addressed you from ${this.channelShown(channel)} ${when}. ` +
|
|
19226
|
+
`Your plain speech ${where} this turn; reaching them takes an explicit send.`,
|
|
19227
|
+
}];
|
|
19228
|
+
const metadata = { system: true, kind: 'routing-notice' };
|
|
19229
|
+
const before = this.storedCountOf(agent);
|
|
19230
|
+
try {
|
|
19231
|
+
const id = agent.getContextManager().addMessage('user', content, metadata);
|
|
19232
|
+
routing.addressedElsewhere.add(key);
|
|
19233
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
19234
|
+
return { participant: 'user', content, metadata };
|
|
19235
|
+
}
|
|
19236
|
+
catch (err) {
|
|
19237
|
+
// A write can throw after its row landed, as storeInjections' can: a
|
|
19238
|
+
// notice that landed is the turn's notice for the channel, as one
|
|
19239
|
+
// stored without a throw is.
|
|
19240
|
+
const text = content[0].text;
|
|
19241
|
+
const landed = before === undefined ? undefined : this.storedSince(agent, before, (m) => m.metadata?.kind === 'routing-notice'
|
|
19242
|
+
&& m.content.some((b) => b.type === 'text' && b.text === text), 'routing', 'the addressed-elsewhere notice');
|
|
19243
|
+
if (landed && landed.length > 0) {
|
|
19244
|
+
routing.addressedElsewhere.add(key);
|
|
19245
|
+
console.error(`addressed-elsewhere notice: ${agent.name}: the notice landed though its write threw:`, err);
|
|
19246
|
+
return { participant: 'user', content, metadata };
|
|
19247
|
+
}
|
|
19248
|
+
console.error(`addressed-elsewhere notice: failed to record routing notice${landed ? '' : ' (whether it landed is unknown)'}:`, err);
|
|
19249
|
+
return undefined;
|
|
19250
|
+
}
|
|
19251
|
+
}
|
|
19252
|
+
/**
|
|
19253
|
+
* The arrivals a start of the turn consumes under `speech.locus:
|
|
19254
|
+
* 'delivery'`: each one that addressed the agent, whose message is still
|
|
19255
|
+
* in the store, gets the addressed-elsewhere notice, oldest first. A
|
|
19256
|
+
* follow-up in an engaged channel addressed nobody and is not announced.
|
|
19257
|
+
*/
|
|
19258
|
+
noticeAddressedArrivals(agent, routing, arrivals, when) {
|
|
19259
|
+
if (arrivals.length === 0)
|
|
19260
|
+
return;
|
|
19261
|
+
const cm = agent.getContextManager();
|
|
19262
|
+
for (const { messageId, channel, addressed } of arrivals) {
|
|
19263
|
+
if (!addressed)
|
|
19264
|
+
continue;
|
|
19265
|
+
const message = cm.getMessage(messageId);
|
|
19266
|
+
if (!message)
|
|
19267
|
+
continue;
|
|
19268
|
+
this.addressedElsewhereNotice(agent, routing, channel, authorName(message.metadata), when);
|
|
19269
|
+
}
|
|
19270
|
+
}
|
|
16964
19271
|
/**
|
|
16965
19272
|
* Durable window notice for a channel the agent did not ask to open: a
|
|
16966
19273
|
* subscription-policy admission, announced as the decision before its
|
|
@@ -17142,7 +19449,7 @@ export class AgentFramework {
|
|
|
17142
19449
|
canCloseChannels(who, channels) {
|
|
17143
19450
|
if (who === undefined)
|
|
17144
19451
|
return false;
|
|
17145
|
-
const home = this.
|
|
19452
|
+
const home = this.homeOf(who);
|
|
17146
19453
|
return channels.every((channel) => !home || sameChannel(home, channel)) && this.canAgentUseTool(who, 'channel_close');
|
|
17147
19454
|
}
|
|
17148
19455
|
/**
|
|
@@ -17165,7 +19472,7 @@ export class AgentFramework {
|
|
|
17165
19472
|
holdsChannelTraffic(who, channels) {
|
|
17166
19473
|
if (who === undefined)
|
|
17167
19474
|
return false;
|
|
17168
|
-
const home = this.
|
|
19475
|
+
const home = this.homeOf(who);
|
|
17169
19476
|
if (home !== undefined) {
|
|
17170
19477
|
return channels.every((channel) => sameChannel(channel, home) && this.conversationRouter?.getBinding(channel)?.agentName === who);
|
|
17171
19478
|
}
|
|
@@ -17182,8 +19489,14 @@ export class AgentFramework {
|
|
|
17182
19489
|
* otherwise the delivery agent and the readers of the residents' shared slot do. An ephemeral run holds none.
|
|
17183
19490
|
*/
|
|
17184
19491
|
holdsChannelTrafficForTools(who) {
|
|
17185
|
-
if (this.
|
|
19492
|
+
if (this.registry.homeChannel(who) !== undefined)
|
|
17186
19493
|
return true;
|
|
19494
|
+
// An agent that shares its source's context (a reader fork) shares its
|
|
19495
|
+
// source's words too: its tool block is the source's prefix, and a
|
|
19496
|
+
// sentence that differed would send the whole window uncached.
|
|
19497
|
+
const inherit = this.registry.get(who)?.inherit;
|
|
19498
|
+
if (inherit?.mode === 'shared' && inherit.from !== who)
|
|
19499
|
+
return this.holdsChannelTrafficForTools(inherit.from);
|
|
17187
19500
|
if (this.conversationRouter)
|
|
17188
19501
|
return false;
|
|
17189
19502
|
return this.residentHoldsTraffic(who);
|
|
@@ -17206,7 +19519,7 @@ export class AgentFramework {
|
|
|
17206
19519
|
* notice passed on later, at resume or at boot, names it by its name.
|
|
17207
19520
|
*/
|
|
17208
19521
|
agentForPrimary(agentName) {
|
|
17209
|
-
const home = this.
|
|
19522
|
+
const home = this.homeOf(agentName);
|
|
17210
19523
|
if (home === undefined)
|
|
17211
19524
|
return { first: agentName, again: agentName };
|
|
17212
19525
|
const label = this.channelRegistry?.getDescriptor(home)?.label;
|
|
@@ -17227,7 +19540,7 @@ export class AgentFramework {
|
|
|
17227
19540
|
/** Whether `agentName` is a conversation fork made from the primary: the
|
|
17228
19541
|
* router's template is the primary (connectome-host's is). */
|
|
17229
19542
|
forkOfPrimary(agentName) {
|
|
17230
|
-
return this.
|
|
19543
|
+
return this.registry.homeChannel(agentName) !== undefined && this.primaryAgentName !== null &&
|
|
17231
19544
|
this.conversationRouter?.templateAgent === this.primaryAgentName;
|
|
17232
19545
|
}
|
|
17233
19546
|
/**
|
|
@@ -17287,7 +19600,7 @@ export class AgentFramework {
|
|
|
17287
19600
|
return [];
|
|
17288
19601
|
}
|
|
17289
19602
|
const who = ended ? primary : agentName || this.deliveryAgent()?.name;
|
|
17290
|
-
const canOpen = who !== undefined &&
|
|
19603
|
+
const canOpen = who !== undefined && this.registry.homeChannel(who) === undefined && this.canAgentUseTool(who, 'channel_open');
|
|
17291
19604
|
const canClose = this.canCloseChannels(who, [{ serverId, channelId: channel.channelId }]);
|
|
17292
19605
|
const selected = selectedForOpening === 'unrecorded'
|
|
17293
19606
|
? " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again"
|
|
@@ -17329,7 +19642,7 @@ export class AgentFramework {
|
|
|
17329
19642
|
};
|
|
17330
19643
|
const notices = [];
|
|
17331
19644
|
if (tellPrimary) {
|
|
17332
|
-
const primaryCanOpen =
|
|
19645
|
+
const primaryCanOpen = this.registry.homeChannel(primary) === undefined && this.canAgentUseTool(primary, 'channel_open');
|
|
17333
19646
|
const reader = this.agentForPrimary(who);
|
|
17334
19647
|
notices.push({
|
|
17335
19648
|
text: `[${NOTICE_TAG.channels}] Not open: ${shown}. A reply from ${reader.first} went out there, but opening the channel failed, and ${reader.again} can't close it.` +
|
|
@@ -17408,7 +19721,7 @@ export class AgentFramework {
|
|
|
17408
19721
|
return;
|
|
17409
19722
|
// A subagent's run ends with its result; a conversation fork answers
|
|
17410
19723
|
// its home channel's message.
|
|
17411
|
-
if (this.ephemeralRuns.has(name) || this.
|
|
19724
|
+
if (this.ephemeralRuns.has(name) || this.registry.homeChannel(name) !== undefined)
|
|
17412
19725
|
return;
|
|
17413
19726
|
if (this.providerAdmissionClosed || this.agents.get(name) !== agent) {
|
|
17414
19727
|
console.error(`[continue] ${name}: not continued — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`);
|
|
@@ -18302,7 +20615,7 @@ export class AgentFramework {
|
|
|
18302
20615
|
}
|
|
18303
20616
|
}
|
|
18304
20617
|
async startAgentStream(agent, trigger, attempt = 0, providerGateAlreadyHeld = false) {
|
|
18305
|
-
const ownsProviderGate =
|
|
20618
|
+
const ownsProviderGate = this.registry.ownsProviderScheduling(agent.name);
|
|
18306
20619
|
// A stopping framework starts no turn, whoever owns provider admission
|
|
18307
20620
|
// (conversation agents and ephemeral runs do not take the gate).
|
|
18308
20621
|
if (this.providerAdmissionClosed) {
|
|
@@ -19175,12 +21488,29 @@ export class AgentFramework {
|
|
|
19175
21488
|
// A turn in a mode with a locus reads its arrivals' messages, a turn
|
|
19176
21489
|
// with no channel (noChannel) included: an addressed message, or a
|
|
19177
21490
|
// follow-up where the turn sent, gives it its channel, as a re-pin does.
|
|
19178
|
-
|
|
21491
|
+
// Under `speech.locus: 'delivery'` an arrival moves nothing; one that
|
|
21492
|
+
// addressed the agent from another channel is announced instead, as a
|
|
21493
|
+
// boundary would have announced it (addressedElsewhereNotice).
|
|
21494
|
+
const routesLocus = turnProseRouting === 'locus' || turnProseRouting === 'hybrid';
|
|
21495
|
+
const arrivals = routing.arrivals;
|
|
21496
|
+
const arrival = routesLocus && this.speechLocus === 'latest-inbound'
|
|
19179
21497
|
? this.remainingArrival(agent, routing)
|
|
19180
21498
|
: undefined;
|
|
19181
21499
|
routing.arrivals = [];
|
|
19182
21500
|
if (arrival)
|
|
19183
21501
|
this.repinTurnLocus(agent, routing, arrival, 'arrival');
|
|
21502
|
+
else if (routesLocus && this.speechLocus === 'delivery') {
|
|
21503
|
+
// Which start this is, since from inside they look alike and the
|
|
21504
|
+
// reason says whether the earlier prose landed (Linn, 2026-10-14).
|
|
21505
|
+
const when = attempt > 0
|
|
21506
|
+
? 'while your turn was retried'
|
|
21507
|
+
: trigger?.reason === 'context_budget_restart'
|
|
21508
|
+
? 'at your budget restart'
|
|
21509
|
+
: isTurnContinuation(trigger?.reason ?? '')
|
|
21510
|
+
? 'before this re-inference on your tool results'
|
|
21511
|
+
: 'while your turn waited on a provider hold';
|
|
21512
|
+
this.noticeAddressedArrivals(agent, routing, arrivals, when);
|
|
21513
|
+
}
|
|
19184
21514
|
}
|
|
19185
21515
|
// A silent start, or a disabled agent's, has no locus to show, whatever a
|
|
19186
21516
|
// channel_open in a silent turn left on its object.
|
|
@@ -19379,7 +21709,7 @@ export class AgentFramework {
|
|
|
19379
21709
|
// can record a wait while this turn compiles, after the last boundary
|
|
19380
21710
|
// checked. Check its waits again here, where nothing has been sent, and
|
|
19381
21711
|
// hold the turn as the boundary would rather than send it into the wait.
|
|
19382
|
-
if (this.
|
|
21712
|
+
if (this.registry.homeChannel(agent.name) !== undefined) {
|
|
19383
21713
|
const wait = this.providerWaits?.active(agent.name, agent.model);
|
|
19384
21714
|
// The turn has begun (inference:started), so the hold keeps its retry.
|
|
19385
21715
|
if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(withTurnCarry(trigger, carry))] : [], false, () => {
|
|
@@ -19401,8 +21731,9 @@ export class AgentFramework {
|
|
|
19401
21731
|
})) {
|
|
19402
21732
|
console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
|
|
19403
21733
|
`(recorded while the turn compiled); ${this.providerAdmissionClosed ? 'the framework is stopping, so nothing is held'
|
|
19404
|
-
: this.
|
|
19405
|
-
:
|
|
21734
|
+
: this.agents.get(agent.name) !== agent ? 'the agent ended as the failure was reported, so nothing is held'
|
|
21735
|
+
: this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held'
|
|
21736
|
+
: 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`);
|
|
19406
21737
|
this.endFailureAtStop(agent.name, turnToken, turnStartedAt, routing);
|
|
19407
21738
|
this.activeStreams.delete(agent.name);
|
|
19408
21739
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
@@ -21032,7 +23363,7 @@ export class AgentFramework {
|
|
|
21032
23363
|
.map((b) => b)
|
|
21033
23364
|
.filter((c) => typeof c.name === 'string' && !cutIds.has(c.id)
|
|
21034
23365
|
&& markedInputRefusal({ ...c, name: c.name }) === undefined
|
|
21035
|
-
&& this.
|
|
23366
|
+
&& this.mayCallTool(agent.name, c.name));
|
|
21036
23367
|
const toolNames = toolCalls.map((c) => c.name);
|
|
21037
23368
|
// A refused skip_reply isn't a silencing call; a plain one is.
|
|
21038
23369
|
const fallbackCause = liveProseRouting
|
|
@@ -21909,7 +24240,7 @@ export class AgentFramework {
|
|
|
21909
24240
|
// turn isn't over (pendingContinuationAgents).
|
|
21910
24241
|
if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)
|
|
21911
24242
|
&& !this.pendingContinuationAgents().has(agent.name)) {
|
|
21912
|
-
this.disposeConversationAgent(agent.name);
|
|
24243
|
+
this.disposeConversationAgent(agent.name, 'idle-ttl');
|
|
21913
24244
|
}
|
|
21914
24245
|
}
|
|
21915
24246
|
}
|
|
@@ -21929,8 +24260,10 @@ export class AgentFramework {
|
|
|
21929
24260
|
* (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt
|
|
21930
24261
|
* from agent allowlists. Registered agents' allowedTools is enforced at
|
|
21931
24262
|
* dispatch, including namespaced utility names. Genuinely unidentified public callers (including
|
|
21932
|
-
* unknown legacy names) remain unrestricted.
|
|
21933
|
-
*
|
|
24263
|
+
* unknown legacy names) remain unrestricted. A name the framework made in
|
|
24264
|
+
* this process is refused while it isn't registered, even if its configured
|
|
24265
|
+
* list allows the tool; after a restart, such a name is a host's caller name
|
|
24266
|
+
* like any other.
|
|
21934
24267
|
*/
|
|
21935
24268
|
async executeToolCall(call) {
|
|
21936
24269
|
return this.executeToolCallFrom(call, {
|
|
@@ -22188,7 +24521,11 @@ export class AgentFramework {
|
|
|
22188
24521
|
// synthesized verbs (think, skip_reply) are deliberately not routed
|
|
22189
24522
|
// here.
|
|
22190
24523
|
if (call.name.startsWith('channel_') && this.channelRegistry) {
|
|
22191
|
-
|
|
24524
|
+
const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined;
|
|
24525
|
+
const input = call.name === 'channel_publish' && call.callerAgentName
|
|
24526
|
+
? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn)
|
|
24527
|
+
: call.input;
|
|
24528
|
+
return this.channelRegistry.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin);
|
|
22192
24529
|
}
|
|
22193
24530
|
// drafts: the calling resident's own held drafts. A puppeted call has
|
|
22194
24531
|
// agent origin and acts on that resident's drafts, as the model's own
|
|
@@ -22209,6 +24546,20 @@ export class AgentFramework {
|
|
|
22209
24546
|
// Module tools. The caller uses the result in process, so it gets the
|
|
22210
24547
|
// module's own result, as the module returned it, less a refusal it
|
|
22211
24548
|
// claims, which is the dispatcher's to say.
|
|
24549
|
+
// The tune-out tool, in process: the same body the dispatcher runs, its
|
|
24550
|
+
// answer awaited here (it can wait for the tune-out's transport).
|
|
24551
|
+
if ((call.name === 'tune_out' || call.name === 'focus') && this.tuneOutCoordinator) {
|
|
24552
|
+
const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__';
|
|
24553
|
+
const input = (call.input ?? {});
|
|
24554
|
+
// The calling turn's own channels, as the channel tools read them here (origin.own).
|
|
24555
|
+
const own = origin.kind === 'agent' ? origin.own ?? [] : [];
|
|
24556
|
+
const turn = this.callingTurnOf(agentName, call);
|
|
24557
|
+
return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn);
|
|
24558
|
+
}
|
|
24559
|
+
if (SUBCONSCIOUS_TOOL_NAMES.includes(call.name)) {
|
|
24560
|
+
const routed = this.readerToolCall(origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__', call);
|
|
24561
|
+
return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result;
|
|
24562
|
+
}
|
|
22212
24563
|
return this.moduleRegistry.handleToolCall(call);
|
|
22213
24564
|
}
|
|
22214
24565
|
/**
|
|
@@ -22249,6 +24600,39 @@ export class AgentFramework {
|
|
|
22249
24600
|
(this.toolsetActCalls ??= new WeakMap()).set(inner, target.instance !== undefined ? { instance: target.instance } : {});
|
|
22250
24601
|
return { ok: true, call: inner, serverId: target.serverId, prefix, ...(target.instance !== undefined ? { instance: target.instance } : {}) };
|
|
22251
24602
|
}
|
|
24603
|
+
/**
|
|
24604
|
+
* A reader reads and reports: a reader fork (registry kind
|
|
24605
|
+
* 'subconscious-fork', in every incarnation) may call only its own tools,
|
|
24606
|
+
* and speak_in_channel where allowChannelSpeech allows it. Every other
|
|
24607
|
+
* tool the resident has stays in its block (the shared prefix) and is
|
|
24608
|
+
* refused here, so with speech off a fork can post nowhere (scout's
|
|
24609
|
+
* condition, 2026-10-19). It's decided at each call from what the reader
|
|
24610
|
+
* may call, not from what it is shown, so a tool added or moved out of
|
|
24611
|
+
* the block later is refused too.
|
|
24612
|
+
*/
|
|
24613
|
+
readerRefuses(agentName, toolName) {
|
|
24614
|
+
if (this.registry.get(agentName)?.kind !== 'subconscious-fork')
|
|
24615
|
+
return false;
|
|
24616
|
+
return !this.readerMayCall(agentName, toolName);
|
|
24617
|
+
}
|
|
24618
|
+
/**
|
|
24619
|
+
* What a reader fork may call: exactly its own tools and the speech its
|
|
24620
|
+
* configuration allows, never widened or narrowed by its resident's
|
|
24621
|
+
* allowedTools. The resident's list bounds the resident's own surface;
|
|
24622
|
+
* the reader's tools are the framework's, for reading on its behalf. A
|
|
24623
|
+
* denyToolsAtDispatch entry the fork takes from its resident that names
|
|
24624
|
+
* one of them still refuses it (mayCallTool checks the deny first): a
|
|
24625
|
+
* resident never holds a reader tool, so such an entry is about readers.
|
|
24626
|
+
* The fork still shows its resident's block (the shared prefix): what it
|
|
24627
|
+
* may call is decided here, at each call, apart from what it is shown.
|
|
24628
|
+
*/
|
|
24629
|
+
readerMayCall(agentName, toolName) {
|
|
24630
|
+
if (this.registry.get(agentName)?.kind !== 'subconscious-fork')
|
|
24631
|
+
return false;
|
|
24632
|
+
if (toolName === 'speak_in_channel')
|
|
24633
|
+
return this.subconsciousConfig?.allowChannelSpeech === true;
|
|
24634
|
+
return SUBCONSCIOUS_TOOL_NAMES.includes(toolName);
|
|
24635
|
+
}
|
|
22252
24636
|
/** A toolsets--act call a host toolset resolved (HostPresentation.resolveAct):
|
|
22253
24637
|
* its refusal answered, or the tool's own call dispatched in its place,
|
|
22254
24638
|
* with the act's id, so its result is the act's. */
|
|
@@ -23857,24 +26241,40 @@ export class AgentFramework {
|
|
|
23857
26241
|
}
|
|
23858
26242
|
return best;
|
|
23859
26243
|
}
|
|
23860
|
-
/** Registered policy
|
|
23861
|
-
* while
|
|
26244
|
+
/** Registered policy. Within this process, a name the framework made has no
|
|
26245
|
+
* dispatch authority while it isn't registered; after a restart, a host call names
|
|
26246
|
+
* its caller as any host call does. Genuine legacy caller names retain
|
|
26247
|
+
* their surface. */
|
|
23862
26248
|
canAgentUseTool(agentName, toolName) {
|
|
23863
26249
|
const agent = this.agents.get(agentName);
|
|
23864
26250
|
return agent ? agent.canUseTool(toolName) : !this.usedEphemeralAgentNames.has(agentName);
|
|
23865
26251
|
}
|
|
26252
|
+
/** Whether a call of `toolName` as `agentName` is refused at dispatch (AgentConfig.denyToolsAtDispatch, or a reader's own rule). */
|
|
26253
|
+
deniedAtDispatch(agentName, toolName) {
|
|
26254
|
+
return this.agents.get(agentName)?.deniesAtDispatch(toolName) === true || this.readerRefuses(agentName, toolName);
|
|
26255
|
+
}
|
|
26256
|
+
/** Whether `agentName` may call `toolName`: the one rule dispatch applies (agentToolPermissionFailure), for whatever else judges a call. */
|
|
26257
|
+
mayCallTool(agentName, toolName) {
|
|
26258
|
+
return !this.deniedAtDispatch(agentName, toolName)
|
|
26259
|
+
&& (this.canAgentUseTool(agentName, toolName) || this.readerMayCall(agentName, toolName));
|
|
26260
|
+
}
|
|
23866
26261
|
agentToolPermissionFailure(agentName, call, reportRefusal = true) {
|
|
23867
|
-
|
|
26262
|
+
// A tool refused at dispatch is refused on every path that runs the
|
|
26263
|
+
// agent's call (a toolset's act, executeToolCall), not only the stream's.
|
|
26264
|
+
if (this.mayCallTool(agentName, call.name))
|
|
23868
26265
|
return undefined;
|
|
23869
|
-
const
|
|
23870
|
-
|
|
23871
|
-
|
|
26266
|
+
const denied = this.deniedAtDispatch(agentName, call.name);
|
|
26267
|
+
const why = denied
|
|
26268
|
+
? `${call.name} is not available to ${agentName}`
|
|
26269
|
+
: !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName)
|
|
26270
|
+
? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered`
|
|
26271
|
+
: `Tool '${call.name}' is not permitted by this agent's allowedTools`;
|
|
23872
26272
|
// Off the dispatcher, nothing opened the call to refuse it in RFC-007's
|
|
23873
26273
|
// lifecycle, so it's a failure, not a refusal (ToolResult.refused), whose
|
|
23874
26274
|
// text says the call didn't run.
|
|
23875
26275
|
if (!reportRefusal)
|
|
23876
26276
|
return { success: false, error: `${why}, so the call didn't run.`, isError: true };
|
|
23877
|
-
const error = `${why}.`;
|
|
26277
|
+
const error = denied ? why : `${why}.`;
|
|
23878
26278
|
const refusal = this.refusedResult(agentName, call.id, error);
|
|
23879
26279
|
this.emitTrace({
|
|
23880
26280
|
type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error,
|
|
@@ -23937,7 +26337,7 @@ export class AgentFramework {
|
|
|
23937
26337
|
* (`refusedHere`) counts as one the list refuses.
|
|
23938
26338
|
*/
|
|
23939
26339
|
roundCallOf(agentName, call, refusedHere = false) {
|
|
23940
|
-
const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.
|
|
26340
|
+
const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.mayCallTool(agentName, call.name);
|
|
23941
26341
|
const refused = bareToolName(call.name) === 'skip_reply' && (!permitted || this.refusesSelfWake(call));
|
|
23942
26342
|
return { name: call.name, cause: permitted || refused ? silenceCauseOf([call]) : null, refused, permitted };
|
|
23943
26343
|
}
|
|
@@ -23960,6 +26360,23 @@ export class AgentFramework {
|
|
|
23960
26360
|
});
|
|
23961
26361
|
return;
|
|
23962
26362
|
}
|
|
26363
|
+
// Dispatch-time deny (AgentConfig.denyToolsAtDispatch): the tool stays
|
|
26364
|
+
// advertised so the agent's request prefix is unchanged; the call is
|
|
26365
|
+
// refused here, before anything runs.
|
|
26366
|
+
if (this.deniedAtDispatch(agentName, call.name)) {
|
|
26367
|
+
const error = `${call.name} is not available to ${agentName}`;
|
|
26368
|
+
const result = this.refusedResult(agentName, call.id, error);
|
|
26369
|
+
this.emitTrace({ type: 'tool:failed', module: 'dendrite', tool: call.name, callId: call.id, error });
|
|
26370
|
+
this.pushEvent({
|
|
26371
|
+
type: 'tool-result',
|
|
26372
|
+
callId: call.id,
|
|
26373
|
+
toolName: call.name,
|
|
26374
|
+
agentName,
|
|
26375
|
+
moduleName: 'dendrite',
|
|
26376
|
+
result,
|
|
26377
|
+
});
|
|
26378
|
+
return;
|
|
26379
|
+
}
|
|
23963
26380
|
const refusal = this.agentToolPermissionFailure(agentName, call) ?? this.markedInputFailure(agentName, call);
|
|
23964
26381
|
if (refusal) {
|
|
23965
26382
|
this.pushEvent({
|
|
@@ -24037,18 +26454,22 @@ export class AgentFramework {
|
|
|
24037
26454
|
return;
|
|
24038
26455
|
}
|
|
24039
26456
|
// Route the tune-out tool (residents) and the subconscious's surface
|
|
24040
|
-
if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
|
|
26457
|
+
if ((enrichedCall.name === 'tune_out' || enrichedCall.name === 'focus') && this.tuneOutCoordinator) {
|
|
24041
26458
|
this.dispatchTuneOutToolCall(agentName, enrichedCall);
|
|
24042
26459
|
return;
|
|
24043
26460
|
}
|
|
24044
|
-
if (
|
|
24045
|
-
|
|
24046
|
-
|
|
24047
|
-
|
|
24048
|
-
|
|
24049
|
-
|
|
24050
|
-
|
|
24051
|
-
|
|
26461
|
+
if (SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
|
|
26462
|
+
const routed = this.readerToolCall(agentName, enrichedCall);
|
|
26463
|
+
if ('refused' in routed) {
|
|
26464
|
+
const error = routed.refused;
|
|
26465
|
+
const result = this.refusedResult(agentName, call.id, error);
|
|
26466
|
+
this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error });
|
|
26467
|
+
this.pushEvent({
|
|
26468
|
+
type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result,
|
|
26469
|
+
});
|
|
26470
|
+
return;
|
|
26471
|
+
}
|
|
26472
|
+
void routed.result
|
|
24052
26473
|
.then((result) => {
|
|
24053
26474
|
// Through pushEvent, like every other tool's result, so one that
|
|
24054
26475
|
// arrives after stop() is dropped rather than rejected unhandled.
|
|
@@ -24369,6 +26790,7 @@ export class AgentFramework {
|
|
|
24369
26790
|
opts.placement.agent = agent.name;
|
|
24370
26791
|
opts.placement.messageId = stored;
|
|
24371
26792
|
}
|
|
26793
|
+
this.releaseLandedMail(metadata, agent.name);
|
|
24372
26794
|
this.noteStored(agent, metadata, stored, opts?.bypassDeferralFor);
|
|
24373
26795
|
return stored;
|
|
24374
26796
|
}
|
|
@@ -24528,6 +26950,38 @@ export class AgentFramework {
|
|
|
24528
26950
|
}
|
|
24529
26951
|
this.noteForOtherResidents(agent, metadata, stored);
|
|
24530
26952
|
}
|
|
26953
|
+
/**
|
|
26954
|
+
* Held agent mail is released only when its message has actually entered
|
|
26955
|
+
* the recipient's store — never on a deferral. Called at every site that
|
|
26956
|
+
* writes a message into a context: the direct path above and the two
|
|
26957
|
+
* deferred-write flushes (turn start, tool boundary). Both writes reach
|
|
26958
|
+
* disk in the same sync, so a crash keeps either both or neither.
|
|
26959
|
+
*/
|
|
26960
|
+
releaseLandedMail(metadata, recipient) {
|
|
26961
|
+
const stamp = metadata?.dendrite;
|
|
26962
|
+
if (typeof stamp?.mailId !== 'string')
|
|
26963
|
+
return;
|
|
26964
|
+
// The message is in the recipient's store; the release is written only
|
|
26965
|
+
// once the store has synced it, so a hard exit between the two leaves
|
|
26966
|
+
// the mail held and the next boot's check of the recipient's store
|
|
26967
|
+
// settles it — never a release on disk for a message that isn't (nissa's
|
|
26968
|
+
// #546 review, round 2, K). A sync that fails leaves it held the same way.
|
|
26969
|
+
try {
|
|
26970
|
+
this.store.sync();
|
|
26971
|
+
}
|
|
26972
|
+
catch (error) {
|
|
26973
|
+
console.error(`[dendrite] mail ${stamp.mailId} landed for ${recipient} but the store did not sync, so it stays held until the next boot settles it:`, error);
|
|
26974
|
+
return;
|
|
26975
|
+
}
|
|
26976
|
+
this.registry.releaseMail(stamp.mailId);
|
|
26977
|
+
this.emitTrace({
|
|
26978
|
+
type: 'dendrite:mail-delivered',
|
|
26979
|
+
mailId: stamp.mailId,
|
|
26980
|
+
kind: String(stamp.kind ?? 'message'),
|
|
26981
|
+
from: String(stamp.from?.agent ?? ''),
|
|
26982
|
+
to: recipient,
|
|
26983
|
+
});
|
|
26984
|
+
}
|
|
24531
26985
|
/**
|
|
24532
26986
|
* Splice out the deferred messages addressed to `agentName` (explicitly
|
|
24533
26987
|
* via forAgent, or implicitly when it is the primary), leaving other
|
|
@@ -24968,6 +27422,8 @@ export class AgentFramework {
|
|
|
24968
27422
|
flushForStart(agent, occasion) {
|
|
24969
27423
|
const stored = this.writeDrainedInOrder(this.drainDeferredFor(agent.name), `${occasion} flush for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
|
|
24970
27424
|
for (const [msg, { id }] of stored) {
|
|
27425
|
+
// Held mail whose message just landed is released (Dendrite).
|
|
27426
|
+
this.releaseLandedMail(msg.metadata, agent.name);
|
|
24971
27427
|
this.emitTrace({ type: 'message:added', messageId: id, source: `deferred-flush:${occasion}` });
|
|
24972
27428
|
this.noteSpokenTo(agent.name, msg.metadata);
|
|
24973
27429
|
this.noteArrival(agent.name, msg.metadata, id);
|
|
@@ -25540,7 +27996,7 @@ export class AgentFramework {
|
|
|
25540
27996
|
// target never read it (passesToPrimary); addMessage takes it off any
|
|
25541
27997
|
// other write.
|
|
25542
27998
|
const forRun = opts?.forAgent !== undefined
|
|
25543
|
-
&& (this.ephemeralRuns.has(opts.forAgent) || this.
|
|
27999
|
+
&& (this.ephemeralRuns.has(opts.forAgent) || this.registry.homeChannel(opts.forAgent) !== undefined);
|
|
25544
28000
|
const placement = {};
|
|
25545
28001
|
const id = this.addMessage(participant, content, forRun ? { ...metadata, [PRIMARY_IF_UNREAD_KEY]: true } : metadata, {
|
|
25546
28002
|
placement, ...(opts?.forAgent !== undefined ? { forAgent: opts.forAgent } : {}), ...(forRun ? { markedForRun: true } : {}),
|
|
@@ -25551,7 +28007,7 @@ export class AgentFramework {
|
|
|
25551
28007
|
* The writes the framework holds for the store `reader` reads, in queue
|
|
25552
28008
|
* order: those addressed to it and, when it's an ordinary resident, those
|
|
25553
28009
|
* addressed to any ordinary resident, since residents share one message
|
|
25554
|
-
* slot (
|
|
28010
|
+
* slot (`readsSharedSlot` in the registry). A write whose target is gone isn't among them:
|
|
25555
28011
|
* no module reads that agent's store, and the write waits for its window's
|
|
25556
28012
|
* release (drainDeferredFor) or, at its flush, is dropped (addMessage), a
|
|
25557
28013
|
* failed reply, or a channel notice its reader could act on, passing to the
|
|
@@ -25944,6 +28400,12 @@ export class AgentFramework {
|
|
|
25944
28400
|
bounded.error = boundFailureText(bounded.error);
|
|
25945
28401
|
if (typeof bounded.stack === 'string')
|
|
25946
28402
|
bounded.stack = boundFailureText(bounded.stack);
|
|
28403
|
+
if (this.earlyTraces) {
|
|
28404
|
+
if (this.earlyTraces.length < 1000)
|
|
28405
|
+
this.earlyTraces.push(traceEvent);
|
|
28406
|
+
else
|
|
28407
|
+
this.earlyTracesDropped++;
|
|
28408
|
+
}
|
|
25947
28409
|
// An event goes to the subscriptions present when its delivery starts,
|
|
25948
28410
|
// each one only if it is still active when its turn comes, as the DOM's
|
|
25949
28411
|
// EventTarget delivers: a subscription made during delivery waits for the
|
|
@@ -26565,10 +29027,11 @@ export class AgentFramework {
|
|
|
26565
29027
|
this.mcplPrefixMap.set(prefix, config.id);
|
|
26566
29028
|
this.setMcplServerConfig(config);
|
|
26567
29029
|
}
|
|
26568
|
-
//
|
|
26569
|
-
//
|
|
26570
|
-
|
|
26571
|
-
|
|
29030
|
+
// The server-level veto on waking (a per-server shouldTriggerInference).
|
|
29031
|
+
// The wake gate is deliberately NOT wired into the inbound lanes any
|
|
29032
|
+
// more: it runs in the admission stage, after the hold decision, so a
|
|
29033
|
+
// held message never forms a gate batch (#232 gap 3, root cause A).
|
|
29034
|
+
const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference;
|
|
26572
29035
|
// Push events handler (Step 6)
|
|
26573
29036
|
this.installPushCoalescer(coalescing);
|
|
26574
29037
|
this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter, (serverId, params, event) => this.handleCoalescedPush(serverId, params, event),
|
|
@@ -26601,13 +29064,29 @@ export class AgentFramework {
|
|
|
26601
29064
|
}
|
|
26602
29065
|
},
|
|
26603
29066
|
shouldTriggerInference: triggerFilter,
|
|
29067
|
+
latestInboundLocus: this.speechLocus === 'latest-inbound',
|
|
29068
|
+
// A tool close of the channel an all-except hold excepts ends that
|
|
29069
|
+
// hold through the cancel flow, since there is nothing left to speak
|
|
29070
|
+
// into. A tool close of a tuned-out channel is refused
|
|
29071
|
+
// before this hook is called (ChannelRegistry.tunedOutRefusal).
|
|
29072
|
+
onToolClose: (serverId, channelId) => {
|
|
29073
|
+
const coordinator = this.tuneOutCoordinator;
|
|
29074
|
+
if (!coordinator)
|
|
29075
|
+
return undefined;
|
|
29076
|
+
const scope = coordinator.getAllExcept();
|
|
29077
|
+
if (!scope || scope.params.except.serverId !== serverId || scope.params.except.channelId !== channelId)
|
|
29078
|
+
return undefined;
|
|
29079
|
+
coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
|
|
29080
|
+
return 'The focus on it ended all the same, and what the other channels held was delivered.';
|
|
29081
|
+
},
|
|
29082
|
+
focusChannelResolver: () => this.tuneOutCoordinator?.focusChannel() ?? null,
|
|
26604
29083
|
// Route a conversation fork's plain-text speech to its HOME channel, not
|
|
26605
29084
|
// the process-global most-recent-inbound locus (item 3). The trunk agent
|
|
26606
29085
|
// has no home entry, so this returns undefined and routeSpeech falls back
|
|
26607
|
-
// to defaultPublishChannel. `
|
|
29086
|
+
// to defaultPublishChannel. The registry's `homeChannel` is the permanent
|
|
26608
29087
|
// spawn-time binding; `channelForAgent` is the router's live binding as a
|
|
26609
29088
|
// belt-and-suspenders fallback.
|
|
26610
|
-
homeChannelResolver: (agentName) => this.
|
|
29089
|
+
homeChannelResolver: (agentName) => this.homeOf(agentName)
|
|
26611
29090
|
?? this.conversationRouter?.channelForAgent(agentName),
|
|
26612
29091
|
// A single TRUNK agent's channel for its CURRENT turn (item-3 redux):
|
|
26613
29092
|
// its locus once the turn has one, a re-pin's or a channel_open's
|
|
@@ -27436,6 +29915,9 @@ export class AgentFramework {
|
|
|
27436
29915
|
*/
|
|
27437
29916
|
makeHostPresentation(storePath) {
|
|
27438
29917
|
return new HostPresentation({
|
|
29918
|
+
// A derived agent showing its parent's tool list (toolSurfaceOwners) is
|
|
29919
|
+
// shown its parent's presentation too, so the two blocks stay one.
|
|
29920
|
+
ownerOf: (agentName) => this.toolSurfaceOwners.get(agentName) ?? agentName,
|
|
27439
29921
|
// As the agent is shown them: its description overrides applied.
|
|
27440
29922
|
available: (agentName) => (this.agents.has(agentName) ? this.compressionToolsForAgent(agentName) : this.getAllTools()),
|
|
27441
29923
|
placedInBuiltin: (name) => this.placedInBuiltin(name),
|
|
@@ -30445,7 +32927,7 @@ export class AgentFramework {
|
|
|
30445
32927
|
// ones; channel_open is rejected outright — opening channels mutates
|
|
30446
32928
|
// framework-global state (the open-channel set every agent's channel
|
|
30447
32929
|
// context is built from), which is not a fork's call to make.
|
|
30448
|
-
const home = this.
|
|
32930
|
+
const home = this.homeOf(agentName);
|
|
30449
32931
|
if (home) {
|
|
30450
32932
|
const reject = (error) => {
|
|
30451
32933
|
// RFC-007: the guard refuses before the tool runs — no lifecycle events.
|
|
@@ -30547,8 +33029,11 @@ export class AgentFramework {
|
|
|
30547
33029
|
const publisher = call.name === 'channel_publish' && callingTurn ? this.agents.get(agentName) : undefined;
|
|
30548
33030
|
const readThrough = publisher ? this.publishReadThrough(publisher, call.id) : null;
|
|
30549
33031
|
const published = {};
|
|
30550
|
-
|
|
30551
|
-
|
|
33032
|
+
const channelInput = call.name === 'channel_publish'
|
|
33033
|
+
? this.publishInputWithTurnLocus(call.input, agentName, callingTurn)
|
|
33034
|
+
: call.input;
|
|
33035
|
+
this.channelRegistry.handleChannelToolCall(call.name, channelInput, {
|
|
33036
|
+
kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(callingTurn ? { inTurn: true } : {}), ...(earlierWake ? { earlierWake } : {}),
|
|
30552
33037
|
}, published)
|
|
30553
33038
|
.then((result) => {
|
|
30554
33039
|
const destination = published.outcome?.status === 'delivered' ? published.outcome.destination : undefined;
|
|
@@ -30597,7 +33082,7 @@ export class AgentFramework {
|
|
|
30597
33082
|
&& this.openLogical.get(agentName) === callingTurn) {
|
|
30598
33083
|
callingTurn.locus = opened;
|
|
30599
33084
|
noteTurnChannel(callingTurn, opened);
|
|
30600
|
-
this.
|
|
33085
|
+
this.rememberAnnouncedLocus(agentName, opened);
|
|
30601
33086
|
const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(opened);
|
|
30602
33087
|
// The routing word comes first, ahead of any backscroll, so a
|
|
30603
33088
|
// result cut to its head for being long keeps it.
|
|
@@ -30847,6 +33332,26 @@ export class AgentFramework {
|
|
|
30847
33332
|
finish({ success: false, error: msg, isError: true });
|
|
30848
33333
|
}
|
|
30849
33334
|
}
|
|
33335
|
+
/**
|
|
33336
|
+
* A reader's own tool (SUBCONSCIOUS_TOOL_NAMES), for both entries that run
|
|
33337
|
+
* an agent's call: the dispatcher's, for the model's call, and
|
|
33338
|
+
* executeToolCallFrom's, for a host's or the puppet's. The tools are
|
|
33339
|
+
* advertised to residents so reader forks share their prefix, and only a
|
|
33340
|
+
* registered reader may use them. A rejection is answered with a failed
|
|
33341
|
+
* result, never left unhandled.
|
|
33342
|
+
*/
|
|
33343
|
+
readerToolCall(agentName, call) {
|
|
33344
|
+
const isReader = agentName === this.subconsciousAgentName || this.registry.get(agentName)?.kind === 'subconscious-fork';
|
|
33345
|
+
if (!this.tuneOutCoordinator || !isReader)
|
|
33346
|
+
return { refused: `${call.name} is available to the resident's reader, not to ${agentName}` };
|
|
33347
|
+
const epoch = this.registry.get(agentName)?.metadata?.epochId;
|
|
33348
|
+
const fork = agentName === this.subconsciousAgentName ? undefined : { epochId: typeof epoch === 'string' ? epoch : undefined };
|
|
33349
|
+
return {
|
|
33350
|
+
result: this.tuneOutCoordinator
|
|
33351
|
+
.handleSubconsciousTool(call.name, call.input, this.callOwnChannels(agentName, call), agentName, fork)
|
|
33352
|
+
.catch((err) => ({ success: false, error: thrownText(err), isError: true })),
|
|
33353
|
+
};
|
|
33354
|
+
}
|
|
30850
33355
|
/**
|
|
30851
33356
|
* Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
|
|
30852
33357
|
* suppression window, optionally announces in the sticky channel, and ends
|
|
@@ -30854,123 +33359,266 @@ export class AgentFramework {
|
|
|
30854
33359
|
*/
|
|
30855
33360
|
/** tune_out tool (residents): enter or cancel a channel tune-out. */
|
|
30856
33361
|
dispatchTuneOutToolCall(agentName, call) {
|
|
30857
|
-
const coordinator = this.tuneOutCoordinator;
|
|
30858
33362
|
const input = (call.input ?? {});
|
|
30859
|
-
const name = String(input.channelId ?? '');
|
|
30860
33363
|
// Inside the calling turn, a name one of the turn's own channels answers
|
|
30861
33364
|
// to by its id means that channel, as for the channel tools
|
|
30862
|
-
// (ChannelRegistry.ownChannelFor)
|
|
30863
|
-
//
|
|
30864
|
-
|
|
30865
|
-
|
|
30866
|
-
|
|
30867
|
-
|
|
30868
|
-
|
|
30869
|
-
|
|
30870
|
-
|
|
30871
|
-
|
|
33365
|
+
// (ChannelRegistry.ownChannelFor): read here, while the call is the
|
|
33366
|
+
// turn's, before the answer can wait.
|
|
33367
|
+
const own = this.callOwnChannels(agentName, call);
|
|
33368
|
+
const turn = this.callingTurnOf(agentName, call);
|
|
33369
|
+
// The answer can wait for the tune-out's transport (openForTuneOut), so
|
|
33370
|
+
// the result goes through pushEvent when it is known, as every other
|
|
33371
|
+
// tool's does; a rejection is answered too, never left unhandled.
|
|
33372
|
+
void (call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn))
|
|
33373
|
+
.catch((err) => ({ success: false, error: thrownText(err), isError: true }))
|
|
33374
|
+
.then((result) => {
|
|
33375
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result });
|
|
33376
|
+
});
|
|
33377
|
+
}
|
|
33378
|
+
/** `focus` is `tune_out` with scope "all-except". */
|
|
33379
|
+
handleFocusTool(agentName, input, own = [], turn) {
|
|
33380
|
+
return this.handleTuneOutTool(agentName, { ...input, scope: 'all-except' }, own, turn);
|
|
33381
|
+
}
|
|
33382
|
+
/**
|
|
33383
|
+
* A tune-out call's refusal for a channel it can't resolve: main's words
|
|
33384
|
+
* for a name no registered channel, and none of the calling turn's own,
|
|
33385
|
+
* answers to, and the registry's own otherwise, as for an id that is
|
|
33386
|
+
* ambiguous across servers (#232 gap 8).
|
|
33387
|
+
*/
|
|
33388
|
+
unknownChannelWords(channelId, resolution, own) {
|
|
33389
|
+
const known = (this.channelRegistry?.channelsNamed(channelId).length ?? 0) > 0
|
|
33390
|
+
|| own.some((c) => c.channelId === channelId);
|
|
33391
|
+
return known ? resolution : `unknown channel: ${channelId}`;
|
|
33392
|
+
}
|
|
33393
|
+
/**
|
|
33394
|
+
* The tune_out tool's body. Channel resolution is server-qualified
|
|
33395
|
+
* (ChannelRegistry.resolveChannel): with a `serverId`, that server's
|
|
33396
|
+
* channel with the id; inside the calling turn, a name one of the turn's
|
|
33397
|
+
* own channels answers to by its id means that channel; otherwise the one
|
|
33398
|
+
* channel the name means, which may be server-qualified, and a name two
|
|
33399
|
+
* servers' channels answer to is refused with them as candidates (#232
|
|
33400
|
+
* gap 8). `turn` is the turn that made the call (callingTurnOf), the only
|
|
33401
|
+
* one whose speech an all-except entry moves.
|
|
33402
|
+
*/
|
|
33403
|
+
async handleTuneOutTool(agentName, input, own = [], turn) {
|
|
33404
|
+
const coordinator = this.tuneOutCoordinator;
|
|
33405
|
+
const channelRegistry = this.channelRegistry;
|
|
33406
|
+
if (!coordinator || !channelRegistry) {
|
|
33407
|
+
return { success: false, error: 'tune-out is not available (no MCPL channels)', isError: true };
|
|
33408
|
+
}
|
|
33409
|
+
// An open that throws is answered too, never left unhandled.
|
|
33410
|
+
const openThrew = (err) => 'The tune-out is recorded, but its messages may not reach the subconscious, since opening its channel ' +
|
|
33411
|
+
`failed: ${thrownText(err)}`;
|
|
33412
|
+
const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined;
|
|
33413
|
+
const allExcept = input.scope === 'all-except';
|
|
33414
|
+
if (input.mode === 'check') {
|
|
33415
|
+
// A peek's name means what the other modes' names mean: the one
|
|
33416
|
+
// channel it picks, by its id or the server-qualified name the agent
|
|
33417
|
+
// is shown where another server shares the id, or inside the turn one
|
|
33418
|
+
// of the turn's own channels (resolveChannel), and its lines are that
|
|
33419
|
+
// channel's, on its server.
|
|
33420
|
+
let peek;
|
|
33421
|
+
if (typeof input.peek === 'string' && input.peek) {
|
|
33422
|
+
const resolved = channelRegistry.resolveChannel(input.peek, serverId, own);
|
|
33423
|
+
if ('error' in resolved)
|
|
33424
|
+
return { success: false, error: this.unknownChannelWords(input.peek, resolved.error, own), isError: true };
|
|
33425
|
+
peek = { serverId: resolved.serverId, channelId: resolved.channelId };
|
|
33426
|
+
}
|
|
33427
|
+
// Read as the other options are (readOptions): a numeric string is its
|
|
33428
|
+
// number, and a value that gives none is refused, named.
|
|
33429
|
+
const peekLimit = input.peekLimit == null ? undefined : Number(input.peekLimit);
|
|
33430
|
+
if (peekLimit !== undefined && !Number.isFinite(peekLimit)) {
|
|
33431
|
+
const got = typeof input.peekLimit === 'string' ? `the string ${JSON.stringify(input.peekLimit)}` : `the value ${JSON.stringify(input.peekLimit)}`;
|
|
33432
|
+
return { success: false, error: `Nothing was checked: peekLimit must be a finite number of lines, and it was ${got}. Leave it out for 5.`, isError: true };
|
|
33433
|
+
}
|
|
33434
|
+
return { success: true, data: coordinator.check({ ...(peek ? { peek } : {}), ...(peekLimit !== undefined ? { peekLimit } : {}) }) };
|
|
33435
|
+
}
|
|
33436
|
+
if (input.mode === 'cancel' && (allExcept || (!input.channelId && coordinator.getAllExcept()))) {
|
|
33437
|
+
const r = coordinator.cancelAllExcept('agent-tool', 'agent request');
|
|
33438
|
+
return r.ok ? { success: true, data: { cancelled: 'all-except' } } : { success: false, error: r.error, isError: true };
|
|
33439
|
+
}
|
|
33440
|
+
const channelId = String(input.channelId ?? '');
|
|
33441
|
+
const resolved = channelRegistry.resolveChannel(channelId, serverId, own);
|
|
33442
|
+
if ('error' in resolved)
|
|
33443
|
+
return { success: false, error: this.unknownChannelWords(channelId, resolved.error, own), isError: true };
|
|
30872
33444
|
// The name the agent knows the channel by: its id, or where another
|
|
30873
33445
|
// server shares the id, its server-qualified name. An answer that waits
|
|
30874
33446
|
// for the transport reads it again then, since a registration meanwhile
|
|
30875
33447
|
// can change which name picks the channel.
|
|
30876
|
-
const shownNow = () =>
|
|
30877
|
-
|
|
30878
|
-
|
|
30879
|
-
|
|
30880
|
-
|
|
30881
|
-
`failed: ${thrownText(err)}`;
|
|
30882
|
-
if (shared) {
|
|
30883
|
-
result = { success: false, error: shared, isError: true };
|
|
30884
|
-
}
|
|
30885
|
-
else if (!channel) {
|
|
30886
|
-
result = { success: false, error: `unknown channel: ${name}`, isError: true };
|
|
30887
|
-
}
|
|
30888
|
-
else if (input.mode === 'cancel') {
|
|
30889
|
-
const r = coordinator.cancel(channel.serverId, channelId, 'agent-tool', 'agent request');
|
|
30890
|
-
result = r.ok
|
|
30891
|
-
? { success: true, data: { cancelled: shown } }
|
|
33448
|
+
const shownNow = () => channelRegistry.nameShown(resolved);
|
|
33449
|
+
if (input.mode === 'cancel') {
|
|
33450
|
+
const r = coordinator.cancel(resolved.serverId, resolved.channelId, 'agent-tool', 'agent request');
|
|
33451
|
+
return r.ok
|
|
33452
|
+
? { success: true, data: { cancelled: shownNow() } }
|
|
30892
33453
|
: { success: false, error: r.error, isError: true };
|
|
30893
33454
|
}
|
|
30894
|
-
|
|
30895
|
-
const r = coordinator.
|
|
33455
|
+
if (allExcept) {
|
|
33456
|
+
const r = coordinator.enterAllExcept(resolved, {
|
|
30896
33457
|
cadenceSeconds: input.cadenceSeconds,
|
|
30897
33458
|
backlogCap: input.backlogCap,
|
|
30898
33459
|
maxWakes: input.maxWakes,
|
|
30899
33460
|
durationSeconds: input.durationSeconds,
|
|
30900
33461
|
}, 'agent-tool');
|
|
30901
|
-
if (r.ok)
|
|
30902
|
-
|
|
33462
|
+
if (!r.ok)
|
|
33463
|
+
return { success: false, error: r.error, isError: true };
|
|
33464
|
+
const speaker = this.agents.get(agentName);
|
|
33465
|
+
const home = this.homeOf(agentName);
|
|
33466
|
+
const open = turn && this.openLogical.get(agentName) === turn ? turn : undefined;
|
|
33467
|
+
if (open && open.locus == null && !open.noChannel && !open.heartbeat && !home
|
|
33468
|
+
&& (speaker?.proseRouting === 'locus' || speaker?.proseRouting === 'hybrid')) {
|
|
33469
|
+
open.locus = resolved;
|
|
33470
|
+
noteTurnChannel(open, resolved);
|
|
33471
|
+
this.rememberAnnouncedLocus(agentName, resolved);
|
|
33472
|
+
}
|
|
33473
|
+
const speech = home
|
|
33474
|
+
? `Your plain speech stays in ${this.channelShown(home)}, your conversation's channel.`
|
|
33475
|
+
: open?.locus && !sameChannel(open.locus, resolved)
|
|
33476
|
+
? `This turn's plain speech still lands in ${this.channelShown(open.locus)}; from your next turn it lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's.`
|
|
33477
|
+
: open && (open.noChannel || open.heartbeat)
|
|
33478
|
+
? "This turn has no channel, so its plain speech goes to none; from your next turn it lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's."
|
|
33479
|
+
: "Your plain speech lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's.";
|
|
33480
|
+
return {
|
|
33481
|
+
success: true,
|
|
33482
|
+
data: {
|
|
33483
|
+
scope: 'all-except',
|
|
33484
|
+
except: shownNow(),
|
|
33485
|
+
announcedIn: coordinator.announces() ? shownNow() : null,
|
|
33486
|
+
expiresAtMs: r.params.expiresAtMs,
|
|
30903
33487
|
cadenceSeconds: r.params.cadenceSeconds,
|
|
30904
33488
|
backlogCap: r.params.backlogCap,
|
|
30905
33489
|
maxWakes: r.params.maxWakes,
|
|
30906
|
-
|
|
30907
|
-
|
|
30908
|
-
|
|
30909
|
-
|
|
30910
|
-
|
|
30911
|
-
|
|
30912
|
-
|
|
30913
|
-
|
|
30914
|
-
|
|
30915
|
-
|
|
30916
|
-
|
|
30917
|
-
|
|
30918
|
-
|
|
30919
|
-
type: 'tool-result',
|
|
30920
|
-
callId: call.id,
|
|
30921
|
-
toolName: call.name,
|
|
30922
|
-
agentName,
|
|
30923
|
-
moduleName: 'tune-out',
|
|
30924
|
-
result: { success: true, data: { tunedOut: shownNow(), ...data, ...(openError ? { openError } : {}) } },
|
|
30925
|
-
});
|
|
30926
|
-
});
|
|
30927
|
-
return;
|
|
30928
|
-
}
|
|
33490
|
+
note: coordinator.hasReader()
|
|
33491
|
+
? `${speech} Reader wakes pause, not cancel, when the budget is spent.`
|
|
33492
|
+
: `No subconscious is configured: this is a silent hold. ${speech} The backlog is delivered per channel when you cancel or the duration ends.`,
|
|
33493
|
+
},
|
|
33494
|
+
};
|
|
33495
|
+
}
|
|
33496
|
+
const r = coordinator.enter(resolved.serverId, resolved.channelId, {
|
|
33497
|
+
cadenceSeconds: input.cadenceSeconds,
|
|
33498
|
+
backlogCap: input.backlogCap,
|
|
33499
|
+
maxWakes: input.maxWakes,
|
|
33500
|
+
durationSeconds: input.durationSeconds,
|
|
33501
|
+
}, 'agent-tool');
|
|
33502
|
+
if (!r.ok) {
|
|
30929
33503
|
// tune_out repeated on a channel already tuned out is where an agent
|
|
30930
33504
|
// retries a transport the first open left closed, so where it isn't
|
|
30931
33505
|
// open, or work is queued for it, the repeat goes through
|
|
30932
33506
|
// openForTuneOut, which joins the open in flight or waits and, where
|
|
30933
33507
|
// this host can open the channel, opens it again; the refusal says
|
|
30934
33508
|
// what its transport is now.
|
|
30935
|
-
const tuneOut =
|
|
30936
|
-
if (r.alreadyTunedOut && tuneOut && !
|
|
30937
|
-
|
|
30938
|
-
|
|
30939
|
-
|
|
30940
|
-
|
|
30941
|
-
|
|
30942
|
-
|
|
30943
|
-
|
|
30944
|
-
|
|
30945
|
-
|
|
30946
|
-
|
|
30947
|
-
|
|
30948
|
-
|
|
30949
|
-
|
|
30950
|
-
|
|
30951
|
-
|
|
30952
|
-
|
|
30953
|
-
|
|
30954
|
-
|
|
30955
|
-
|
|
30956
|
-
|
|
30957
|
-
|
|
30958
|
-
|
|
30959
|
-
|
|
30960
|
-
|
|
30961
|
-
|
|
30962
|
-
|
|
30963
|
-
|
|
30964
|
-
|
|
30965
|
-
|
|
30966
|
-
|
|
33509
|
+
const tuneOut = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId);
|
|
33510
|
+
if (r.alreadyTunedOut && tuneOut && !channelRegistry.tuneOutTransportOpen(resolved.serverId, resolved.channelId)) {
|
|
33511
|
+
const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew);
|
|
33512
|
+
// The tune-out is read again: one that ended or was replaced while
|
|
33513
|
+
// the open was out isn't described as standing, whatever the open
|
|
33514
|
+
// answered for it.
|
|
33515
|
+
const now = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId);
|
|
33516
|
+
const error = now?.params.epochId === tuneOut.params.epochId
|
|
33517
|
+
// A name in a sentence is quoted as tune_out takes it back (main's #852).
|
|
33518
|
+
? alreadyTunedOutText(channelRegistry.quotedShown(resolved), now.params, openError ?? 'Its transport is open now, so its messages reach the subconscious.')
|
|
33519
|
+
: TUNE_OUT_REPLACED;
|
|
33520
|
+
return { success: false, error, isError: true };
|
|
33521
|
+
}
|
|
33522
|
+
return { success: false, error: r.error, isError: true };
|
|
33523
|
+
}
|
|
33524
|
+
// A tuned-out channel's traffic must keep arriving for its reader, so the
|
|
33525
|
+
// answer waits for its transport (openForTuneOut). The tune-out is
|
|
33526
|
+
// recorded either way, so it's a success, and where the transport isn't
|
|
33527
|
+
// open, openError says why its messages don't or may not reach the
|
|
33528
|
+
// reader, and when they will where that's known, or that the tune-out
|
|
33529
|
+
// isn't in effect any more.
|
|
33530
|
+
const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew);
|
|
33531
|
+
return {
|
|
33532
|
+
success: true,
|
|
33533
|
+
data: {
|
|
33534
|
+
tunedOut: shownNow(),
|
|
33535
|
+
cadenceSeconds: r.params.cadenceSeconds,
|
|
33536
|
+
backlogCap: r.params.backlogCap,
|
|
33537
|
+
maxWakes: r.params.maxWakes,
|
|
33538
|
+
...(openError ? { openError } : {}),
|
|
33539
|
+
...(coordinator.hasReader()
|
|
33540
|
+
? {}
|
|
33541
|
+
: { note: 'No subconscious is configured: this is a silent hold. Nothing summarizes or wakes you; the backlog is delivered when you cancel.' }),
|
|
33542
|
+
},
|
|
33543
|
+
};
|
|
33544
|
+
}
|
|
33545
|
+
/**
|
|
33546
|
+
* The admission stage for one inbound event: the pure decision over the
|
|
33547
|
+
* framework's policy inputs (recipients, the hold in force, the wake
|
|
33548
|
+
* gate), traced. Callers apply it; nothing downstream re-asks.
|
|
33549
|
+
*/
|
|
33550
|
+
admitInbound(env) {
|
|
33551
|
+
const gateInfo = (e) => ({
|
|
33552
|
+
content: e.gateContent,
|
|
33553
|
+
eventType: String(e.gateMetadata.eventType ?? 'unknown'),
|
|
33554
|
+
serverId: String(e.gateMetadata.serverId ?? e.serverId),
|
|
33555
|
+
channelId: typeof e.gateMetadata.channelId === 'string' ? e.gateMetadata.channelId : '',
|
|
33556
|
+
metadata: e.gateMetadata,
|
|
33557
|
+
tags: Array.isArray(e.gateMetadata.tags) ? e.gateMetadata.tags : undefined,
|
|
33558
|
+
});
|
|
33559
|
+
const decision = admit(env, {
|
|
33560
|
+
untargetedRecipients: () => this.registry.untargetedRecipients(),
|
|
33561
|
+
holdFor: (serverId, channelId) => {
|
|
33562
|
+
const hold = this.tuneOutCoordinator?.holdFor(serverId, channelId) ?? null;
|
|
33563
|
+
return hold ? { kind: 'tune-out', epochId: hold.epochId, serverId, channelId } : null;
|
|
33564
|
+
},
|
|
33565
|
+
...(this.eventGate
|
|
33566
|
+
? {
|
|
33567
|
+
gate: {
|
|
33568
|
+
evaluate: (e) => this.eventGate.evaluate(gateInfo(e)).trigger,
|
|
33569
|
+
// "Would the gate have woken them": a debounce policy says yes,
|
|
33570
|
+
// later — for the hold's reader that is a pass, not a mute.
|
|
33571
|
+
probe: (e) => {
|
|
33572
|
+
const d = this.eventGate.probe(gateInfo(e));
|
|
33573
|
+
return d.trigger || (typeof d.behavior === 'object' && d.behavior !== null && 'debounce' in d.behavior);
|
|
33574
|
+
},
|
|
33575
|
+
},
|
|
33576
|
+
}
|
|
33577
|
+
: {}),
|
|
30967
33578
|
});
|
|
33579
|
+
this.emitTrace({
|
|
33580
|
+
type: 'admission:decided',
|
|
33581
|
+
lane: env.lane,
|
|
33582
|
+
serverId: env.serverId,
|
|
33583
|
+
channelId: env.channelId,
|
|
33584
|
+
messageId: env.messageId,
|
|
33585
|
+
recipients: decision.recipients,
|
|
33586
|
+
held: decision.held ? decision.held.epochId : null,
|
|
33587
|
+
wake: decision.wake,
|
|
33588
|
+
gatePass: decision.gatePass,
|
|
33589
|
+
});
|
|
33590
|
+
return decision;
|
|
33591
|
+
}
|
|
33592
|
+
/**
|
|
33593
|
+
* Make delivered messages durable now, including those the delivery left
|
|
33594
|
+
* in the deferred queue: a tune-out release flips durable epoch state
|
|
33595
|
+
* first, so its dump must not be lost to a crash in between (#232 gap 6).
|
|
33596
|
+
*/
|
|
33597
|
+
commitDeliveries() {
|
|
33598
|
+
if (this.deferredMessages.length > 0) {
|
|
33599
|
+
this.deferredWritesPersisted = true;
|
|
33600
|
+
this.persistDeferredWrites();
|
|
33601
|
+
}
|
|
33602
|
+
try {
|
|
33603
|
+
this.store.sync();
|
|
33604
|
+
}
|
|
33605
|
+
catch (err) {
|
|
33606
|
+
console.error('[tune-out] could not sync the store after a release:', err);
|
|
33607
|
+
}
|
|
33608
|
+
}
|
|
33609
|
+
/** The history-reading tool the primary resident actually has, if any (#232 gap 11). */
|
|
33610
|
+
residentHistoryTool() {
|
|
33611
|
+
const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined;
|
|
33612
|
+
if (!primary)
|
|
33613
|
+
return null;
|
|
33614
|
+
const names = this.getAllTools().map((t) => t.name).filter((n) => primary.canUseTool(n));
|
|
33615
|
+
return names.find((n) => /(^|--)(fetch_history|channel_read|read_history|history)$/.test(n)) ?? null;
|
|
30968
33616
|
}
|
|
30969
33617
|
dispatchSleepToolCall(agentName, call) {
|
|
30970
33618
|
// The gate's sleep quiets the whole agent, every conversation of it, so a
|
|
30971
33619
|
// conversation fork or an ephemeral run neither sets one nor ends the
|
|
30972
33620
|
// agent's own. Refused before the tool runs, as a fork's channel_open is.
|
|
30973
|
-
const fork = this.
|
|
33621
|
+
const fork = this.registry.homeChannel(agentName) !== undefined;
|
|
30974
33622
|
if (fork || this.ephemeralRuns.has(agentName)) {
|
|
30975
33623
|
const one = fork ? 'conversation' : 'run';
|
|
30976
33624
|
// Only where the fork can follow it: skip_reply needs the channel
|