@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/src/framework.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue, type PresentationSnapshot } 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, type RegistryJournal, type ResultClaims, type ScriptLine, type ServerAddress } from './mcpl/references.js';
|
|
@@ -14,8 +15,16 @@ import { classifyFailure, retryDecision, MEMBRANE_RETRY_SETTINGS, type RetrySett
|
|
|
14
15
|
import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager';
|
|
15
16
|
import type { CacheWireReceipt } from './kv-unified-wire.js';
|
|
16
17
|
import { SILENT_HEARTBEAT_SEPARATOR, type RequestOnlyPrompt, type RequestOnlyStamps } from './silent-heartbeat.js';
|
|
17
|
-
import {
|
|
18
|
-
|
|
18
|
+
import {
|
|
19
|
+
SUBCONSCIOUS_TOOLS,
|
|
20
|
+
SUBCONSCIOUS_TOOL_NAMES,
|
|
21
|
+
defaultReaderFraming,
|
|
22
|
+
type SubconsciousConfig,
|
|
23
|
+
type PersistentReaderConfig,
|
|
24
|
+
type ForkReaderConfig,
|
|
25
|
+
} from './tune-out/tools.js';
|
|
26
|
+
import { TuneOutCoordinator, TUNE_OUT_DEFAULTS, alreadyTunedOutText, type QueuedHeldMessage, type ReaderInvocation } from './tune-out/coordinator.js';
|
|
27
|
+
import { admit, type InboundEnvelope, type AdmissionDecision } from './admission/admit.js';
|
|
19
28
|
import type {
|
|
20
29
|
MessageId,
|
|
21
30
|
MessageMetadata,
|
|
@@ -106,6 +115,24 @@ import { InferenceRouter } from './mcpl/inference-router.js';
|
|
|
106
115
|
import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, NO_CHANNEL_EXAMPLE, NO_PERSON_EXAMPLE, qualifiedChannelName, sameChannel, type ChannelRef, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js';
|
|
107
116
|
import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftState, type InheritedRisk } from './prose-drafts.js';
|
|
108
117
|
import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
|
|
118
|
+
import {
|
|
119
|
+
AgentRegistry,
|
|
120
|
+
conversationForkSpec,
|
|
121
|
+
residentSpec,
|
|
122
|
+
subconsciousSpec,
|
|
123
|
+
taskForkSpec,
|
|
124
|
+
workerSpec,
|
|
125
|
+
} from './dendrite/index.js';
|
|
126
|
+
import type {
|
|
127
|
+
ActivationRecord,
|
|
128
|
+
AgentEndReason,
|
|
129
|
+
AgentRecord,
|
|
130
|
+
AgentSpec,
|
|
131
|
+
EndOutcome,
|
|
132
|
+
HeldMail,
|
|
133
|
+
PolicyEpoch,
|
|
134
|
+
RegistrySnapshot,
|
|
135
|
+
} from './dendrite/index.js';
|
|
109
136
|
import { INBOUND_SOURCE_KEY, sourceBodyDigest, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js';
|
|
110
137
|
import { markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from './supplied-text.js';
|
|
111
138
|
import { NOTICE_TAG } from './notice-tags.js';
|
|
@@ -173,7 +200,7 @@ import { splitThinkingDrops, ThinkingLossIncidents, type PlacedDrop, type Stream
|
|
|
173
200
|
import { asNextRequestCarries, roundRows, type RoundRow, type SentRow } from './sent-rows.js';
|
|
174
201
|
import { ReasoningLossTeller } from './reasoning-loss.js';
|
|
175
202
|
import { contentAsStored, nameAsStored } from './text-as-stored.js';
|
|
176
|
-
import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isStoredKind, storedInjectionMarkOf, unitHash, type StoredInjectionMark } from './stored-injections.js';
|
|
203
|
+
import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isOwnInjection, isStoredKind, storedInjectionMarkOf, unitHash, type StoredInjectionMark } from './stored-injections.js';
|
|
177
204
|
|
|
178
205
|
/** An MCPL tool call refused because its server isn't in the registry: one
|
|
179
206
|
* closed for good, one removed, or one whose connect failed without
|
|
@@ -373,10 +400,11 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread';
|
|
|
373
400
|
* a deferred write, `external` a module's own row for an outside message,
|
|
374
401
|
* `openaiResponsesItems` membrane's mark of an imported Responses
|
|
375
402
|
* history, `contextInjection` an agent's stored context injection
|
|
376
|
-
* (stored-injections.ts), which every other agent's view leaves out,
|
|
403
|
+
* (stored-injections.ts), which every other agent's view leaves out,
|
|
377
404
|
* `primaryIfUnread` a module's write for an ephemeral run or a
|
|
378
405
|
* conversation fork, which goes to the primary if its target never read it
|
|
379
|
-
* (passesToPrimary)
|
|
406
|
+
* (passesToPrimary), and `dendrite` the stamp of delivered agent mail, whose
|
|
407
|
+
* `mailId` releases that held mail once the row lands (releaseLandedMail). A
|
|
380
408
|
* server's metadata, a channel message's `metadata` or a
|
|
381
409
|
* push event's `origin`, is stored without them, and the host writes its own
|
|
382
410
|
* after it: MCPL SPEC §16.6 makes metadata the producer's untrusted claims,
|
|
@@ -387,7 +415,7 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread';
|
|
|
387
415
|
*/
|
|
388
416
|
const HOST_METADATA_KEYS = [
|
|
389
417
|
'system', 'kind', 'draftOwner', 'draftIds', 'tuneOut', 'coalescingSubject', 'firstView',
|
|
390
|
-
'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY,
|
|
418
|
+
'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY, 'dendrite',
|
|
391
419
|
] as const;
|
|
392
420
|
|
|
393
421
|
/**
|
|
@@ -655,16 +683,18 @@ function mcplToolNoWordsText(serverId: string, callName: string): string {
|
|
|
655
683
|
}
|
|
656
684
|
|
|
657
685
|
/**
|
|
658
|
-
* True when an incoming channel message explicitly addressed the agent
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
686
|
+
* True when an incoming channel message explicitly addressed the agent: the
|
|
687
|
+
* MCPL RFC-001 `chat:addressed` tag (mention / reply / DM, as classified by
|
|
688
|
+
* the server). Tag-only — there is no metadata fallback, so a server that
|
|
689
|
+
* does not tag never addresses. Addressed messages outrank ambient chatter
|
|
662
690
|
* when picking a turn's frozen speech locus.
|
|
663
691
|
*/
|
|
664
|
-
const isAddressedMessage = (
|
|
665
|
-
tags
|
|
666
|
-
|
|
667
|
-
|
|
692
|
+
const isAddressedMessage = (tags?: string[]): boolean =>
|
|
693
|
+
Array.isArray(tags) && tags.includes('chat:addressed');
|
|
694
|
+
|
|
695
|
+
/** The author a channel message names, for a notice about it. */
|
|
696
|
+
const authorName = (metadata: Record<string, unknown> | undefined): string =>
|
|
697
|
+
(metadata?.author as { name?: string } | undefined)?.name ?? 'Someone';
|
|
668
698
|
|
|
669
699
|
/** A wake's `ambient` mark: only a message its server tagged `chat:ambient`
|
|
670
700
|
* (RFC-001) is known to ask nothing of the agent. */
|
|
@@ -843,7 +873,7 @@ import { HostPresentation, BUILTIN_TOOLSET, type HostActTarget } from './host-pr
|
|
|
843
873
|
import { isEmptyChange, type Presence } from './mcpl/tool-presence.js';
|
|
844
874
|
import { ToolsetsModule, resolveAct, withLookHint, type TakenChange } from './mcpl/toolsets-module.js';
|
|
845
875
|
import { ModernMcpConnection } from './mcpl/modern-connection.js';
|
|
846
|
-
import { WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js';
|
|
876
|
+
import { MAX_TIMER_MS, WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js';
|
|
847
877
|
import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js';
|
|
848
878
|
import { boundedForLog, describeCause } from './mcpl/errors.js';
|
|
849
879
|
import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js';
|
|
@@ -860,7 +890,7 @@ import type {
|
|
|
860
890
|
ChannelsChangedParams,
|
|
861
891
|
ChannelsIncomingParams,
|
|
862
892
|
} from './mcpl/types.js';
|
|
863
|
-
import type { ContextInjection, ThinkingBinding } from '@animalabs/context-manager';
|
|
893
|
+
import type { ContextInjection, ContextStrategy, ThinkingBinding } from '@animalabs/context-manager';
|
|
864
894
|
import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
|
|
865
895
|
import { CLOCK_ROW_KIND, clockStampOf, planClock, takesClockBefore } from './clock-rows.js';
|
|
866
896
|
import { firstFreeBranchName } from './branch-names.js';
|
|
@@ -902,6 +932,12 @@ import {
|
|
|
902
932
|
} from './operator-log.js';
|
|
903
933
|
|
|
904
934
|
const FRAMEWORK_STATE_ID = 'framework/state';
|
|
935
|
+
/** Dendrite agent registry: live records, incarnation counters, held mail. */
|
|
936
|
+
const AGENT_REGISTRY_STATE_ID = 'framework/dendrite';
|
|
937
|
+
/** Dendrite agent registry: append-only history of ended agent records. */
|
|
938
|
+
const AGENT_REGISTRY_ENDED_ID = 'framework/dendrite/ended';
|
|
939
|
+
/** How many ended agent records are reloaded for inspection at boot. */
|
|
940
|
+
const AGENT_REGISTRY_ENDED_RELOAD = 256;
|
|
905
941
|
/** Snapshot slot mirroring context writes deferred while quiesced (#122). */
|
|
906
942
|
const DEFERRED_WRITES_ID = 'framework/deferred-writes';
|
|
907
943
|
/** What a write of the deferred-write queue did: wrote the record (the
|
|
@@ -1086,11 +1122,12 @@ function describeDeferredWrite(msg: { id: string; participant: string; content:
|
|
|
1086
1122
|
/** Whether a deferred message can join a live stream at a tool boundary:
|
|
1087
1123
|
* no tool blocks (the membrane's tool cycle) and not named as the agent
|
|
1088
1124
|
* itself (it would render as an assistant turn, a prefill). */
|
|
1089
|
-
function isInjectableMidTurn(msg: { participant: string; content: ContentBlock[] },
|
|
1090
|
-
// Exact, as membrane's role test is:
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
|
|
1125
|
+
function isInjectableMidTurn(msg: { participant: string; content: ContentBlock[] }, agent: Pick<Agent, 'isOwnParticipant'>): boolean {
|
|
1126
|
+
// Exact, as membrane's role test is: a participant the request assigns
|
|
1127
|
+
// the assistant role (the agent's name, and for a derived agent the
|
|
1128
|
+
// voice it inherited) renders as the assistant. It's the name as sent,
|
|
1129
|
+
// which the boundary takes as the store keeps it.
|
|
1130
|
+
return !agent.isOwnParticipant(nameAsStored(msg.participant))
|
|
1094
1131
|
&& !msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
|
|
1095
1132
|
}
|
|
1096
1133
|
|
|
@@ -1447,6 +1484,13 @@ interface TurnRouting {
|
|
|
1447
1484
|
* retracted, isn't there for the agent to answer.
|
|
1448
1485
|
*/
|
|
1449
1486
|
arrivals: Arrival[];
|
|
1487
|
+
/**
|
|
1488
|
+
* Under `speech.locus: 'delivery'`, the channels the turn has already told
|
|
1489
|
+
* the agent it was addressed from while busy, by destinationKey (one
|
|
1490
|
+
* notice per channel per turn; a retry or restart continues the turn and
|
|
1491
|
+
* doesn't repeat it).
|
|
1492
|
+
*/
|
|
1493
|
+
addressedElsewhere: Set<string>;
|
|
1450
1494
|
heard?: Map<MessageId, number>;
|
|
1451
1495
|
}
|
|
1452
1496
|
|
|
@@ -1455,6 +1499,8 @@ interface Arrival {
|
|
|
1455
1499
|
messageId: MessageId;
|
|
1456
1500
|
/** The channel it re-pins the locus to. */
|
|
1457
1501
|
channel: ChannelRef;
|
|
1502
|
+
/** It addressed the agent (`chat:addressed`), rather than following up in an engaged channel. */
|
|
1503
|
+
addressed: boolean;
|
|
1458
1504
|
}
|
|
1459
1505
|
|
|
1460
1506
|
/** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
|
|
@@ -1472,6 +1518,7 @@ function freshTurnRouting(): TurnRouting {
|
|
|
1472
1518
|
engaged: new Set(),
|
|
1473
1519
|
wasIn: [],
|
|
1474
1520
|
continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], heard: new Map(),
|
|
1521
|
+
addressedElsewhere: new Set(),
|
|
1475
1522
|
};
|
|
1476
1523
|
}
|
|
1477
1524
|
|
|
@@ -2633,6 +2680,136 @@ interface EphemeralRun {
|
|
|
2633
2680
|
inferenceStarted: boolean;
|
|
2634
2681
|
lastActivity: number;
|
|
2635
2682
|
toolCallsCount: number;
|
|
2683
|
+
/** Why the run ended, for the registry record. Unset = it failed. */
|
|
2684
|
+
endReason?: AgentEndReason;
|
|
2685
|
+
/** Who ended it, when it was stopped from outside. */
|
|
2686
|
+
endedBy?: string;
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
/**
|
|
2690
|
+
* The admission record on a stored message: it was held from the residents
|
|
2691
|
+
* by a policy epoch (`metadata.tuneOut = { epochId }`). ONE predicate for
|
|
2692
|
+
* "held from the resident", read by the compiled-view filter and by the
|
|
2693
|
+
* mid-turn injection alike (#232 H1), so the window and the wire agree.
|
|
2694
|
+
*/
|
|
2695
|
+
export const heldFromResidents = (metadata: Record<string, unknown> | undefined): boolean =>
|
|
2696
|
+
!!(metadata as { tuneOut?: unknown } | undefined)?.tuneOut;
|
|
2697
|
+
|
|
2698
|
+
/**
|
|
2699
|
+
* What a reader of the residents' shared message slot does not see: held
|
|
2700
|
+
* messages. Used by every resident, and by a context derived from one when
|
|
2701
|
+
* it is reopened.
|
|
2702
|
+
*/
|
|
2703
|
+
const RESIDENT_VIEW_FILTER = (message: StoredMessage): boolean =>
|
|
2704
|
+
!heldFromResidents(message.metadata as Record<string, unknown> | undefined);
|
|
2705
|
+
|
|
2706
|
+
/** The context-manager surface shared inheritance needs; absent on versions without derivation. */
|
|
2707
|
+
interface DerivingContextManager {
|
|
2708
|
+
derive(options: {
|
|
2709
|
+
branch: string;
|
|
2710
|
+
strategy: ContextStrategy;
|
|
2711
|
+
membrane?: Membrane;
|
|
2712
|
+
solve?: 'reuse' | 'fresh';
|
|
2713
|
+
inherit?: { refusals?: boolean };
|
|
2714
|
+
atSequence?: number;
|
|
2715
|
+
viewFilter?: (message: StoredMessage) => boolean;
|
|
2716
|
+
debugLogContext?: boolean;
|
|
2717
|
+
}): Promise<ContextManager>;
|
|
2718
|
+
getDerivation(): ContextDerivationRecord | null;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
/** context-manager's plain-data record of a derivation (see its ContextDerivation). */
|
|
2722
|
+
interface ContextDerivationRecord {
|
|
2723
|
+
parentBranch: string;
|
|
2724
|
+
branch: string;
|
|
2725
|
+
atSequence: number;
|
|
2726
|
+
solve: 'reuse' | 'fresh';
|
|
2727
|
+
slots?: { messageNamespace: string | null; contextNamespace: string | null };
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/** `ContextManager.reopenDerived`, absent on versions without derivation. */
|
|
2731
|
+
type ReopenDerived = (options: {
|
|
2732
|
+
store: JsStore;
|
|
2733
|
+
derivation: ContextDerivationRecord;
|
|
2734
|
+
strategy?: ContextStrategy;
|
|
2735
|
+
membrane?: Membrane;
|
|
2736
|
+
viewFilter?: (message: StoredMessage) => boolean;
|
|
2737
|
+
debugLogContext?: boolean;
|
|
2738
|
+
}) => Promise<ContextManager>;
|
|
2739
|
+
|
|
2740
|
+
/** Options for {@link AgentFramework.deriveAgent}. */
|
|
2741
|
+
export interface DeriveAgentOptions {
|
|
2742
|
+
/** The new agent's name. Single-use within this framework's lifetime. */
|
|
2743
|
+
name: string;
|
|
2744
|
+
/** The live agent whose context and configuration are inherited. */
|
|
2745
|
+
from: string;
|
|
2746
|
+
/** How context is inherited. Default `'shared'`. */
|
|
2747
|
+
mode?: 'shared' | 'copy';
|
|
2748
|
+
/** `'shared'` only. Default `'reuse'`. */
|
|
2749
|
+
solve?: 'reuse' | 'fresh';
|
|
2750
|
+
/**
|
|
2751
|
+
* `'shared'` only. Also inherit the parent's refusal ledger (what it
|
|
2752
|
+
* declined to compress). Default false; set for a fork that stands in
|
|
2753
|
+
* for the parent's attention. Recorded as `inherit.refusals`.
|
|
2754
|
+
*/
|
|
2755
|
+
inheritRefusals?: boolean;
|
|
2756
|
+
/**
|
|
2757
|
+
* A fresh strategy instance for the child. Strategy instances hold state
|
|
2758
|
+
* and are never shared; to reuse the parent's rendering it must be the
|
|
2759
|
+
* same class with compatible configuration. Default: passthrough.
|
|
2760
|
+
*/
|
|
2761
|
+
strategy?: ContextStrategy;
|
|
2762
|
+
/** Identity and settings overrides. Everything not named is the parent's. */
|
|
2763
|
+
config?: Partial<Omit<AgentConfig, 'name' | 'strategy'>>;
|
|
2764
|
+
/**
|
|
2765
|
+
* The parent's tool round in flight, if any. While a tool call runs, the
|
|
2766
|
+
* assistant turn that made it is not in the parent's store yet. By
|
|
2767
|
+
* default the child's branch gets that turn verbatim (as its own, like
|
|
2768
|
+
* the rest of the inheritance) followed by a tool-result message: results
|
|
2769
|
+
* the parent has already received as they are; the call that derived the
|
|
2770
|
+
* child with `madeBy.result`, or a note saying it did; and any other call
|
|
2771
|
+
* still running with a note that its result was not available. So a fork
|
|
2772
|
+
* sees the call that made it and never meets a dangling `tool_use`.
|
|
2773
|
+
* `madeBy` defaults to the only open call when there is exactly one.
|
|
2774
|
+
* `include: false` leaves the round out, and the child starts from the
|
|
2775
|
+
* last stored message before it.
|
|
2776
|
+
*/
|
|
2777
|
+
pendingRound?: {
|
|
2778
|
+
include?: boolean;
|
|
2779
|
+
madeBy?: { toolUseId: string; result: string };
|
|
2780
|
+
/** Results, from the child's side, for other calls still open, by tool-use id. */
|
|
2781
|
+
results?: Record<string, string>;
|
|
2782
|
+
};
|
|
2783
|
+
/** Ordinary messages appended to the child before it runs. */
|
|
2784
|
+
framing?: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }>;
|
|
2785
|
+
/** Who spawned it. Default: `from`. */
|
|
2786
|
+
spawnedBy?: string;
|
|
2787
|
+
/** Where its completion returns. */
|
|
2788
|
+
resultTo?: import('./dendrite/index.js').ResultRoute;
|
|
2789
|
+
/** What happens to it when its spawner ends. Default `'orphan'`. */
|
|
2790
|
+
onParentEnd?: import('./dendrite/index.js').ParentEndPolicy;
|
|
2791
|
+
deadlineMs?: number;
|
|
2792
|
+
idleTimeoutMs?: number;
|
|
2793
|
+
maxTurns?: number;
|
|
2794
|
+
/** Preset label for discovery. Default `'task-fork'`. */
|
|
2795
|
+
kind?: string;
|
|
2796
|
+
metadata?: Record<string, unknown>;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
/** Rejection delivered to the caller awaiting a bounded run that was ended from outside. */
|
|
2800
|
+
export class AgentStoppedError extends Error {
|
|
2801
|
+
constructor(
|
|
2802
|
+
readonly agentName: string,
|
|
2803
|
+
readonly reason: AgentEndReason,
|
|
2804
|
+
readonly by?: string,
|
|
2805
|
+
detail?: string,
|
|
2806
|
+
) {
|
|
2807
|
+
super(
|
|
2808
|
+
`Agent "${agentName}" was ended (${reason}${by ? ` by ${by}` : ''})` +
|
|
2809
|
+
(detail ? `: ${detail}` : ''),
|
|
2810
|
+
);
|
|
2811
|
+
this.name = 'AgentStoppedError';
|
|
2812
|
+
}
|
|
2636
2813
|
}
|
|
2637
2814
|
|
|
2638
2815
|
interface SyncMoveReinit {
|
|
@@ -3370,6 +3547,13 @@ export class AgentFramework {
|
|
|
3370
3547
|
private quiesceReason?: string;
|
|
3371
3548
|
private quiescedAt?: number;
|
|
3372
3549
|
private traceListeners: TraceRegistration[] = [];
|
|
3550
|
+
/** Traces emitted before start() — registry restore, interrupted work,
|
|
3551
|
+
* resumed epochs — replayed to listeners that attach afterwards, since a
|
|
3552
|
+
* host attaches its sinks after create() returns. Dropped at start().
|
|
3553
|
+
* It keeps the first 1,000, the start of the stream, and counts the rest
|
|
3554
|
+
* (earlyTracesDropped), which a replay ends by naming. */
|
|
3555
|
+
private earlyTraces: TraceEvent[] | null = [];
|
|
3556
|
+
private earlyTracesDropped = 0;
|
|
3373
3557
|
private syncIntervalMs: number;
|
|
3374
3558
|
private syncTimer: ReturnType<typeof setInterval> | null = null;
|
|
3375
3559
|
private maintenanceIntervalMs: number;
|
|
@@ -3431,8 +3615,9 @@ export class AgentFramework {
|
|
|
3431
3615
|
private midTurnInputSignals: Set<string> = new Set();
|
|
3432
3616
|
/** Last outbound locus announced to each agent as a durable `[routing]`
|
|
3433
3617
|
* window message. Announce-on-change only: steady state emits nothing
|
|
3434
|
-
* (KV-safe, no per-turn chatter).
|
|
3435
|
-
*
|
|
3618
|
+
* (KV-safe, no per-turn chatter). Kept in the store as well
|
|
3619
|
+
* (rememberAnnouncedLocus), so a restart compares with what the agent was
|
|
3620
|
+
* last told rather than with nothing. */
|
|
3436
3621
|
private lastAnnouncedLocus: Map<string, ChannelRef | null> = new Map();
|
|
3437
3622
|
|
|
3438
3623
|
// ---- Held prose drafts (src/prose-drafts.ts) ---------------------------
|
|
@@ -3519,7 +3704,22 @@ export class AgentFramework {
|
|
|
3519
3704
|
* global fallback even after its release, or a conversation fork. */
|
|
3520
3705
|
private usedEphemeralAgentNames: Map<string, 'run' | 'conversation'> = new Map();
|
|
3521
3706
|
/** One-shot generation tickets minted by createEphemeralAgent. */
|
|
3522
|
-
private ephemeralCandidates: WeakMap<Agent,
|
|
3707
|
+
private ephemeralCandidates: WeakMap<Agent, {
|
|
3708
|
+
contextManager: ContextManager;
|
|
3709
|
+
config: AgentConfig;
|
|
3710
|
+
spec: AgentSpec;
|
|
3711
|
+
/** A new incarnation of an identity that ended (see resumeAgent). */
|
|
3712
|
+
resume?: boolean;
|
|
3713
|
+
/** The incarnation it resumes: the new one is past it, though the registry's counter for an aged-out name is gone. */
|
|
3714
|
+
after?: number;
|
|
3715
|
+
}> = new WeakMap();
|
|
3716
|
+
/**
|
|
3717
|
+
* Derived agent → the agent whose tool presentation it shows. A derived
|
|
3718
|
+
* agent that inherits its parent's tools must advertise the identical
|
|
3719
|
+
* tool block (it is the front of the shared request prefix), including
|
|
3720
|
+
* the parent's resident-edited visibility and descriptions.
|
|
3721
|
+
*/
|
|
3722
|
+
private toolSurfaceOwners: Map<string, string> = new Map();
|
|
3523
3723
|
/** Every agent createEphemeralAgent made, for as long as the object lives:
|
|
3524
3724
|
* its usage counts in the usage record's one shared ephemeral entry. Kept
|
|
3525
3725
|
* by the object, not the run's record (ephemeralRuns), which its cleanup
|
|
@@ -3596,8 +3796,25 @@ export class AgentFramework {
|
|
|
3596
3796
|
private subconsciousStrategy: WindowedPassthroughStrategy | null = null;
|
|
3597
3797
|
/** Its config (speak_in_channel gate, voice block provenance). */
|
|
3598
3798
|
private subconsciousConfig: SubconsciousConfig | null = null;
|
|
3599
|
-
/** Tune-out coordinator (issue #77); non-null iff
|
|
3799
|
+
/** Tune-out coordinator (issue #77); non-null iff a reader is configured + channels. */
|
|
3600
3800
|
private tuneOutCoordinator: TuneOutCoordinator | null = null;
|
|
3801
|
+
/** Residents are offered `tune_out` (a reader is configured, or `tuneOut.enabled`). */
|
|
3802
|
+
private tuneOutOffered = false;
|
|
3803
|
+
/**
|
|
3804
|
+
* The reply anchor a coalesced delivery would set, parked until the
|
|
3805
|
+
* acceptance is acknowledged to the server (handleCoalescedIncoming): an
|
|
3806
|
+
* acceptance whose commit fails after its delivery moves no anchor, and the
|
|
3807
|
+
* retry the receipt answers, which delivers nothing again, commits the one
|
|
3808
|
+
* its first attempt parked. Keyed by server and event id; bounded.
|
|
3809
|
+
*/
|
|
3810
|
+
private parkedAnchors: Map<string, { channel: ChannelRef; messageId: string; threadId?: string; tags?: string[]; firstView: boolean }> = new Map();
|
|
3811
|
+
/** `tuneOut.announce`: the excepted channel hears the all-except scope open and close. */
|
|
3812
|
+
private tuneOutAnnounce = true;
|
|
3813
|
+
/** `speech.locus`: where prose goes when nothing names a destination (see FrameworkConfig). */
|
|
3814
|
+
private speechLocus: 'delivery' | 'latest-inbound' = 'delivery';
|
|
3815
|
+
/** `subconscious.reader: 'forks'`: the reader is a succession of forks (Dendrite). */
|
|
3816
|
+
private readerForks = false;
|
|
3817
|
+
private readerForkCounter = 0;
|
|
3601
3818
|
|
|
3602
3819
|
/** Agents an abandon could not cancel (token held, no stream) — surfaced
|
|
3603
3820
|
* in HostModeStatus until the next quiesce/resume. */
|
|
@@ -3616,6 +3833,24 @@ export class AgentFramework {
|
|
|
3616
3833
|
/** True once the cap has been met in the current run, so its line is
|
|
3617
3834
|
* logged once a run, whatever began the run. */
|
|
3618
3835
|
private deferredWritesCapLogged = false;
|
|
3836
|
+
/** The agent registry and its ended log, as recovery files beside the store
|
|
3837
|
+
* (nissa's #546 finding 3): Chronicle state follows the store's branch, and
|
|
3838
|
+
* an undo or an operator's surgery must not erase a result held for
|
|
3839
|
+
* delivery or the record of work that ran. The file wins where it exists;
|
|
3840
|
+
* the branch-local state is read only for stores that predate it. */
|
|
3841
|
+
private agentRegistryPath: string | undefined;
|
|
3842
|
+
private agentRegistryEndedPath: string | undefined;
|
|
3843
|
+
/** The ended log as the recovery file keeps it: the newest records, bounded. */
|
|
3844
|
+
private endedRecovery: AgentRecord[] = [];
|
|
3845
|
+
/** The branch-local ended log by name (its newest entry for each), and how far it is indexed (findEndedAgentRecord). */
|
|
3846
|
+
private readonly endedLogIndex = new Map<string, number>();
|
|
3847
|
+
/** The ended log's entries by the namespace their context holds (the latest for each). */
|
|
3848
|
+
private readonly endedLogNamespaceIndex = new Map<string, number>();
|
|
3849
|
+
/** Namespaces this process opened for an ephemeral agent's context, by owner: candidates not yet registered included. */
|
|
3850
|
+
private readonly namespaceOwners = new Map<string, string>();
|
|
3851
|
+
private endedLogIndexed = 0;
|
|
3852
|
+
/** The last indexed entry's mark, so a log that changed under the index is seen. */
|
|
3853
|
+
private endedLogTail = '';
|
|
3619
3854
|
/** Branch-independent recovery files (see FrameworkConfig.hostModePath /
|
|
3620
3855
|
* deferredWritesPath). Undefined → branch-local slot fallback. */
|
|
3621
3856
|
private hostModePath: string | undefined;
|
|
@@ -3777,12 +4012,32 @@ export class AgentFramework {
|
|
|
3777
4012
|
private coalescingSnapshotDirty: (() => CoalescingSnapshot) | null = null;
|
|
3778
4013
|
private coalescingRecentReceipts: CoalescingReceiptRecord[] = [];
|
|
3779
4014
|
/**
|
|
3780
|
-
*
|
|
3781
|
-
*
|
|
3782
|
-
*
|
|
3783
|
-
*
|
|
3784
|
-
*
|
|
3785
|
-
*/
|
|
4015
|
+
* Dendrite agent registry — every agent this framework runs, with its
|
|
4016
|
+
* roles, lifetime and relationships. `agents` stays the map of live Agent
|
|
4017
|
+
* OBJECTS (the generation identity streams are checked against); the
|
|
4018
|
+
* registry is what says what each of them is for. Selection sites read
|
|
4019
|
+
* roles from here instead of excluding kinds by hand.
|
|
4020
|
+
*/
|
|
4021
|
+
private readonly registry: AgentRegistry = new AgentRegistry({
|
|
4022
|
+
persist: (snapshot) => this.persistAgentRegistry(snapshot),
|
|
4023
|
+
appendEnded: (record) => this.appendEndedAgentRecord(record),
|
|
4024
|
+
onRollback: () => this.rewriteAgentRegistryAfterRollback(),
|
|
4025
|
+
findEnded: (name) => this.findEndedAgentRecord(name),
|
|
4026
|
+
});
|
|
4027
|
+
/** Tell the first adoption candidate when task work is orphaned (config.dendrite.announceOrphans). */
|
|
4028
|
+
private announceOrphans = true;
|
|
4029
|
+
/**
|
|
4030
|
+
* After this many derivations of one kind from one agent, that agent hears
|
|
4031
|
+
* a count line every so many instead of a line per fork
|
|
4032
|
+
* (config.dendrite.collapseDerivationNoticesAfter; 0 = never collapse).
|
|
4033
|
+
*/
|
|
4034
|
+
private collapseDerivationNoticesAfter = 10;
|
|
4035
|
+
/** The host's say over each derivation, ephemeral spawn and resume (config.dendrite.beforeDerive). */
|
|
4036
|
+
private beforeDerive?: (spec: AgentSpec) => void | { refuse: string };
|
|
4037
|
+
/** What a source is told of a derivation (config.dendrite.derivationNotice); absent, the default line. */
|
|
4038
|
+
private derivationNoticeHook?: (record: AgentRecord, from: string, defaultText: string) => string | null;
|
|
4039
|
+
/** Per source agent, per kind: how many derivations, since when, and at which count the last note went. */
|
|
4040
|
+
private derivationTallies: Map<string, Map<string, { count: number; since: number }>> = new Map();
|
|
3786
4041
|
private readonly sharedSlotAgents = new Set<Agent>();
|
|
3787
4042
|
/** The batched-wake lines already written (placeWakeLines), so every agent a
|
|
3788
4043
|
* wake was for, and every later start of it, writes each at most once. */
|
|
@@ -3791,6 +4046,7 @@ export class AgentFramework {
|
|
|
3791
4046
|
* in the round counted by their share of the bound: such a round is
|
|
3792
4047
|
* stored by its share whether or not it fits (buildStoredToolResultContent). */
|
|
3793
4048
|
private readonly toolRoundsByShare = new WeakSet<CompletedToolCall[]>();
|
|
4049
|
+
/** Group commit: one fsync per event-loop turn, shared by every waiter. */
|
|
3794
4050
|
/** The windows of forks and runs deregistered while the host was quiesced,
|
|
3795
4051
|
* by agent name (releaseWindowNowOrAfterResume); resume() releases them
|
|
3796
4052
|
* (runReleasesAfterResume), or stop() passes on what they left, unread or
|
|
@@ -3799,6 +4055,7 @@ export class AgentFramework {
|
|
|
3799
4055
|
private readonly releasesAfterResume = new Map<string, Agent>();
|
|
3800
4056
|
private readonly managerReleases = new WeakMap<Agent, ManagerRelease>();
|
|
3801
4057
|
private readonly releasedByCleanup = new WeakSet<Agent>();
|
|
4058
|
+
private readonly resumesPending = new Map<string, Agent | undefined>();
|
|
3802
4059
|
// context-manager 0.23.1: a strategy that keeps its context, as
|
|
3803
4060
|
// AutobiographicalStrategy does (hostContext, autobiographical.ts:2897),
|
|
3804
4061
|
// keeps its manager reachable while its work is pending, since the
|
|
@@ -3819,12 +4076,10 @@ export class AgentFramework {
|
|
|
3819
4076
|
private conversationRouter: ConversationRouter | null = null;
|
|
3820
4077
|
/** Agent configs by name — fork agents are built from the template's config. */
|
|
3821
4078
|
private agentConfigs: Map<string, AgentConfig> = new Map();
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
*/
|
|
3827
|
-
private conversationAgentHomes: Map<string, ChannelRef> = new Map();
|
|
4079
|
+
// A fork agent's home channel is its registry record's `homeChannel`
|
|
4080
|
+
// (homeOf). Permanent (unlike router bindings, which expire):
|
|
4081
|
+
// publish/injection scoping must survive unbinding so the closure turn
|
|
4082
|
+
// still lands in the right channel.
|
|
3828
4083
|
/** Forks being spawned, by channel id, each until its spawn settles (see
|
|
3829
4084
|
* routeConversation). The promise never rejects. */
|
|
3830
4085
|
private readonly conversationSpawns = new Map<string, Promise<void>>();
|
|
@@ -3832,7 +4087,7 @@ export class AgentFramework {
|
|
|
3832
4087
|
private lastConversationSweep = 0;
|
|
3833
4088
|
|
|
3834
4089
|
/** Forks whose TTL closure turn has been queued — disposed (removed from
|
|
3835
|
-
* agents/agentConfigs
|
|
4090
|
+
* agents/agentConfigs and ended in the registry) when their stream ends. */
|
|
3836
4091
|
private closingConversationAgents: Set<string> = new Set();
|
|
3837
4092
|
// Client-side programmatic tool calling (`code_execution`). Null unless
|
|
3838
4093
|
// config.codeExecution.enabled: its settings, and every agent's scripts,
|
|
@@ -4170,7 +4425,7 @@ export class AgentFramework {
|
|
|
4170
4425
|
// Register framework states
|
|
4171
4426
|
ensureStateRegistered(store, { id: FRAMEWORK_STATE_ID, strategy: 'snapshot' });
|
|
4172
4427
|
ensureStateRegistered(store, { id: DEFERRED_WRITES_ID, strategy: 'snapshot' });
|
|
4173
|
-
for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) {
|
|
4428
|
+
for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID, AGENT_REGISTRY_STATE_ID]) {
|
|
4174
4429
|
ensureStateRegistered(store, { id, strategy: 'snapshot' });
|
|
4175
4430
|
}
|
|
4176
4431
|
ensureStateRegistered(store, {
|
|
@@ -4203,6 +4458,12 @@ export class AgentFramework {
|
|
|
4203
4458
|
);
|
|
4204
4459
|
}
|
|
4205
4460
|
}
|
|
4461
|
+
ensureStateRegistered(store, {
|
|
4462
|
+
id: AGENT_REGISTRY_ENDED_ID,
|
|
4463
|
+
strategy: 'append_log',
|
|
4464
|
+
deltaSnapshotEvery: 100,
|
|
4465
|
+
fullSnapshotEvery: 20,
|
|
4466
|
+
});
|
|
4206
4467
|
|
|
4207
4468
|
// Process logging config (default: disabled)
|
|
4208
4469
|
const processLoggingPersist = config.processLogging?.persist === true;
|
|
@@ -4287,9 +4548,41 @@ export class AgentFramework {
|
|
|
4287
4548
|
// subconscious disabled now may have run and left its own entry.
|
|
4288
4549
|
framework.restoreUsageState([
|
|
4289
4550
|
...config.agents.map((agent) => String(agent.name)),
|
|
4290
|
-
|
|
4551
|
+
// A forks reader has no agent of its own; its forks are bounded jobs.
|
|
4552
|
+
...(config.subconscious && config.subconscious.reader !== 'forks' ? [String(config.subconscious.name ?? 'Subconscious')] : []),
|
|
4291
4553
|
]);
|
|
4292
4554
|
|
|
4555
|
+
framework.announceOrphans = config.dendrite?.announceOrphans ?? true;
|
|
4556
|
+
framework.collapseDerivationNoticesAfter = Math.max(0, Math.floor(config.dendrite?.collapseDerivationNoticesAfter ?? 10));
|
|
4557
|
+
framework.beforeDerive = config.dendrite?.beforeDerive;
|
|
4558
|
+
framework.derivationNoticeHook = config.dendrite?.derivationNotice;
|
|
4559
|
+
framework.speechLocus = config.speech?.locus ?? 'delivery';
|
|
4560
|
+
framework.tuneOutOffered = !!config.subconscious?.enabled || config.tuneOut?.enabled === true;
|
|
4561
|
+
framework.tuneOutAnnounce = config.tuneOut?.announce !== false;
|
|
4562
|
+
|
|
4563
|
+
// What the previous process left in the agent registry. Read before any
|
|
4564
|
+
// agent is declared so a restored persistent agent becomes a new
|
|
4565
|
+
// incarnation of the same identity rather than a stranger. The registry
|
|
4566
|
+
// lives in recovery files beside the store (see agentRegistryPath).
|
|
4567
|
+
framework.agentRegistryPath = config.agentRegistryPath
|
|
4568
|
+
?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-registry.json') : undefined);
|
|
4569
|
+
framework.agentRegistryEndedPath = config.agentRegistryEndedPath
|
|
4570
|
+
?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-ended.json') : undefined);
|
|
4571
|
+
if (!framework.agentRegistryPath || !framework.agentRegistryEndedPath) {
|
|
4572
|
+
const missing = [
|
|
4573
|
+
...(framework.agentRegistryPath ? [] : ['agentRegistryPath']),
|
|
4574
|
+
...(framework.agentRegistryEndedPath ? [] : ['agentRegistryEndedPath']),
|
|
4575
|
+
];
|
|
4576
|
+
console.error(
|
|
4577
|
+
`[dendrite] no storePath / ${missing.join(' / ')}: the agent registry's ` +
|
|
4578
|
+
(framework.agentRegistryPath
|
|
4579
|
+
? 'ended log persists only in the branch-local store, where a branch switch or historical rollback can erase it'
|
|
4580
|
+
: 'records and held results persist only in the branch-local store, where a branch switch or historical rollback can erase them') +
|
|
4581
|
+
', and a crash can lose what wasn\'t synced; configure the recovery paths',
|
|
4582
|
+
);
|
|
4583
|
+
}
|
|
4584
|
+
const interruptedAgents = framework.restoreAgentRegistry();
|
|
4585
|
+
|
|
4293
4586
|
// Create agents
|
|
4294
4587
|
for (const agentConfig of config.agents) {
|
|
4295
4588
|
await framework.createAgent(agentConfig);
|
|
@@ -4299,8 +4592,12 @@ export class AgentFramework {
|
|
|
4299
4592
|
// The subconscious resident (issue #77) registers after the residents so
|
|
4300
4593
|
// it can never become primary; it is excluded from every broadcast
|
|
4301
4594
|
// fan-out and woken only by the tune-out coordinator's explicit pushes.
|
|
4302
|
-
if (config.subconscious?.enabled
|
|
4303
|
-
|
|
4595
|
+
if (config.subconscious?.enabled) {
|
|
4596
|
+
if (config.subconscious.reader === 'forks') {
|
|
4597
|
+
framework.configureReaderForks(config.subconscious);
|
|
4598
|
+
} else {
|
|
4599
|
+
await framework.createSubconsciousAgent(config.subconscious);
|
|
4600
|
+
}
|
|
4304
4601
|
}
|
|
4305
4602
|
|
|
4306
4603
|
// Finish any branch-local suppression interrupted after Chronicle switched
|
|
@@ -4433,8 +4730,9 @@ export class AgentFramework {
|
|
|
4433
4730
|
})(),
|
|
4434
4731
|
});
|
|
4435
4732
|
},
|
|
4436
|
-
getAgentNames: () =>
|
|
4437
|
-
|
|
4733
|
+
getAgentNames: () => framework.registry.gateRecipients(),
|
|
4734
|
+
isHeld: (serverId, channelId) =>
|
|
4735
|
+
!!(serverId && channelId && framework.tuneOutCoordinator?.holdFor(serverId, channelId)),
|
|
4438
4736
|
// Push events reach the gate with the adapter's raw channel id; map
|
|
4439
4737
|
// them to the registered composite id the same way ingestion does
|
|
4440
4738
|
// (handleMcplPushEvent registers that channel on arrival, before any
|
|
@@ -4601,6 +4899,15 @@ export class AgentFramework {
|
|
|
4601
4899
|
framework.hostPresentation = framework.makeHostPresentation(config.storePath);
|
|
4602
4900
|
|
|
4603
4901
|
// Initialize MCPL subsystems if configured
|
|
4902
|
+
// Configuration has now declared its agents, and host mode and the
|
|
4903
|
+
// deferred-write queue are restored: settle what the previous process
|
|
4904
|
+
// left unfinished (interrupted bounded work, persistent agents no longer
|
|
4905
|
+
// configured, results still held for delivery). After the restore, so a
|
|
4906
|
+
// quiesced host holds the restart notices and redelivered mail as it
|
|
4907
|
+
// holds any write, and mail an entry of the restored queue carries is
|
|
4908
|
+
// left to that entry's landing (nissa's #546 finding 4).
|
|
4909
|
+
framework.reconcileAgentRegistry(interruptedAgents);
|
|
4910
|
+
|
|
4604
4911
|
if (config.mcplServers && config.mcplServers.length > 0) {
|
|
4605
4912
|
// Check every server's config before any is brought up: its boolean
|
|
4606
4913
|
// settings, and its tool prefix, which mustn't collide with a module
|
|
@@ -4653,9 +4960,14 @@ export class AgentFramework {
|
|
|
4653
4960
|
await framework.ensureToolsetsModule();
|
|
4654
4961
|
}
|
|
4655
4962
|
|
|
4656
|
-
// Tune-out coordinator (issue #77):
|
|
4657
|
-
//
|
|
4658
|
-
|
|
4963
|
+
// Tune-out coordinator (issue #77, #232 §3.3): the hold machinery exists
|
|
4964
|
+
// wherever tune_out is offered — with a reader (persistent subconscious
|
|
4965
|
+
// or reader forks) it summarizes and wakes; with `tuneOut.enabled` alone
|
|
4966
|
+
// it is a silent hold, and a persisted epoch is resumed and released
|
|
4967
|
+
// (#232 gap 10). Where neither is configured nothing could ever cancel a
|
|
4968
|
+
// tune-out on record, so each one ends at the start instead
|
|
4969
|
+
// (endTuneOutsWithoutCoordinator), as main has it.
|
|
4970
|
+
if (framework.channelRegistry && framework.tuneOutOffered) {
|
|
4659
4971
|
framework.tuneOutCoordinator = new TuneOutCoordinator(
|
|
4660
4972
|
framework.channelRegistry,
|
|
4661
4973
|
framework.mcplServerRegistry!,
|
|
@@ -4667,12 +4979,21 @@ export class AgentFramework {
|
|
|
4667
4979
|
framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
|
|
4668
4980
|
},
|
|
4669
4981
|
subconsciousName: () => framework.subconsciousAgentName,
|
|
4982
|
+
...(framework.readerForks
|
|
4983
|
+
? {
|
|
4984
|
+
invokeReader: (invocation: ReaderInvocation) => framework.invokeReaderFork(invocation),
|
|
4985
|
+
deliverFromReader: (reader: string, content: ContentBlock[], metadata: Record<string, unknown>) =>
|
|
4986
|
+
framework.deliverFromReader(reader, content, metadata),
|
|
4987
|
+
}
|
|
4988
|
+
: {}),
|
|
4670
4989
|
primaryName: () => framework.primaryAgentName,
|
|
4671
4990
|
getStoredMessages: () => {
|
|
4672
4991
|
const primary = framework.primaryAgentName
|
|
4673
4992
|
? framework.agents.get(framework.primaryAgentName) : undefined;
|
|
4674
4993
|
return primary ? primary.getContextManager().getAllMessages() : [];
|
|
4675
4994
|
},
|
|
4995
|
+
commitDeliveries: () => framework.commitDeliveries(),
|
|
4996
|
+
historyTool: () => framework.residentHistoryTool(),
|
|
4676
4997
|
getDeferredMessages: () => {
|
|
4677
4998
|
const forPrimary = (m: DeferredWrite): boolean => (m.forAgent ?? framework.primaryAgentName) === framework.primaryAgentName;
|
|
4678
4999
|
// Settled first, so a write that landed is in the stored rows
|
|
@@ -4680,7 +5001,7 @@ export class AgentFramework {
|
|
|
4680
5001
|
framework.settleUnknownLandings(forPrimary);
|
|
4681
5002
|
return framework.deferredMessages.filter(forPrimary)
|
|
4682
5003
|
.sort(bySeq)
|
|
4683
|
-
.map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata as Record<string, unknown> | undefined }));
|
|
5004
|
+
.map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata as Record<string, unknown> | undefined, writeId: m.id, heldAt: m.heldAt }));
|
|
4684
5005
|
},
|
|
4685
5006
|
// Every reader of the shared slot compiles the dump with its own
|
|
4686
5007
|
// strategy, so the dump fits the smallest of their bounds.
|
|
@@ -4696,10 +5017,22 @@ export class AgentFramework {
|
|
|
4696
5017
|
setSubconsciousAnchor: (sequence) =>
|
|
4697
5018
|
framework.subconsciousStrategy?.setAnchor(sequence),
|
|
4698
5019
|
isForkBound: (channel) =>
|
|
4699
|
-
|
|
5020
|
+
framework.registry.list().some((record) => sameChannel(framework.homeOf(record.name), channel)),
|
|
4700
5021
|
isPrivilegedAuthor: (authorId) =>
|
|
4701
5022
|
framework.eventGate?.isPrivilegedUser(authorId) ?? false,
|
|
4702
5023
|
allowChannelSpeech: () => framework.subconsciousConfig?.allowChannelSpeech === true,
|
|
5024
|
+
holdWithoutReader: () => framework.tuneOutOffered,
|
|
5025
|
+
...(framework.tuneOutAnnounce
|
|
5026
|
+
? {
|
|
5027
|
+
// The excepted channel on its server: a bare id two servers
|
|
5028
|
+
// register is refused (publishForAgent).
|
|
5029
|
+
announce: async (serverId: string, channelId: string, text: string) => {
|
|
5030
|
+
const r = await framework.channelRegistry!.publishForAgent(
|
|
5031
|
+
{ serverId, channelId }, text, framework.primaryAgentName ?? 'resident');
|
|
5032
|
+
return { success: r.success, ...(r.error ? { error: r.error } : {}) };
|
|
5033
|
+
},
|
|
5034
|
+
}
|
|
5035
|
+
: {}),
|
|
4703
5036
|
emitTrace: (e) => framework.emitTrace(e as never),
|
|
4704
5037
|
},
|
|
4705
5038
|
);
|
|
@@ -4791,6 +5124,7 @@ export class AgentFramework {
|
|
|
4791
5124
|
|
|
4792
5125
|
this.running = true;
|
|
4793
5126
|
this.providerAdmissionClosed = false;
|
|
5127
|
+
this.earlyTraces = null;
|
|
4794
5128
|
// A new lifecycle: a turn compiled before an earlier stop() never drives,
|
|
4795
5129
|
// and withheld spills, closed for the generation a stop ended, are open
|
|
4796
5130
|
// for this one.
|
|
@@ -5042,6 +5376,11 @@ export class AgentFramework {
|
|
|
5042
5376
|
step(() => this.pushCoalescer?.suspend());
|
|
5043
5377
|
step(() => this.flushCoalescingSnapshot());
|
|
5044
5378
|
this.running = false;
|
|
5379
|
+
// Bounded work this stop cuts off is neither finished nor failed. Its
|
|
5380
|
+
// record stays live in the snapshot, so the next boot ends it as
|
|
5381
|
+
// `host-restart` and tells its result recipient — the same as a crash
|
|
5382
|
+
// (first live run, 2026-10-12, finding D: a reader fork ended silently).
|
|
5383
|
+
for (const run of this.ephemeralRuns.values()) run.endReason ??= 'host-stop';
|
|
5045
5384
|
// Flushed-but-unsynced deferred writes: sync and ack now, while the
|
|
5046
5385
|
// store is still open, rather than leaving them to a reboot replay.
|
|
5047
5386
|
step(() => this.ackDeferredWrites());
|
|
@@ -5249,11 +5588,11 @@ export class AgentFramework {
|
|
|
5249
5588
|
// since the name is never used again. These releases, and the pass after
|
|
5250
5589
|
// them, come before the registry forgets its channels, since a notice
|
|
5251
5590
|
// names its channel by the label the registry holds (channelForNotice).
|
|
5252
|
-
|
|
5591
|
+
// The registry names the conversation forks and their home channels.
|
|
5592
|
+
for (const { name, home } of this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ name: r.name, home: this.homeOf(r.name) }))) {
|
|
5253
5593
|
step(() => {
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
this.disposeConversationAgent(name);
|
|
5594
|
+
if (home !== undefined && this.conversationRouter?.getBinding(home)?.agentName === name) this.conversationRouter.unbind(home);
|
|
5595
|
+
this.disposeConversationAgent(name, 'host-stop');
|
|
5257
5596
|
});
|
|
5258
5597
|
}
|
|
5259
5598
|
// A window release the quiesce postponed (releasesAfterResume) never runs
|
|
@@ -5682,10 +6021,12 @@ export class AgentFramework {
|
|
|
5682
6021
|
}
|
|
5683
6022
|
|
|
5684
6023
|
/** The agents the heartbeat wakes: the residents, not conversation forks,
|
|
5685
|
-
* ephemeral runs or the subconscious, which keep windows of their own.
|
|
6024
|
+
* ephemeral runs or the subconscious, which keep windows of their own.
|
|
6025
|
+
* Read from the registry by role, as an untargeted push's recipients are
|
|
6026
|
+
* (the heartbeat's wakes were heartbeat-mcpl's pushes): the agents whose
|
|
6027
|
+
* spec takes untargeted inbound, in registration order. */
|
|
5686
6028
|
private heartbeatTargets(): string[] {
|
|
5687
|
-
return
|
|
5688
|
-
&& !this.ephemeralRuns.has(n) && n !== this.subconsciousAgentName);
|
|
6029
|
+
return this.registry.untargetedRecipients().filter((n) => this.agents.has(n));
|
|
5689
6030
|
}
|
|
5690
6031
|
|
|
5691
6032
|
/**
|
|
@@ -5783,7 +6124,7 @@ export class AgentFramework {
|
|
|
5783
6124
|
private heartbeatRefusal(agentName: string, call: ToolCall): string | null {
|
|
5784
6125
|
const tool = call.name.replace(/^(mcpl--)?heartbeat--/, '');
|
|
5785
6126
|
if (!HEARTBEAT_CHANGING_TOOLS.includes(tool) || this.heartbeatTargets().includes(agentName)) return null;
|
|
5786
|
-
const one = this.
|
|
6127
|
+
const one = this.registry.homeChannel(agentName) !== undefined ? 'conversation'
|
|
5787
6128
|
: this.ephemeralRuns.has(agentName) ? 'run' : 'side agent';
|
|
5788
6129
|
return `Nothing was changed: the heartbeat and its reminders are the agent's, and wake its residents, so this ${one} can't `
|
|
5789
6130
|
+ 'change them. heartbeat--heartbeat_status and heartbeat--reminder_list show them.';
|
|
@@ -6011,7 +6352,7 @@ export class AgentFramework {
|
|
|
6011
6352
|
return true;
|
|
6012
6353
|
}
|
|
6013
6354
|
private holdProviderAcceleration(agent: Agent, error: Error, trigger?: InferenceRequest, auxiliary = false): boolean {
|
|
6014
|
-
if (this.
|
|
6355
|
+
if (!this.registry.ownsProviderScheduling(agent.name)) return false;
|
|
6015
6356
|
// A provider's stated wait binds later calls to the primary model, across
|
|
6016
6357
|
// restarts. (An auxiliary call records its own model's wait at the gate.)
|
|
6017
6358
|
const wait = auxiliary ? undefined : this.recordProviderWait(agent.name, agent.model, error);
|
|
@@ -6140,7 +6481,7 @@ export class AgentFramework {
|
|
|
6140
6481
|
// From either copy of membrane (isMembraneError), a wait is stated only
|
|
6141
6482
|
// as a number, as the default error policy reads it.
|
|
6142
6483
|
if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number') return undefined;
|
|
6143
|
-
if (this.
|
|
6484
|
+
if (this.registry.homeChannel(agent.name) !== undefined) {
|
|
6144
6485
|
const wait = this.recordProviderWait(agent.name, agent.model, error);
|
|
6145
6486
|
return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true)
|
|
6146
6487
|
? 'held'
|
|
@@ -6364,6 +6705,14 @@ export class AgentFramework {
|
|
|
6364
6705
|
// failure or start reaching here after it holds nothing, so no retry
|
|
6365
6706
|
// outlives the stop as a turn still alive. Nothing is started either.
|
|
6366
6707
|
if (this.providerAdmissionClosed) return true;
|
|
6708
|
+
// A listener of the failure reported here (`report`) may have ended the
|
|
6709
|
+
// agent (stopAgent): its end dropped what of its turn was on its way,
|
|
6710
|
+
// and nothing is held for an agent that's gone, so no retry outlives it
|
|
6711
|
+
// as a turn still alive. (A caller that reports a failure before calling
|
|
6712
|
+
// here checks the agent first: nonOwningProviderWait holds only for an
|
|
6713
|
+
// agent whose registry record is still live, and the scheduler and the
|
|
6714
|
+
// last boundary pass this.agents' own Agent.)
|
|
6715
|
+
if (this.agents.get(agent.name) !== agent) return true;
|
|
6367
6716
|
// A listener of the report may have released the wait
|
|
6368
6717
|
// (releaseProviderWait) before anything was held: nothing binds now, so
|
|
6369
6718
|
// the requests start, as the release of an installed hold starts them.
|
|
@@ -6901,9 +7250,21 @@ export class AgentFramework {
|
|
|
6901
7250
|
/**
|
|
6902
7251
|
* Add a trace event listener for observability.
|
|
6903
7252
|
*/
|
|
6904
|
-
onTrace(listener: TraceEventListener): () => void {
|
|
7253
|
+
onTrace(listener: TraceEventListener, options?: { replay?: boolean }): () => void {
|
|
6905
7254
|
const registration: TraceRegistration = { listener, active: true };
|
|
6906
7255
|
this.traceListeners.push(registration);
|
|
7256
|
+
// A host attaches its sinks after create() returns, so the traces
|
|
7257
|
+
// create() emitted (registry restore, interrupted work, resumed epochs)
|
|
7258
|
+
// are replayed to a listener that asks; dropped at start().
|
|
7259
|
+
if (options?.replay) {
|
|
7260
|
+
const replayed: TraceEvent[] = [...(this.earlyTraces ?? [])];
|
|
7261
|
+
if (this.earlyTraces && this.earlyTracesDropped > 0) {
|
|
7262
|
+
replayed.push({ type: 'dendrite:early-traces-dropped', dropped: this.earlyTracesDropped, timestamp: Date.now() });
|
|
7263
|
+
}
|
|
7264
|
+
for (const event of replayed) {
|
|
7265
|
+
try { registration.listener(event); } catch (error) { console.error('Trace listener error (replay):', error); }
|
|
7266
|
+
}
|
|
7267
|
+
}
|
|
6907
7268
|
// Return an unsubscribe so callers with a bounded lifetime (e.g. modules
|
|
6908
7269
|
// that get torn down and recreated on session switch) don't leak
|
|
6909
7270
|
// listeners. Existing callers that ignore the return value are unaffected.
|
|
@@ -7293,8 +7654,8 @@ export class AgentFramework {
|
|
|
7293
7654
|
if (this.channelRegistry) {
|
|
7294
7655
|
channelTools.push(AgentFramework.DRAFTS_TOOL);
|
|
7295
7656
|
}
|
|
7296
|
-
if (this.tuneOutCoordinator) {
|
|
7297
|
-
channelTools.push(AgentFramework.TUNE_OUT_TOOL);
|
|
7657
|
+
if (this.tuneOutCoordinator && this.tuneOutOffered) {
|
|
7658
|
+
channelTools.push(AgentFramework.TUNE_OUT_TOOL, AgentFramework.FOCUS_TOOL);
|
|
7298
7659
|
}
|
|
7299
7660
|
const gateTools = this.eventGate
|
|
7300
7661
|
? [
|
|
@@ -7689,7 +8050,7 @@ export class AgentFramework {
|
|
|
7689
8050
|
private tunedOutSendNote(agentName: string, channel: ChannelRef): string | undefined {
|
|
7690
8051
|
try {
|
|
7691
8052
|
const registry = this.channelRegistry;
|
|
7692
|
-
if (!registry || this.
|
|
8053
|
+
if (!registry || this.registry.homeChannel(agentName) !== undefined) return undefined;
|
|
7693
8054
|
if (registry.getDesiredState(channel.serverId, channel.channelId) !== 'tuned-out') return undefined;
|
|
7694
8055
|
if (!this.closedChannelNoteOn(agentName)) return undefined;
|
|
7695
8056
|
// How it ends is tune_out's own refusals' sentence, which names the
|
|
@@ -7722,7 +8083,7 @@ export class AgentFramework {
|
|
|
7722
8083
|
* delivered, so that's logged and nothing is added. */
|
|
7723
8084
|
private closedChannelNotes(agentName: string, channels: Iterable<ChannelRef>): string[] {
|
|
7724
8085
|
try {
|
|
7725
|
-
if (this.
|
|
8086
|
+
if (this.registry.homeChannel(agentName) !== undefined) return [];
|
|
7726
8087
|
const notes = [...channels].flatMap((channel) => this.closedChannelNoteFor(agentName, channel) ?? []);
|
|
7727
8088
|
// The setting is read only when there's a note to give.
|
|
7728
8089
|
return notes.length > 0 && this.closedChannelNoteOn(agentName) ? notes : [];
|
|
@@ -7963,7 +8324,13 @@ export class AgentFramework {
|
|
|
7963
8324
|
: t);
|
|
7964
8325
|
return [...SUBCONSCIOUS_TOOLS, ...basics];
|
|
7965
8326
|
}
|
|
7966
|
-
|
|
8327
|
+
const surfaceOwner = this.toolSurfaceOwners.get(agentName) ?? agentName;
|
|
8328
|
+
// Reader forks (subconscious.reader: 'forks') share the resident's
|
|
8329
|
+
// provider prefix, and the tool block is the front of it: the reader's
|
|
8330
|
+
// four tools are therefore in every agent's block and refused at
|
|
8331
|
+
// dispatch for everyone but a reader (see dispatchToolCall).
|
|
8332
|
+
const readerTools = this.readerForks ? SUBCONSCIOUS_TOOLS : [];
|
|
8333
|
+
return [...this.getAllTools(), ...readerTools, ...(this.toolPresentations.has(surfaceOwner) ? presentationTools(this.toolPresentations.get(surfaceOwner)!.config.cataloguePath) : [])]
|
|
7967
8334
|
.map((tool) => {
|
|
7968
8335
|
if (tool.name === 'think') {
|
|
7969
8336
|
return this.buildThinkTool(
|
|
@@ -7991,7 +8358,9 @@ export class AgentFramework {
|
|
|
7991
8358
|
}
|
|
7992
8359
|
|
|
7993
8360
|
inspectToolPresentation(agentName: string, snapshot?: InferenceToolSnapshot): PresentationSnapshot | null {
|
|
7994
|
-
|
|
8361
|
+
// A derived agent shows its parent's presentation (see toolSurfaceOwners);
|
|
8362
|
+
// only the owner can edit it (editToolPresentation looks up by own name).
|
|
8363
|
+
const presentation = this.toolPresentations.get(this.toolSurfaceOwners.get(agentName) ?? agentName);
|
|
7995
8364
|
if (!presentation) return null;
|
|
7996
8365
|
const tools = this.availableToolsForPresentation(agentName, snapshot);
|
|
7997
8366
|
const sources = new Map<string, string>();
|
|
@@ -9349,6 +9718,15 @@ export class AgentFramework {
|
|
|
9349
9718
|
}
|
|
9350
9719
|
this.deferredMessages.push(...restored);
|
|
9351
9720
|
this.deferredWritesPersisted = true;
|
|
9721
|
+
// A tune-out release that was still deferred at the crash owes the
|
|
9722
|
+
// resident the wake its cancel requested (#232 gap 6).
|
|
9723
|
+
for (const m of restored) {
|
|
9724
|
+
if ((m.metadata as { kind?: unknown } | undefined)?.kind !== 'tune-out-cancel') continue;
|
|
9725
|
+
const target = m.forAgent ?? this.primaryAgentName;
|
|
9726
|
+
if (target) {
|
|
9727
|
+
this.pendingRequests.push({ agentName: target, reason: 'tune-out released (restored)', source: 'tune-out', timestamp: Date.now() });
|
|
9728
|
+
}
|
|
9729
|
+
}
|
|
9352
9730
|
// Rewrite the durable queue without the already-landed entries so a
|
|
9353
9731
|
// second interrupted boot does not re-scan them, without the malformed
|
|
9354
9732
|
// and repeated ones, and with a seq for each entry that came without a
|
|
@@ -10249,7 +10627,18 @@ export class AgentFramework {
|
|
|
10249
10627
|
* ContextManager, before the store. Until then the ContextManager stays
|
|
10250
10628
|
* open.
|
|
10251
10629
|
*/
|
|
10252
|
-
async createEphemeralAgent(
|
|
10630
|
+
async createEphemeralAgent(
|
|
10631
|
+
config: AgentConfig,
|
|
10632
|
+
/**
|
|
10633
|
+
* How the registry should describe this agent. Omitted: an independent
|
|
10634
|
+
* fresh worker with no spawner and no result route, which is what every
|
|
10635
|
+
* caller of this method got before the registry existed. Supplying a
|
|
10636
|
+
* spec (see the presets in `dendrite/`) declares who spawned it, where
|
|
10637
|
+
* its result goes and what it inherited, so lifecycle rules — orphan
|
|
10638
|
+
* notice, reparenting, held results — apply to it.
|
|
10639
|
+
*/
|
|
10640
|
+
spec?: Omit<AgentSpec, 'name'>,
|
|
10641
|
+
): Promise<{
|
|
10253
10642
|
agent: Agent;
|
|
10254
10643
|
contextManager: ContextManager;
|
|
10255
10644
|
cleanup: () => void;
|
|
@@ -10266,24 +10655,79 @@ export class AgentFramework {
|
|
|
10266
10655
|
}
|
|
10267
10656
|
this.usedEphemeralAgentNames.set(config.name, 'run');
|
|
10268
10657
|
const release: ManagerRelease = { released: false, calls: new Set() };
|
|
10658
|
+
let opened: string | undefined;
|
|
10269
10659
|
try {
|
|
10270
|
-
|
|
10660
|
+
// A namespace the spec names is where the context opens, as it is what
|
|
10661
|
+
// the record says and what inspection and resumption open later.
|
|
10662
|
+
let namespace = spec?.namespace ?? `subagent/${config.name}`;
|
|
10663
|
+
let contextManager: ContextManager;
|
|
10664
|
+
// The host's say, on the spec of what is about to exist.
|
|
10665
|
+
if (this.beforeDerive) {
|
|
10666
|
+
const effective: AgentSpec = spec
|
|
10667
|
+
? { ...spec, name: config.name, model: config.model }
|
|
10668
|
+
: { ...workerSpec(config.name, { namespace }), model: config.model };
|
|
10669
|
+
const verdict = this.beforeDerive(effective);
|
|
10670
|
+
if (verdict && typeof verdict === 'object' && 'refuse' in verdict) {
|
|
10671
|
+
throw new Error(`${config.name} was not created: ${verdict.refuse}`);
|
|
10672
|
+
}
|
|
10673
|
+
}
|
|
10674
|
+
let inherit = spec?.inherit;
|
|
10271
10675
|
|
|
10272
|
-
|
|
10273
|
-
|
|
10274
|
-
|
|
10275
|
-
|
|
10276
|
-
|
|
10277
|
-
|
|
10278
|
-
|
|
10279
|
-
}
|
|
10676
|
+
if (inherit?.mode === 'shared') {
|
|
10677
|
+
// Shared reads, separate writes: the child's context is the parent's
|
|
10678
|
+
// at a checkpoint, on a branch of its own. Nothing is copied.
|
|
10679
|
+
const derived = await this.deriveSharedContext(config, inherit, release);
|
|
10680
|
+
contextManager = derived.contextManager;
|
|
10681
|
+
inherit = derived.inherit;
|
|
10682
|
+
namespace = derived.namespace;
|
|
10683
|
+
} else {
|
|
10684
|
+
// A namespace belongs to the agent that opened it: another agent's
|
|
10685
|
+
// turns there would be read as its own by inspection and by its
|
|
10686
|
+
// resumption, which reopens "its own namespace". So one another
|
|
10687
|
+
// agent's context holds (live, a candidate, awaiting re-declaration
|
|
10688
|
+
// or ended) is refused; an agent's own reopens in a new incarnation.
|
|
10689
|
+
const holder = this.namespaceHolder(namespace, config.name);
|
|
10690
|
+
if (holder) {
|
|
10691
|
+
// The default is offered only where it isn't the namespace refused.
|
|
10692
|
+
const fallback = `subagent/${config.name}`;
|
|
10693
|
+
throw new Error(
|
|
10694
|
+
`Ephemeral agent "${config.name}" refused: namespace "${namespace}" holds the context of agent ` +
|
|
10695
|
+
`"${holder}"; name another${namespace === fallback ? '' : `, or none for ${fallback}`}`,
|
|
10696
|
+
);
|
|
10697
|
+
}
|
|
10698
|
+
this.namespaceOwners.set(namespace, config.name);
|
|
10699
|
+
opened = namespace;
|
|
10700
|
+
contextManager = await ContextManager.open({
|
|
10701
|
+
store: this.store,
|
|
10702
|
+
namespace,
|
|
10703
|
+
isolate: true,
|
|
10704
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
10705
|
+
membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
|
|
10706
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
10707
|
+
});
|
|
10708
|
+
}
|
|
10709
|
+
// Either manager is the run's to release (main's manager release), the derived one included.
|
|
10280
10710
|
this.trackManager(contextManager);
|
|
10281
10711
|
release.manager = contextManager;
|
|
10282
10712
|
|
|
10283
10713
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
10284
10714
|
this.adoptToolResultGuard(agent);
|
|
10285
10715
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
10286
|
-
this.ephemeralCandidates.set(agent,
|
|
10716
|
+
this.ephemeralCandidates.set(agent, {
|
|
10717
|
+
contextManager,
|
|
10718
|
+
config,
|
|
10719
|
+
spec: spec
|
|
10720
|
+
? {
|
|
10721
|
+
...spec,
|
|
10722
|
+
name: config.name,
|
|
10723
|
+
model: config.model,
|
|
10724
|
+
// A shared fork's context lives under its parent's namespace,
|
|
10725
|
+
// on the fork's own branch — not in a namespace of its own.
|
|
10726
|
+
namespace: inherit?.mode === 'shared' ? namespace : spec.namespace ?? namespace,
|
|
10727
|
+
...(inherit ? { inherit } : {}),
|
|
10728
|
+
}
|
|
10729
|
+
: { ...workerSpec(config.name, { namespace }), model: config.model },
|
|
10730
|
+
});
|
|
10287
10731
|
this.ephemeralAgents.add(agent);
|
|
10288
10732
|
this.managerReleases.set(agent, release);
|
|
10289
10733
|
|
|
@@ -10296,10 +10740,1447 @@ export class AgentFramework {
|
|
|
10296
10740
|
} catch (error) {
|
|
10297
10741
|
if (release.manager) this.closeManager(release.manager);
|
|
10298
10742
|
this.usedEphemeralAgentNames.delete(config.name);
|
|
10743
|
+
if (opened !== undefined) this.namespaceOwners.delete(opened);
|
|
10299
10744
|
throw error;
|
|
10300
10745
|
}
|
|
10301
10746
|
}
|
|
10302
10747
|
|
|
10748
|
+
/**
|
|
10749
|
+
* Derive a new agent from a live one: the parent's context at a
|
|
10750
|
+
* checkpoint, a private continuation, and — unless overridden — the
|
|
10751
|
+
* parent's whole configuration (model, thinking, cache TTL, provider
|
|
10752
|
+
* params, prompt, tools). The parent keeps running.
|
|
10753
|
+
*
|
|
10754
|
+
* The returned agent is a bounded job, driven with
|
|
10755
|
+
* `runEphemeralToCompletion` exactly like one from `createEphemeralAgent`.
|
|
10756
|
+
*
|
|
10757
|
+
* **Inheritance.** `mode: 'shared'` (the default) gives the child the
|
|
10758
|
+
* parent's messages and fold state through shared storage: nothing is
|
|
10759
|
+
* copied, the child folds with the parent's memory tree, and neither can
|
|
10760
|
+
* alter what the other reads. It needs branch-bound store handles and is
|
|
10761
|
+
* refused, not silently downgraded, where they are missing. `mode: 'copy'`
|
|
10762
|
+
* is the historical mechanism — the parent's compiled context re-added as
|
|
10763
|
+
* the child's own messages — and works everywhere.
|
|
10764
|
+
*
|
|
10765
|
+
* **Rendering.** With `solve: 'reuse'` (the default) and nothing
|
|
10766
|
+
* overridden, the child's first request is the parent's request plus the
|
|
10767
|
+
* child's framing: the same system prompt and tool block, the parent's
|
|
10768
|
+
* turns presented as the child's own, and a cache marker on the end of
|
|
10769
|
+
* the parent's request so the prefix it wrote is read back. `solve:
|
|
10770
|
+
* 'fresh'` deliberately re-solves, e.g. at another budget; so does any
|
|
10771
|
+
* override that changes what is rendered. Those are operating choices —
|
|
10772
|
+
* the presentation changes and the provider cache misses.
|
|
10773
|
+
*
|
|
10774
|
+
* **Framing** is ordinary messages appended to the child before it runs:
|
|
10775
|
+
* the task, and whatever tells it which stream it is. The parent's turn
|
|
10776
|
+
* in flight is not in its store yet (see `getPendingAssistantRound`), so
|
|
10777
|
+
* a fork made from inside a tool call starts from the message before the
|
|
10778
|
+
* turn that made it.
|
|
10779
|
+
*
|
|
10780
|
+
* Context isolation is not process or filesystem isolation: parent and
|
|
10781
|
+
* child share tools, any shell and the workspace.
|
|
10782
|
+
*/
|
|
10783
|
+
async deriveAgent(options: DeriveAgentOptions): Promise<{
|
|
10784
|
+
agent: Agent;
|
|
10785
|
+
contextManager: ContextManager;
|
|
10786
|
+
cleanup: () => void;
|
|
10787
|
+
}> {
|
|
10788
|
+
const parent = this.agents.get(options.from);
|
|
10789
|
+
const parentConfig = this.agentConfigs.get(options.from);
|
|
10790
|
+
if (!parent || !parentConfig) {
|
|
10791
|
+
throw new Error(`deriveAgent: "${options.from}" is not a registered agent`);
|
|
10792
|
+
}
|
|
10793
|
+
const mode = options.mode ?? 'shared';
|
|
10794
|
+
const shared = mode === 'shared';
|
|
10795
|
+
if (!shared && options.inheritRefusals === true) {
|
|
10796
|
+
throw new Error(`deriveAgent: "${options.name}" was not created: inheritRefusals is for mode 'shared', and a copy brings no refusal ledger`);
|
|
10797
|
+
}
|
|
10798
|
+
if (!shared && options.solve !== undefined) {
|
|
10799
|
+
throw new Error(`deriveAgent: "${options.name}" was not created: solve is for mode 'shared', and a copy is not solved`);
|
|
10800
|
+
}
|
|
10801
|
+
|
|
10802
|
+
const config: AgentConfig = {
|
|
10803
|
+
...parentConfig,
|
|
10804
|
+
// The parent's LIVE settings, not just its recipe: a runtime budget or
|
|
10805
|
+
// think-policy override changes what it renders.
|
|
10806
|
+
...(parent.contextBudgetTokens !== undefined ? { contextBudgetTokens: parent.contextBudgetTokens } : {}),
|
|
10807
|
+
sameRoundThinkTextPolicy: parent.getEffectiveSameRoundThinkTextPolicy(),
|
|
10808
|
+
...options.config,
|
|
10809
|
+
name: options.name,
|
|
10810
|
+
strategy: options.strategy,
|
|
10811
|
+
// A shared inheritance has no copy step to rename the parent's turns,
|
|
10812
|
+
// so the mapping is declared: the parent's voice is the child's own,
|
|
10813
|
+
// presented under the name the parent's own requests use.
|
|
10814
|
+
...(shared
|
|
10815
|
+
? {
|
|
10816
|
+
selfParticipants: [...new Set([
|
|
10817
|
+
parent.name,
|
|
10818
|
+
...(parentConfig.selfParticipants ?? []),
|
|
10819
|
+
...(options.config?.selfParticipants ?? []),
|
|
10820
|
+
])],
|
|
10821
|
+
presentAs: options.config?.presentAs ?? parent.presentAs,
|
|
10822
|
+
}
|
|
10823
|
+
: { selfParticipants: options.config?.selfParticipants, presentAs: options.config?.presentAs }),
|
|
10824
|
+
};
|
|
10825
|
+
|
|
10826
|
+
const spec = taskForkSpec(options.name, {
|
|
10827
|
+
parent: options.from,
|
|
10828
|
+
spawnedBy: options.spawnedBy ?? options.from,
|
|
10829
|
+
...(options.resultTo ? { resultTo: options.resultTo } : {}),
|
|
10830
|
+
...(options.deadlineMs !== undefined ? { deadlineMs: options.deadlineMs } : {}),
|
|
10831
|
+
...(options.idleTimeoutMs !== undefined ? { idleTimeoutMs: options.idleTimeoutMs } : {}),
|
|
10832
|
+
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
10833
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
10834
|
+
inherit: {
|
|
10835
|
+
mode,
|
|
10836
|
+
...(options.solve ? { solve: options.solve } : {}),
|
|
10837
|
+
...(options.inheritRefusals !== undefined ? { refusals: options.inheritRefusals } : {}),
|
|
10838
|
+
},
|
|
10839
|
+
});
|
|
10840
|
+
const { name: _name, ...specBody } = {
|
|
10841
|
+
...spec,
|
|
10842
|
+
// The record states the mapping the agent's requests actually use.
|
|
10843
|
+
...(config.selfParticipants?.length ? { selfParticipants: config.selfParticipants } : {}),
|
|
10844
|
+
...(config.presentAs && config.presentAs !== config.name ? { presentAs: config.presentAs } : {}),
|
|
10845
|
+
...(options.kind ? { kind: options.kind } : {}),
|
|
10846
|
+
...(options.onParentEnd ? { onParentEnd: options.onParentEnd } : {}),
|
|
10847
|
+
};
|
|
10848
|
+
|
|
10849
|
+
const created = await this.createEphemeralAgent(config, specBody);
|
|
10850
|
+
try {
|
|
10851
|
+
if (this.agents.get(options.from) !== parent) {
|
|
10852
|
+
throw new Error(`deriveAgent: "${options.from}" ended while "${options.name}" was being derived from it`);
|
|
10853
|
+
}
|
|
10854
|
+
if (options.config?.allowedTools === undefined) {
|
|
10855
|
+
this.toolSurfaceOwners.set(options.name, this.toolSurfaceOwners.get(options.from) ?? options.from);
|
|
10856
|
+
}
|
|
10857
|
+
|
|
10858
|
+
if (!shared) {
|
|
10859
|
+
// The historical copy: the parent's compiled view, its turns renamed
|
|
10860
|
+
// so the child reads its inheritance as its own history.
|
|
10861
|
+
const { messages } = await parent.getContextManager().compile();
|
|
10862
|
+
for (const message of messages) {
|
|
10863
|
+
created.contextManager.addMessage(
|
|
10864
|
+
parent.isOwnParticipant(message.participant) ? options.name : message.participant,
|
|
10865
|
+
message.content,
|
|
10866
|
+
);
|
|
10867
|
+
}
|
|
10868
|
+
created.agent.markContextConsumed();
|
|
10869
|
+
}
|
|
10870
|
+
await this.framePendingRound(parent, created.contextManager, options, shared);
|
|
10871
|
+
for (const message of options.framing ?? []) {
|
|
10872
|
+
created.contextManager.addMessage(message.participant, message.content, message.metadata);
|
|
10873
|
+
}
|
|
10874
|
+
return created;
|
|
10875
|
+
} catch (error) {
|
|
10876
|
+
this.discardEphemeralCandidate(created.agent, created.contextManager);
|
|
10877
|
+
throw error;
|
|
10878
|
+
}
|
|
10879
|
+
}
|
|
10880
|
+
|
|
10881
|
+
private discardEphemeralCandidate(agent: Agent, contextManager: ContextManager): void {
|
|
10882
|
+
this.ephemeralCandidates.delete(agent);
|
|
10883
|
+
this.ephemeralAgents.delete(agent);
|
|
10884
|
+
if (this.toolSurfaceOwners.has(agent.name) && !this.agents.has(agent.name)) this.toolSurfaceOwners.delete(agent.name);
|
|
10885
|
+
this.releaseManager(agent);
|
|
10886
|
+
this.managerReleases.delete(agent);
|
|
10887
|
+
this.closeManager(contextManager);
|
|
10888
|
+
}
|
|
10889
|
+
|
|
10890
|
+
/**
|
|
10891
|
+
* The default wording for the call that derived a child: who it is, what
|
|
10892
|
+
* kind, and how long — the fields the consent event names, so the fork
|
|
10893
|
+
* never has to infer its own lifetime from context (Linn, 10-09).
|
|
10894
|
+
*/
|
|
10895
|
+
private derivedNote(parentName: string, options: DeriveAgentOptions): string {
|
|
10896
|
+
const record = this.registry.inspect(options.name);
|
|
10897
|
+
const kind = record?.kind ?? options.kind ?? 'task-fork';
|
|
10898
|
+
const onParentEnd = record?.onParentEnd ?? options.onParentEnd ?? 'orphan';
|
|
10899
|
+
const route = record?.relationships.resultTo ?? options.resultTo;
|
|
10900
|
+
const returns = route
|
|
10901
|
+
? route.as === 'message'
|
|
10902
|
+
? `your report returns to ${route.to} as mail under your own name`
|
|
10903
|
+
: `your result returns to ${route.to} as this call's result`
|
|
10904
|
+
: null;
|
|
10905
|
+
const lifetime =
|
|
10906
|
+
onParentEnd === 'end'
|
|
10907
|
+
? `You end when ${parentName} does${returns ? `; ${returns}` : ''}; nothing you do speaks as ${parentName}.`
|
|
10908
|
+
: `You finish your task and then end${returns ? `; ${returns}` : ''}; if ${parentName} ends first you keep running, as an orphan with the same return address.`;
|
|
10909
|
+
return (
|
|
10910
|
+
`[This call derived ${options.name}. You are ${options.name}, a ${kind}, continuing from here on your own branch. ` +
|
|
10911
|
+
`${lifetime} ${parentName} receives this call's result, not you.]`
|
|
10912
|
+
);
|
|
10913
|
+
}
|
|
10914
|
+
|
|
10915
|
+
/**
|
|
10916
|
+
* The parent's in-flight tool round, reproduced on the child's branch so
|
|
10917
|
+
* the child sees the call that made it (see `DeriveAgentOptions.pendingRound`).
|
|
10918
|
+
*/
|
|
10919
|
+
private async framePendingRound(
|
|
10920
|
+
parent: Agent,
|
|
10921
|
+
child: ContextManager,
|
|
10922
|
+
options: DeriveAgentOptions,
|
|
10923
|
+
shared: boolean,
|
|
10924
|
+
): Promise<void> {
|
|
10925
|
+
const round = options.pendingRound ?? {};
|
|
10926
|
+
if (round.include === false) return;
|
|
10927
|
+
const pending = this.pendingAssistantBlocks.get(parent.name);
|
|
10928
|
+
if (!pending) return;
|
|
10929
|
+
const calls = pending.filter((b): b is Extract<ContentBlock, { type: 'tool_use' }> => b.type === 'tool_use');
|
|
10930
|
+
if (calls.length === 0) return;
|
|
10931
|
+
const state = parent.state;
|
|
10932
|
+
const completed = state.status === 'waiting_for_tools' ? state.completed : [];
|
|
10933
|
+
const cap = this.resolveToolResultInlineCap(parent).cap;
|
|
10934
|
+
const { blocks: received } = await this.buildStoredToolResultContent(options.name, completed, cap);
|
|
10935
|
+
const byId = new Map(received.map((b) => [(b as { toolUseId: string }).toolUseId, b]));
|
|
10936
|
+
const open = calls.filter((c) => !byId.has(c.id));
|
|
10937
|
+
const madeBy = round.madeBy?.toolUseId ?? (open.length === 1 ? open[0]!.id : undefined);
|
|
10938
|
+
const results: ContentBlock[] = calls.map((call) => {
|
|
10939
|
+
const stored = byId.get(call.id);
|
|
10940
|
+
if (stored) return stored;
|
|
10941
|
+
const content =
|
|
10942
|
+
call.id === madeBy
|
|
10943
|
+
? round.madeBy?.result ?? this.derivedNote(parent.name, options)
|
|
10944
|
+
: round.results?.[call.id] ??
|
|
10945
|
+
`[No result yet: this call was still running in ${parent.name} when ${options.name} was derived.]`;
|
|
10946
|
+
return { type: 'tool_result' as const, toolUseId: call.id, toolName: call.name, content, isError: false };
|
|
10947
|
+
});
|
|
10948
|
+
const metadata = { kind: 'dendrite-pending-round', from: parent.name } as unknown as MessageMetadata;
|
|
10949
|
+
child.addMessage(shared ? parent.name : options.name, structuredClone(pending), metadata);
|
|
10950
|
+
child.addMessage('user', results, metadata);
|
|
10951
|
+
}
|
|
10952
|
+
|
|
10953
|
+
/**
|
|
10954
|
+
* The assistant turn an agent has produced but not yet stored: its tool
|
|
10955
|
+
* calls and any text before them, held until every tool result is in so
|
|
10956
|
+
* the pair lands adjacently. A host deriving an agent from inside one of
|
|
10957
|
+
* those tool calls can reproduce the turn in the child's framing.
|
|
10958
|
+
*/
|
|
10959
|
+
getPendingAssistantRound(agentName: string): ContentBlock[] | undefined {
|
|
10960
|
+
const blocks = this.pendingAssistantBlocks.get(agentName);
|
|
10961
|
+
return blocks ? structuredClone(blocks) : undefined;
|
|
10962
|
+
}
|
|
10963
|
+
|
|
10964
|
+
/**
|
|
10965
|
+
* Bring an ended bounded job back as a new incarnation of the same
|
|
10966
|
+
* identity, on the context it left: an isolated agent's own namespace, or
|
|
10967
|
+
* a derived agent's own branch. Nothing is re-derived and nothing is
|
|
10968
|
+
* re-seeded — the agent continues from its last stored message, which is
|
|
10969
|
+
* the end of its last complete round (a round whose tool results never
|
|
10970
|
+
* all arrived was never stored).
|
|
10971
|
+
*
|
|
10972
|
+
* Use it for a job that was stopped, failed, or was interrupted by a
|
|
10973
|
+
* restart. Configuration is not persisted with an agent, so the caller
|
|
10974
|
+
* supplies it again; the role mapping a derived agent used is restored
|
|
10975
|
+
* from its record unless the config sets one. Relationships are kept
|
|
10976
|
+
* where the other agent still exists; a job whose spawner or result
|
|
10977
|
+
* recipient is gone resumes without that edge and can be reparented.
|
|
10978
|
+
*
|
|
10979
|
+
* External effects are not replayed and not undone: a tool the previous
|
|
10980
|
+
* incarnation ran before it ended has had its effect, and a call it never
|
|
10981
|
+
* received a result for may be made again by the model. Drive the result
|
|
10982
|
+
* with `runEphemeralToCompletion`.
|
|
10983
|
+
*/
|
|
10984
|
+
async resumeAgent(name: string, config: Omit<AgentConfig, 'name'>): Promise<{
|
|
10985
|
+
agent: Agent;
|
|
10986
|
+
contextManager: ContextManager;
|
|
10987
|
+
cleanup: () => void;
|
|
10988
|
+
}> {
|
|
10989
|
+
if (this.resumesPending.has(name)) {
|
|
10990
|
+
throw new Error(`resumeAgent: "${name}" is already being resumed`);
|
|
10991
|
+
}
|
|
10992
|
+
if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) {
|
|
10993
|
+
throw new Error(`resumeAgent: "${name}" is still registered; only an ended agent resumes`);
|
|
10994
|
+
}
|
|
10995
|
+
const record = this.registry.inspect(name);
|
|
10996
|
+
if (!record?.ended) throw new Error(`resumeAgent: "${name}" is not a known ended agent`);
|
|
10997
|
+
if (record.lifetime.kind !== 'task') {
|
|
10998
|
+
throw new Error(
|
|
10999
|
+
`resumeAgent: "${name}" has a ${record.lifetime.kind} lifetime; only bounded jobs resume this way`,
|
|
11000
|
+
);
|
|
11001
|
+
}
|
|
11002
|
+
const fullConfig: AgentConfig = {
|
|
11003
|
+
...config,
|
|
11004
|
+
name,
|
|
11005
|
+
...(config.selfParticipants === undefined && record.selfParticipants
|
|
11006
|
+
? { selfParticipants: record.selfParticipants }
|
|
11007
|
+
: {}),
|
|
11008
|
+
...(config.presentAs === undefined && record.presentAs ? { presentAs: record.presentAs } : {}),
|
|
11009
|
+
};
|
|
11010
|
+
const spawnedBy = record.relationships.spawnedBy;
|
|
11011
|
+
const resultTo = record.relationships.resultTo;
|
|
11012
|
+
const keepSpawner = spawnedBy !== undefined && this.registry.has(spawnedBy);
|
|
11013
|
+
const spec: AgentSpec = {
|
|
11014
|
+
name,
|
|
11015
|
+
kind: record.kind,
|
|
11016
|
+
model: fullConfig.model,
|
|
11017
|
+
roles: record.roles,
|
|
11018
|
+
lifetime: record.lifetime,
|
|
11019
|
+
...(record.activation ? { activation: record.activation } : {}),
|
|
11020
|
+
...(keepSpawner ? { spawnedBy, onParentEnd: record.onParentEnd } : {}),
|
|
11021
|
+
...(resultTo && this.registry.has(resultTo.to) ? { resultTo } : {}),
|
|
11022
|
+
...(record.inherit ? { inherit: record.inherit } : {}),
|
|
11023
|
+
...(fullConfig.selfParticipants?.length ? { selfParticipants: fullConfig.selfParticipants } : {}),
|
|
11024
|
+
...(fullConfig.presentAs && fullConfig.presentAs !== name ? { presentAs: fullConfig.presentAs } : {}),
|
|
11025
|
+
...(record.namespace ? { namespace: record.namespace } : {}),
|
|
11026
|
+
...(record.metadata ? { metadata: record.metadata } : {}),
|
|
11027
|
+
};
|
|
11028
|
+
if (this.beforeDerive) {
|
|
11029
|
+
const verdict = this.beforeDerive(spec);
|
|
11030
|
+
if (verdict && typeof verdict === 'object' && 'refuse' in verdict) {
|
|
11031
|
+
throw new Error(`${name} was not resumed: ${verdict.refuse}`);
|
|
11032
|
+
}
|
|
11033
|
+
}
|
|
11034
|
+
|
|
11035
|
+
this.resumesPending.set(name, undefined);
|
|
11036
|
+
const release: ManagerRelease = { released: false, calls: new Set() };
|
|
11037
|
+
let contextManager: ContextManager;
|
|
11038
|
+
try {
|
|
11039
|
+
contextManager = await this.reopenAgentContext(record, config.strategy, this.ephemeralAuxiliaryMembraneFor(name, release));
|
|
11040
|
+
} catch (error) {
|
|
11041
|
+
this.resumesPending.delete(name);
|
|
11042
|
+
throw error;
|
|
11043
|
+
}
|
|
11044
|
+
this.trackManager(contextManager);
|
|
11045
|
+
release.manager = contextManager;
|
|
11046
|
+
let agent: Agent;
|
|
11047
|
+
try {
|
|
11048
|
+
if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) {
|
|
11049
|
+
throw new Error(`resumeAgent: "${name}" was registered while it was being resumed`);
|
|
11050
|
+
}
|
|
11051
|
+
agent = new Agent(fullConfig, contextManager, this.membrane);
|
|
11052
|
+
this.adoptToolResultGuard(agent);
|
|
11053
|
+
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
11054
|
+
} catch (error) {
|
|
11055
|
+
this.resumesPending.delete(name);
|
|
11056
|
+
this.closeManager(contextManager);
|
|
11057
|
+
throw error;
|
|
11058
|
+
}
|
|
11059
|
+
|
|
11060
|
+
this.usedEphemeralAgentNames.set(name, 'run'); // a resumed job is an ephemeral run: no global fallback (takesGlobalFallback)
|
|
11061
|
+
this.ephemeralAgents.add(agent); // and its usage counts in the shared ephemeral entry (#558)
|
|
11062
|
+
this.ephemeralCandidates.set(agent, { contextManager, config: fullConfig, resume: true, after: record.incarnation, spec });
|
|
11063
|
+
this.managerReleases.set(agent, release);
|
|
11064
|
+
this.resumesPending.set(name, agent);
|
|
11065
|
+
const cleanup = () => {
|
|
11066
|
+
if (this.resumesPending.get(name) === agent) this.resumesPending.delete(name);
|
|
11067
|
+
this.releasedByCleanup.add(agent);
|
|
11068
|
+
this.releaseManager(agent);
|
|
11069
|
+
};
|
|
11070
|
+
return { agent, contextManager, cleanup };
|
|
11071
|
+
}
|
|
11072
|
+
|
|
11073
|
+
/**
|
|
11074
|
+
* A context to read an agent's history from, without changing the live
|
|
11075
|
+
* system.
|
|
11076
|
+
*
|
|
11077
|
+
* - A live agent: a snapshot derived from its context — at the head, or
|
|
11078
|
+
* at `atSequence` for an earlier state. The snapshot is its own branch;
|
|
11079
|
+
* the agent keeps running and is not affected by anything done to it.
|
|
11080
|
+
* - An ended agent: the context it left, reopened where it lives.
|
|
11081
|
+
*
|
|
11082
|
+
* The returned manager uses a passthrough strategy: it shows what is
|
|
11083
|
+
* stored, raw. An ended agent's context is the real one — treat it as
|
|
11084
|
+
* read-only unless you mean to change what a later resume starts from.
|
|
11085
|
+
*/
|
|
11086
|
+
async inspectAgentContext(name: string, options: { atSequence?: number } = {}): Promise<ContextManager> {
|
|
11087
|
+
const live = this.registry.has(name) ? this.agents.get(name) : undefined;
|
|
11088
|
+
if (live) {
|
|
11089
|
+
const source = live.getContextManager() as ContextManager & Partial<DerivingContextManager>;
|
|
11090
|
+
if (typeof source.derive !== 'function') {
|
|
11091
|
+
throw new Error(
|
|
11092
|
+
'inspectAgentContext: a snapshot of a live agent needs a @animalabs/context-manager that provides ContextManager.derive',
|
|
11093
|
+
);
|
|
11094
|
+
}
|
|
11095
|
+
const taken = new Set(this.store.listBranches().map((branch) => branch.name));
|
|
11096
|
+
let n = 1;
|
|
11097
|
+
while (taken.has(`dendrite/inspect/${name}/${n}`)) n++;
|
|
11098
|
+
return source.derive({
|
|
11099
|
+
branch: `dendrite/inspect/${name}/${n}`,
|
|
11100
|
+
strategy: new PassthroughStrategy(),
|
|
11101
|
+
membrane: this.membrane,
|
|
11102
|
+
solve: 'fresh',
|
|
11103
|
+
...(options.atSequence !== undefined ? { atSequence: options.atSequence } : {}),
|
|
11104
|
+
});
|
|
11105
|
+
}
|
|
11106
|
+
const record = this.registry.inspect(name);
|
|
11107
|
+
if (!record) throw new Error(`inspectAgentContext: "${name}" is not a known agent`);
|
|
11108
|
+
if (options.atSequence !== undefined) {
|
|
11109
|
+
throw new Error('inspectAgentContext: atSequence applies to a live agent; an ended agent is read as it was left');
|
|
11110
|
+
}
|
|
11111
|
+
return this.reopenAgentContext(record, new PassthroughStrategy());
|
|
11112
|
+
}
|
|
11113
|
+
|
|
11114
|
+
/** Open the context an ended agent left, wherever it lives. */
|
|
11115
|
+
private async reopenAgentContext(record: AgentRecord, strategy?: ContextStrategy, membrane: Membrane = this.membrane): Promise<ContextManager> {
|
|
11116
|
+
// The residents' view as createAgent gives it: held rows out, and another
|
|
11117
|
+
// agent's stored context injections out, since those are that agent's
|
|
11118
|
+
// context, not conversation (Weft's ninth-round probe: an ended reader
|
|
11119
|
+
// reopened for inspection showed a co-resident's injection it never saw live).
|
|
11120
|
+
const residentSees = (name: string) => (metadata: unknown): boolean =>
|
|
11121
|
+
RESIDENT_VIEW_FILTER({ metadata } as StoredMessage) && !isAnotherAgentsInjection(metadata, name);
|
|
11122
|
+
// A context derived from a reader of the shared slot sees, as it did live
|
|
11123
|
+
// (deriveSharedContext, #291), what its source saw and its own stored
|
|
11124
|
+
// injections; its source's view is composed the same way, up the shared
|
|
11125
|
+
// sources the registry names, to the reader the first was derived from.
|
|
11126
|
+
const derivedSees = (from: AgentRecord, walked = new Set<string>()): ((metadata: unknown) => boolean) => {
|
|
11127
|
+
const source = from.inherit?.mode === 'shared' ? from.inherit.from : undefined;
|
|
11128
|
+
if (source === undefined || walked.has(from.name)) return residentSees(from.name);
|
|
11129
|
+
walked.add(from.name);
|
|
11130
|
+
const sourceRecord = this.registry.inspect(source);
|
|
11131
|
+
const sourceSees = sourceRecord ? derivedSees(sourceRecord, walked) : residentSees(source);
|
|
11132
|
+
return (metadata) => sourceSees(metadata) || isOwnInjection(metadata, from.name);
|
|
11133
|
+
};
|
|
11134
|
+
// The predicate is the manager's view filter, and is recorded beside it,
|
|
11135
|
+
// so main's checks of whether an agent sees a row (agentSees) ask what its
|
|
11136
|
+
// view holds when the reopened context is live.
|
|
11137
|
+
const seen = (contextManager: ContextManager, sees: (metadata: unknown) => boolean): ContextManager => {
|
|
11138
|
+
this.storeSees.set(contextManager, sees);
|
|
11139
|
+
return contextManager;
|
|
11140
|
+
};
|
|
11141
|
+
if (record.inherit?.mode === 'shared') {
|
|
11142
|
+
const derivation = record.inherit.derivation as ContextDerivationRecord | undefined;
|
|
11143
|
+
const reopen = (ContextManager as unknown as { reopenDerived?: ReopenDerived }).reopenDerived;
|
|
11144
|
+
if (!derivation || typeof reopen !== 'function') {
|
|
11145
|
+
throw new Error(
|
|
11146
|
+
`Agent "${record.name}": its context is a derived branch, which this ` +
|
|
11147
|
+
'@animalabs/context-manager cannot reopen (ContextManager.reopenDerived)',
|
|
11148
|
+
);
|
|
11149
|
+
}
|
|
11150
|
+
// A context derived from a reader of the shared slot keeps the view it had.
|
|
11151
|
+
const sees = derivation.slots?.messageNamespace === null ? derivedSees(record) : undefined;
|
|
11152
|
+
const reopened = await reopen.call(ContextManager, {
|
|
11153
|
+
store: this.store,
|
|
11154
|
+
derivation,
|
|
11155
|
+
strategy: strategy ?? new PassthroughStrategy(),
|
|
11156
|
+
membrane,
|
|
11157
|
+
...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : {}),
|
|
11158
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
11159
|
+
});
|
|
11160
|
+
return sees ? seen(reopened, sees) : reopened;
|
|
11161
|
+
}
|
|
11162
|
+
if (!record.namespace) {
|
|
11163
|
+
throw new Error(`Agent "${record.name}": its record names no context to reopen`);
|
|
11164
|
+
}
|
|
11165
|
+
// A reader of the residents' shared slot (a resident dropped from the
|
|
11166
|
+
// configuration, say) is reopened as createAgent opened it: on the
|
|
11167
|
+
// shared slot, through the residents' view. Isolated, its namespace
|
|
11168
|
+
// holds none of what it read (nissa's #546 review, round 2).
|
|
11169
|
+
const sees = record.readsSharedSlot === true ? residentSees(record.name) : undefined;
|
|
11170
|
+
const opened = await ContextManager.open({
|
|
11171
|
+
store: this.store,
|
|
11172
|
+
namespace: record.namespace,
|
|
11173
|
+
...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : { isolate: true }),
|
|
11174
|
+
strategy: strategy ?? new PassthroughStrategy(),
|
|
11175
|
+
membrane,
|
|
11176
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
11177
|
+
});
|
|
11178
|
+
return sees ? seen(opened, sees) : opened;
|
|
11179
|
+
}
|
|
11180
|
+
|
|
11181
|
+
/** Open a derived agent's context on a branch of its own (see deriveAgent). */
|
|
11182
|
+
private async deriveSharedContext(
|
|
11183
|
+
config: AgentConfig,
|
|
11184
|
+
inherit: NonNullable<AgentSpec['inherit']>,
|
|
11185
|
+
release: ManagerRelease,
|
|
11186
|
+
): Promise<{ contextManager: ContextManager; inherit: NonNullable<AgentSpec['inherit']>; namespace: string }> {
|
|
11187
|
+
const parent = this.agents.get(inherit.from);
|
|
11188
|
+
if (!parent) {
|
|
11189
|
+
throw new Error(`Agent "${config.name}": context source "${inherit.from}" is not a registered agent`);
|
|
11190
|
+
}
|
|
11191
|
+
if (this.surgeryHold) {
|
|
11192
|
+
throw new Error(
|
|
11193
|
+
`Agent "${config.name}" refused: the store is under live ${this.surgeryHold.verb} ` +
|
|
11194
|
+
`for ${this.surgeryHold.agentName} — retry when it completes`,
|
|
11195
|
+
);
|
|
11196
|
+
}
|
|
11197
|
+
const source = parent.getContextManager() as ContextManager & Partial<DerivingContextManager>;
|
|
11198
|
+
if (typeof source.derive !== 'function') {
|
|
11199
|
+
throw new Error(
|
|
11200
|
+
`Agent "${config.name}": shared context inheritance needs a @animalabs/context-manager ` +
|
|
11201
|
+
'that provides ContextManager.derive; use inherit.mode "copy" with this version',
|
|
11202
|
+
);
|
|
11203
|
+
}
|
|
11204
|
+
// The branch is the child's durable state and outlives it. Names from an
|
|
11205
|
+
// earlier process may still own a branch of the same name.
|
|
11206
|
+
const taken = new Set(this.store.listBranches().map((branch) => branch.name));
|
|
11207
|
+
let ownBranch = `dendrite/${config.name}`;
|
|
11208
|
+
for (let n = 2; taken.has(ownBranch); n++) ownBranch = `dendrite/${config.name}~${n}`;
|
|
11209
|
+
|
|
11210
|
+
// The fork's view: what its source sees, and its own stored context
|
|
11211
|
+
// injections. context-manager's derive would give it the source's filter
|
|
11212
|
+
// alone, which is tied to the source's name (isAnotherAgentsInjection), so
|
|
11213
|
+
// the fork's own injections were hidden from its compile while its
|
|
11214
|
+
// parent's showed (#291; Weft's eleventh note, on the real compile path).
|
|
11215
|
+
// The composed predicate goes to derive, so the first compile already
|
|
11216
|
+
// reads through it, and is recorded beside the manager (storeSees), so
|
|
11217
|
+
// main's checks of whether an agent sees a row (agentSees: a tool
|
|
11218
|
+
// boundary's injection, an arrival, a reader of an unread message) ask
|
|
11219
|
+
// what its compile shows. A source with no view recorded has no filter
|
|
11220
|
+
// (an isolated run's): it sees every row, so its fork keeps none, and
|
|
11221
|
+
// sees its own.
|
|
11222
|
+
const sourceSees = this.storeSees.get(source);
|
|
11223
|
+
const sees = sourceSees
|
|
11224
|
+
? (metadata: unknown): boolean => sourceSees(metadata) || isOwnInjection(metadata, config.name)
|
|
11225
|
+
: undefined;
|
|
11226
|
+
const contextManager = await source.derive({
|
|
11227
|
+
branch: ownBranch,
|
|
11228
|
+
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
11229
|
+
// The run's auxiliary membrane, as the copy path's: its compression calls
|
|
11230
|
+
// are withdrawn once the run is released (Weft's ninth-round reading).
|
|
11231
|
+
membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
|
|
11232
|
+
solve: inherit.solve ?? 'reuse',
|
|
11233
|
+
inherit: { refusals: inherit.refusals === true },
|
|
11234
|
+
...(inherit.atSequence !== undefined ? { atSequence: inherit.atSequence } : {}),
|
|
11235
|
+
...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : {}),
|
|
11236
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
11237
|
+
});
|
|
11238
|
+
if (sees) this.storeSees.set(contextManager, sees);
|
|
11239
|
+
const derivation = (contextManager as ContextManager & Partial<DerivingContextManager>).getDerivation?.();
|
|
11240
|
+
return {
|
|
11241
|
+
contextManager,
|
|
11242
|
+
inherit: {
|
|
11243
|
+
...inherit,
|
|
11244
|
+
solve: inherit.solve ?? 'reuse',
|
|
11245
|
+
refusals: inherit.refusals === true,
|
|
11246
|
+
ownBranch,
|
|
11247
|
+
...(derivation
|
|
11248
|
+
? { branch: derivation.parentBranch, atSequence: derivation.atSequence, derivation }
|
|
11249
|
+
: {}),
|
|
11250
|
+
},
|
|
11251
|
+
namespace: this.registry.get(inherit.from)?.namespace ?? `subagent/${config.name}`,
|
|
11252
|
+
};
|
|
11253
|
+
}
|
|
11254
|
+
|
|
11255
|
+
// ==========================================================================
|
|
11256
|
+
// Dendrite: agent registry, lifecycle and attributed communication
|
|
11257
|
+
// ==========================================================================
|
|
11258
|
+
//
|
|
11259
|
+
// One trust domain: there is no permission check on any of this. Any
|
|
11260
|
+
// caller may list, cancel, stop or reparent any agent. Context isolation
|
|
11261
|
+
// between agents is not process or filesystem isolation — agents sharing
|
|
11262
|
+
// tools, a shell or a workspace can affect the same external state.
|
|
11263
|
+
|
|
11264
|
+
/** Flat list of registered agents with their roles, lifetime and relationships. */
|
|
11265
|
+
listAgents(options: { includeEnded?: boolean } = {}): AgentRecord[] {
|
|
11266
|
+
return structuredClone(this.registry.list(options));
|
|
11267
|
+
}
|
|
11268
|
+
|
|
11269
|
+
/** One agent's record: the live one, else its most recent ended one. */
|
|
11270
|
+
getAgentRecord(name: string): AgentRecord | null {
|
|
11271
|
+
const record = this.registry.inspect(name);
|
|
11272
|
+
return record ? structuredClone(record) : null;
|
|
11273
|
+
}
|
|
11274
|
+
|
|
11275
|
+
/** The agent that owns default delivery, or null before any is declared. */
|
|
11276
|
+
getPrimaryAgentName(): string | null {
|
|
11277
|
+
return this.registry.primary();
|
|
11278
|
+
}
|
|
11279
|
+
|
|
11280
|
+
/**
|
|
11281
|
+
* Activations in flight, each with the agent that owns the work. Derived
|
|
11282
|
+
* from the live turn state, so it cannot disagree with what is running.
|
|
11283
|
+
*/
|
|
11284
|
+
listActivations(): ActivationRecord[] {
|
|
11285
|
+
const activations: ActivationRecord[] = [];
|
|
11286
|
+
for (const [name, token] of this.activeTurnTokens) {
|
|
11287
|
+
const record = this.registry.get(name);
|
|
11288
|
+
if (!record) continue;
|
|
11289
|
+
const trigger = this.activeTurnTriggers.get(name);
|
|
11290
|
+
activations.push({
|
|
11291
|
+
id: `${name}#${record.incarnation}:${token}`,
|
|
11292
|
+
owner: { agent: name, incarnation: record.incarnation },
|
|
11293
|
+
...(trigger?.timestamp !== undefined ? { requestedAt: trigger.timestamp } : {}),
|
|
11294
|
+
...(trigger?.reason ? { reason: trigger.reason } : {}),
|
|
11295
|
+
...(trigger?.source ? { source: trigger.source } : {}),
|
|
11296
|
+
...(trigger?.channelId ? { channelId: trigger.channelId } : {}),
|
|
11297
|
+
});
|
|
11298
|
+
}
|
|
11299
|
+
return activations;
|
|
11300
|
+
}
|
|
11301
|
+
|
|
11302
|
+
/**
|
|
11303
|
+
* Policy epochs currently in force (tune-out holds), read from the
|
|
11304
|
+
* coordinator's own durable state — not mirrored. An epoch is neither an
|
|
11305
|
+
* agent nor a task: ending one leaves both running.
|
|
11306
|
+
*/
|
|
11307
|
+
listPolicyEpochs(): PolicyEpoch[] {
|
|
11308
|
+
if (!this.channelRegistry) return [];
|
|
11309
|
+
const owner = this.registry.primary();
|
|
11310
|
+
const epochs: PolicyEpoch[] = [];
|
|
11311
|
+
for (const entry of this.channelRegistry.listChannelsRaw()) {
|
|
11312
|
+
const state = this.channelRegistry.getTuneOutState(entry.serverId, entry.descriptor.id);
|
|
11313
|
+
if (!state) continue;
|
|
11314
|
+
epochs.push({
|
|
11315
|
+
id: state.params.epochId,
|
|
11316
|
+
kind: 'tune-out',
|
|
11317
|
+
scope: { serverId: entry.serverId, channelId: entry.descriptor.id },
|
|
11318
|
+
...(owner ? { owner } : {}),
|
|
11319
|
+
...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}),
|
|
11320
|
+
startedAtSequence: state.params.startedAtSequence,
|
|
11321
|
+
...(state.params.expiresAtMs !== undefined ? { expiresAtMs: state.params.expiresAtMs } : {}),
|
|
11322
|
+
});
|
|
11323
|
+
}
|
|
11324
|
+
const scope = this.tuneOutCoordinator?.getAllExcept();
|
|
11325
|
+
if (scope) {
|
|
11326
|
+
epochs.push({
|
|
11327
|
+
id: scope.params.epochId,
|
|
11328
|
+
kind: 'tune-out',
|
|
11329
|
+
scope: { kind: 'all-except', except: scope.params.except },
|
|
11330
|
+
...(owner ? { owner } : {}),
|
|
11331
|
+
...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}),
|
|
11332
|
+
startedAtSequence: scope.params.startedAtSequence,
|
|
11333
|
+
expiresAtMs: scope.params.expiresAtMs,
|
|
11334
|
+
});
|
|
11335
|
+
}
|
|
11336
|
+
return epochs;
|
|
11337
|
+
}
|
|
11338
|
+
|
|
11339
|
+
/**
|
|
11340
|
+
* Cancel an agent's activation in flight, leaving the agent registered.
|
|
11341
|
+
* For a bounded job, whose lifetime is that one run, this ends the job.
|
|
11342
|
+
* Returns false when nothing was running.
|
|
11343
|
+
*/
|
|
11344
|
+
cancelActivation(name: string, options: { by?: string; reason?: string } = {}): boolean {
|
|
11345
|
+
if (!this.registry.has(name)) throw new Error(`cancelActivation: "${name}" is not a registered agent`);
|
|
11346
|
+
const run = this.ephemeralRuns.get(name);
|
|
11347
|
+
if (run) {
|
|
11348
|
+
run.endReason ??= 'stopped';
|
|
11349
|
+
run.endedBy ??= options.by;
|
|
11350
|
+
// The awaiting caller is told who stopped the job and why, whether or
|
|
11351
|
+
// not a stream was in flight: settled first, so the aborted stream's own
|
|
11352
|
+
// settle ('Stream cancelled') finds the run already settled. The caller's
|
|
11353
|
+
// teardown runs after this call, as for stopAgent.
|
|
11354
|
+
run.settle.reject(new AgentStoppedError(name, 'stopped', options.by, options.reason));
|
|
11355
|
+
}
|
|
11356
|
+
const cancelled = this.abortInference(name, options.reason ?? 'cancelled');
|
|
11357
|
+
if (cancelled || run) {
|
|
11358
|
+
this.emitTrace({
|
|
11359
|
+
type: 'dendrite:activation-cancelled',
|
|
11360
|
+
agentName: name,
|
|
11361
|
+
...(options.by ? { by: options.by } : {}),
|
|
11362
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
11363
|
+
});
|
|
11364
|
+
}
|
|
11365
|
+
return cancelled || run !== undefined;
|
|
11366
|
+
}
|
|
11367
|
+
|
|
11368
|
+
/**
|
|
11369
|
+
* End an agent: cancel its run, remove it from the framework, and settle
|
|
11370
|
+
* what it spawned — attention tenants end with it; task work is orphaned,
|
|
11371
|
+
* notified and offered for reparenting, and keeps running.
|
|
11372
|
+
*
|
|
11373
|
+
* The default-delivery owner cannot be stopped (the framework needs
|
|
11374
|
+
* exactly one); cancel its activation instead. A persistent agent stopped
|
|
11375
|
+
* here is declared by configuration and returns at the next boot.
|
|
11376
|
+
*/
|
|
11377
|
+
stopAgent(name: string, options: { by?: string; reason?: string } = {}): EndOutcome {
|
|
11378
|
+
const record = this.registry.get(name);
|
|
11379
|
+
if (!record) throw new Error(`stopAgent: "${name}" is not a registered agent`);
|
|
11380
|
+
if (record.roles.defaultDelivery) {
|
|
11381
|
+
throw new Error(
|
|
11382
|
+
`stopAgent: "${name}" owns default delivery and cannot be stopped; use cancelActivation to end its current run`,
|
|
11383
|
+
);
|
|
11384
|
+
}
|
|
11385
|
+
const outcome = this.registry.end(name, 'stopped', options.by)!;
|
|
11386
|
+
this.teardownEndedAgent(outcome.ended, options.reason);
|
|
11387
|
+
this.settleEndedAgent(outcome);
|
|
11388
|
+
return structuredClone(outcome);
|
|
11389
|
+
}
|
|
11390
|
+
|
|
11391
|
+
/**
|
|
11392
|
+
* Give an agent a new parent. Clears its orphan state; a result route
|
|
11393
|
+
* whose recipient is gone follows the new parent, and the mail it already
|
|
11394
|
+
* produced for the lost recipient, results and messages, is delivered to
|
|
11395
|
+
* the new one.
|
|
11396
|
+
*/
|
|
11397
|
+
reparentAgent(name: string, newParent: string, options: { by?: string } = {}): AgentRecord {
|
|
11398
|
+
// An orphan that finished before it was adopted: its record stays as it
|
|
11399
|
+
// ended, and the mail it held for a recipient that is gone goes to the
|
|
11400
|
+
// new parent.
|
|
11401
|
+
if (!this.registry.has(name) && this.registry.inspect(name)?.ended) {
|
|
11402
|
+
const ended = this.registry.readdressHeldMail(name, newParent);
|
|
11403
|
+
this.emitTrace({
|
|
11404
|
+
type: 'dendrite:agent-reparented',
|
|
11405
|
+
agentName: name,
|
|
11406
|
+
parent: newParent,
|
|
11407
|
+
...(options.by ? { by: options.by } : {}),
|
|
11408
|
+
});
|
|
11409
|
+
for (const mail of ended.deliverable) this.attemptMailDelivery(mail);
|
|
11410
|
+
return structuredClone(ended.record);
|
|
11411
|
+
}
|
|
11412
|
+
const { record, deliverable } = this.registry.reparent(name, newParent);
|
|
11413
|
+
this.emitTrace({
|
|
11414
|
+
type: 'dendrite:agent-reparented',
|
|
11415
|
+
agentName: name,
|
|
11416
|
+
parent: newParent,
|
|
11417
|
+
...(options.by ? { by: options.by } : {}),
|
|
11418
|
+
});
|
|
11419
|
+
this.lifecycleNotice(
|
|
11420
|
+
name,
|
|
11421
|
+
`[${NOTICE_TAG.agentLifecycle}: ${newParent} is now your parent` +
|
|
11422
|
+
(record.relationships.resultTo?.to === newParent ? ' and will receive your result' : '') +
|
|
11423
|
+
'.]',
|
|
11424
|
+
{ event: 'reparented', parent: newParent },
|
|
11425
|
+
);
|
|
11426
|
+
for (const mail of deliverable) this.attemptMailDelivery(mail);
|
|
11427
|
+
return structuredClone(record);
|
|
11428
|
+
}
|
|
11429
|
+
|
|
11430
|
+
/**
|
|
11431
|
+
* An explicit message from one agent to another: the sender's verbatim
|
|
11432
|
+
* content, in the recipient's context under the SENDER's name, with the
|
|
11433
|
+
* sender's identity and incarnation and the causal chain in metadata.
|
|
11434
|
+
* Never an unmarked insertion into the recipient's own voice.
|
|
11435
|
+
*/
|
|
11436
|
+
sendAgentMessage(
|
|
11437
|
+
from: string,
|
|
11438
|
+
to: string,
|
|
11439
|
+
content: ContentBlock[],
|
|
11440
|
+
options: { causedBy?: string[]; wake?: boolean } = {},
|
|
11441
|
+
): { mailId: string; delivered: boolean } {
|
|
11442
|
+
if (!this.registry.inspect(from)) throw new Error(`sendAgentMessage: sender "${from}" is not a known agent`);
|
|
11443
|
+
if (!this.registry.inspect(to)) throw new Error(`sendAgentMessage: recipient "${to}" is not a known agent`);
|
|
11444
|
+
this.registry.noteMessagePath(from, to);
|
|
11445
|
+
const mail = this.registry.holdMail({
|
|
11446
|
+
kind: 'message',
|
|
11447
|
+
from: this.registry.ref(from),
|
|
11448
|
+
to,
|
|
11449
|
+
content,
|
|
11450
|
+
...(options.causedBy?.length ? { causedBy: options.causedBy } : {}),
|
|
11451
|
+
});
|
|
11452
|
+
return { mailId: mail.id, delivered: this.attemptMailDelivery(mail, options.wake ?? true) };
|
|
11453
|
+
}
|
|
11454
|
+
|
|
11455
|
+
/**
|
|
11456
|
+
* Return a bounded job's result along the agent's declared result route.
|
|
11457
|
+
* The result is recorded durably before delivery is attempted, so it is
|
|
11458
|
+
* neither lost nor delivered twice across a restart; if the recipient is
|
|
11459
|
+
* gone it is held as attributed mail until the agent is reparented.
|
|
11460
|
+
*
|
|
11461
|
+
* May be called after the agent's run has ended — its record stays
|
|
11462
|
+
* inspectable. Refused for a `tool-result` route: there the awaiting
|
|
11463
|
+
* caller already holds the result and delivers it itself.
|
|
11464
|
+
*/
|
|
11465
|
+
deliverAgentResult(
|
|
11466
|
+
from: string,
|
|
11467
|
+
content: ContentBlock[],
|
|
11468
|
+
options: { causedBy?: string[] } = {},
|
|
11469
|
+
): { mailId: string; delivered: boolean; to: string } {
|
|
11470
|
+
const record = this.registry.inspect(from);
|
|
11471
|
+
if (!record) throw new Error(`deliverAgentResult: "${from}" is not a known agent`);
|
|
11472
|
+
const route = record.relationships.resultTo;
|
|
11473
|
+
if (!route) throw new Error(`deliverAgentResult: "${from}" has no result route`);
|
|
11474
|
+
if (route.as !== 'message') {
|
|
11475
|
+
throw new Error(
|
|
11476
|
+
`deliverAgentResult: "${from}" returns its result as a ${route.as}; the caller awaiting the run delivers it`,
|
|
11477
|
+
);
|
|
11478
|
+
}
|
|
11479
|
+
const mail = this.registry.holdMail({
|
|
11480
|
+
kind: 'result',
|
|
11481
|
+
from: { agent: from, incarnation: record.incarnation },
|
|
11482
|
+
to: route.to,
|
|
11483
|
+
content,
|
|
11484
|
+
...(options.causedBy?.length ? { causedBy: options.causedBy } : {}),
|
|
11485
|
+
});
|
|
11486
|
+
// A finished job's output survives a hard exit from here on: the hold is
|
|
11487
|
+
// in the registry's recovery file as it is written (persistAgentRegistry),
|
|
11488
|
+
// and holdMail failed this call if it couldn't be. The release, once the
|
|
11489
|
+
// message has landed, waits for the store's sync (releaseLandedMail).
|
|
11490
|
+
return { mailId: mail.id, delivered: this.attemptMailDelivery(mail), to: route.to };
|
|
11491
|
+
}
|
|
11492
|
+
|
|
11493
|
+
/** Communications not yet handed to their recipient, and why. */
|
|
11494
|
+
listHeldMail(filter: { to?: string; from?: string } = {}): HeldMail[] {
|
|
11495
|
+
return structuredClone(this.registry.listMail(filter));
|
|
11496
|
+
}
|
|
11497
|
+
|
|
11498
|
+
/**
|
|
11499
|
+
* Try to hand one held item to its recipient. True when the message is in
|
|
11500
|
+
* (or queued for) the recipient's context; false when it stays held
|
|
11501
|
+
* because nobody can receive it. The held copy is released by
|
|
11502
|
+
* `addMessage` at the moment the message enters the store.
|
|
11503
|
+
*/
|
|
11504
|
+
private attemptMailDelivery(mail: HeldMail, wake = true): boolean {
|
|
11505
|
+
const recipient = this.registry.has(mail.to) ? this.agents.get(mail.to) : undefined;
|
|
11506
|
+
if (!recipient) {
|
|
11507
|
+
const newlyHeld = mail.heldBecause !== 'recipient-gone';
|
|
11508
|
+
this.registry.markMailHeld(mail.id, 'recipient-gone');
|
|
11509
|
+
// A result its sender delivered after it had ended (one delivered while
|
|
11510
|
+
// it ran is told of when it ends, settleEndedAgent).
|
|
11511
|
+
const sender = this.registry.inspect(mail.from.agent);
|
|
11512
|
+
if (newlyHeld && mail.kind === 'result' && sender?.ended) this.tellResultHeld(sender, mail.id);
|
|
11513
|
+
this.emitTrace({
|
|
11514
|
+
type: 'dendrite:mail-held',
|
|
11515
|
+
mailId: mail.id,
|
|
11516
|
+
kind: mail.kind,
|
|
11517
|
+
from: mail.from.agent,
|
|
11518
|
+
to: mail.to,
|
|
11519
|
+
reason: 'recipient-gone',
|
|
11520
|
+
});
|
|
11521
|
+
return false;
|
|
11522
|
+
}
|
|
11523
|
+
const placement: { agent?: string; messageId?: MessageId; deferredId?: string } = {};
|
|
11524
|
+
this.addMessage(
|
|
11525
|
+
// The sender's own name: its words stay its own.
|
|
11526
|
+
recipient.isOwnParticipant(mail.from.agent) && mail.from.agent !== recipient.name
|
|
11527
|
+
? `${mail.from.agent} (mail)`
|
|
11528
|
+
: mail.from.agent,
|
|
11529
|
+
mail.content as ContentBlock[],
|
|
11530
|
+
{
|
|
11531
|
+
kind: `agent-${mail.kind}`,
|
|
11532
|
+
dendrite: {
|
|
11533
|
+
mailId: mail.id,
|
|
11534
|
+
kind: mail.kind,
|
|
11535
|
+
from: mail.from,
|
|
11536
|
+
// When the sender produced it — distinct from when it is read. A
|
|
11537
|
+
// result held across a restart arrives later than it was made,
|
|
11538
|
+
// and the reader should be able to tell.
|
|
11539
|
+
producedAt: mail.createdAt,
|
|
11540
|
+
...(mail.causedBy?.length ? { causedBy: mail.causedBy } : {}),
|
|
11541
|
+
},
|
|
11542
|
+
} as MessageMetadata,
|
|
11543
|
+
{ forAgent: mail.to, placement },
|
|
11544
|
+
);
|
|
11545
|
+
if (!placement.messageId) {
|
|
11546
|
+
// Queued for the recipient's next boundary. `mail-delivered` fires
|
|
11547
|
+
// when the message actually enters its store (releaseLandedMail):
|
|
11548
|
+
// published is not delivered.
|
|
11549
|
+
this.emitTrace({
|
|
11550
|
+
type: 'dendrite:mail-deferred',
|
|
11551
|
+
mailId: mail.id,
|
|
11552
|
+
kind: mail.kind,
|
|
11553
|
+
from: mail.from.agent,
|
|
11554
|
+
to: mail.to,
|
|
11555
|
+
});
|
|
11556
|
+
}
|
|
11557
|
+
if (wake) {
|
|
11558
|
+
this.pendingRequests.push({
|
|
11559
|
+
agentName: mail.to,
|
|
11560
|
+
reason: `agent-${mail.kind}:${mail.from.agent}`,
|
|
11561
|
+
source: 'dendrite',
|
|
11562
|
+
timestamp: Date.now(),
|
|
11563
|
+
});
|
|
11564
|
+
}
|
|
11565
|
+
return true;
|
|
11566
|
+
}
|
|
11567
|
+
|
|
11568
|
+
/**
|
|
11569
|
+
* An ended orphan's result held for a recipient that is gone: its first
|
|
11570
|
+
* adoption candidate is told it exists and how it arrives, since nothing
|
|
11571
|
+
* else would surface it (reparentAgent delivers it).
|
|
11572
|
+
*/
|
|
11573
|
+
private tellResultHeld(sender: AgentRecord, mailId: string): void {
|
|
11574
|
+
const candidate = sender.orphaned?.candidates[0];
|
|
11575
|
+
if (!candidate || !this.announceOrphans) return;
|
|
11576
|
+
this.lifecycleNotice(
|
|
11577
|
+
candidate,
|
|
11578
|
+
`[${NOTICE_TAG.agentLifecycle}: "${sender.name}" has ended after ${sender.orphaned!.formerParent.agent} did, ` +
|
|
11579
|
+
`so its result has nowhere to go. The result is held; reparenting "${sender.name}" to you delivers it.]`,
|
|
11580
|
+
// `heldMailId`, not `mailId`: a row carrying dendrite.mailId is read
|
|
11581
|
+
// as that mail having landed, which would release it.
|
|
11582
|
+
{ event: 'result-held', agent: sender.name, heldMailId: mailId, formerParent: sender.orphaned!.formerParent },
|
|
11583
|
+
);
|
|
11584
|
+
}
|
|
11585
|
+
|
|
11586
|
+
/** A host-framed lifecycle fact in an agent's context. Never voiced as anyone. */
|
|
11587
|
+
private lifecycleNotice(agentName: string, text: string, detail: Record<string, unknown>): void {
|
|
11588
|
+
if (!this.agents.has(agentName)) return;
|
|
11589
|
+
this.addMessage(
|
|
11590
|
+
'user',
|
|
11591
|
+
[{ type: 'text', text }],
|
|
11592
|
+
{ system: true, kind: 'agent-lifecycle', dendrite: detail } as MessageMetadata,
|
|
11593
|
+
{ forAgent: agentName },
|
|
11594
|
+
);
|
|
11595
|
+
}
|
|
11596
|
+
|
|
11597
|
+
/**
|
|
11598
|
+
* The creation ("consent") event: names what kind of agent this is, what
|
|
11599
|
+
* ends it and what happens to it when its spawner ends. A derived agent's
|
|
11600
|
+
* creation is also told to the agent it was derived from (#270, Linn:
|
|
11601
|
+
* "told when the body changes" as a property, not a courtesy): one
|
|
11602
|
+
* host-framed line in the source's context, built from the same facts as
|
|
11603
|
+
* the trace, with no wake; a host's derivationNotice can replace the line
|
|
11604
|
+
* or send none. Both derivation paths announce after the
|
|
11605
|
+
* fork's branch or copy exists, so a fork never inherits the notice about
|
|
11606
|
+
* itself; a source that has ended is told nothing (lifecycleNotice).
|
|
11607
|
+
*/
|
|
11608
|
+
private announceAgentCreated(record: AgentRecord): void {
|
|
11609
|
+
this.emitTrace({
|
|
11610
|
+
type: 'dendrite:agent-created',
|
|
11611
|
+
agentName: record.name,
|
|
11612
|
+
kind: record.kind,
|
|
11613
|
+
...(record.model ? { model: record.model } : {}),
|
|
11614
|
+
incarnation: record.incarnation,
|
|
11615
|
+
lifetime: record.lifetime,
|
|
11616
|
+
...(record.relationships.spawnedBy
|
|
11617
|
+
? { spawnedBy: record.relationships.spawnedBy, onParentEnd: record.onParentEnd }
|
|
11618
|
+
: {}),
|
|
11619
|
+
...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}),
|
|
11620
|
+
...(record.inherit ? { inherit: record.inherit } : {}),
|
|
11621
|
+
});
|
|
11622
|
+
const from = record.inherit?.from;
|
|
11623
|
+
if (!from || from === record.name) return;
|
|
11624
|
+
const detail = {
|
|
11625
|
+
event: 'derived',
|
|
11626
|
+
agent: record.name,
|
|
11627
|
+
kind: record.kind,
|
|
11628
|
+
incarnation: record.incarnation,
|
|
11629
|
+
...(record.model ? { model: record.model } : {}),
|
|
11630
|
+
inherit: record.inherit,
|
|
11631
|
+
...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}),
|
|
11632
|
+
lifetime: record.lifetime,
|
|
11633
|
+
};
|
|
11634
|
+
// A resumption is rare and always said. A first incarnation is tallied:
|
|
11635
|
+
// past the threshold, a reader loop that forks a hundred times a session
|
|
11636
|
+
// would fill its source's context with stamps (Linn, 2026-10-15), so
|
|
11637
|
+
// the source hears a count line every `threshold` forks instead, with
|
|
11638
|
+
// the latest described; every derivation still emits its trace.
|
|
11639
|
+
const threshold = this.collapseDerivationNoticesAfter;
|
|
11640
|
+
const tell = (defaultText: string, extra: Record<string, unknown> = {}) => {
|
|
11641
|
+
try {
|
|
11642
|
+
const text = this.derivationNoticeHook ? this.derivationNoticeHook(record, from, defaultText) : defaultText;
|
|
11643
|
+
if (text !== null) this.lifecycleNotice(from, text, { ...detail, ...extra });
|
|
11644
|
+
} catch (error) {
|
|
11645
|
+
console.error(`[dendrite] ${record.name} was created, but ${from} could not be told:`, error);
|
|
11646
|
+
}
|
|
11647
|
+
};
|
|
11648
|
+
if (record.incarnation > 1 || threshold <= 0) {
|
|
11649
|
+
tell(this.derivationNotice(record, from));
|
|
11650
|
+
return;
|
|
11651
|
+
}
|
|
11652
|
+
let byKind = this.derivationTallies.get(from);
|
|
11653
|
+
if (!byKind) this.derivationTallies.set(from, (byKind = new Map()));
|
|
11654
|
+
let tally = byKind.get(record.kind);
|
|
11655
|
+
if (!tally) byKind.set(record.kind, (tally = { count: 0, since: Date.now() }));
|
|
11656
|
+
tally.count++;
|
|
11657
|
+
if (tally.count <= threshold) {
|
|
11658
|
+
tell(this.derivationNotice(record, from));
|
|
11659
|
+
} else if ((tally.count - threshold) % threshold === 0) {
|
|
11660
|
+
const since = new Date(tally.since).toISOString().slice(11, 16) + ' UTC';
|
|
11661
|
+
tell(
|
|
11662
|
+
`[${NOTICE_TAG.agentLifecycle}: ${tally.count} ${record.kind}s have been derived from you since ${since} ` +
|
|
11663
|
+
`(${threshold} since the last note); the latest, ${record.name}, is ${this.derivationDescription(record, from)}.]`,
|
|
11664
|
+
{ collapsed: { count: tally.count, sinceLastNote: threshold } },
|
|
11665
|
+
);
|
|
11666
|
+
}
|
|
11667
|
+
}
|
|
11668
|
+
|
|
11669
|
+
/** The derivation notice's text, addressed to the agent `from` the record was derived. */
|
|
11670
|
+
private derivationNotice(record: AgentRecord, from: string): string {
|
|
11671
|
+
const made = record.incarnation > 1
|
|
11672
|
+
? `was resumed (incarnation ${record.incarnation}) as`
|
|
11673
|
+
: 'was derived from you as';
|
|
11674
|
+
return `[${NOTICE_TAG.agentLifecycle}: ${record.name} ${made} ${this.derivationDescription(record, from)}.]`;
|
|
11675
|
+
}
|
|
11676
|
+
|
|
11677
|
+
/** What a derived agent is, to the agent it was derived from: "a task-fork on your weights, …; …; …". */
|
|
11678
|
+
private derivationDescription(record: AgentRecord, from: string): string {
|
|
11679
|
+
const sourceModel = this.agentConfigs.get(from)?.model ?? this.registry.get(from)?.model;
|
|
11680
|
+
const weights = !record.model
|
|
11681
|
+
? ''
|
|
11682
|
+
: record.model === sourceModel
|
|
11683
|
+
? ` on your weights (${record.model})`
|
|
11684
|
+
: ` on ${record.model}`;
|
|
11685
|
+
const inherited = record.inherit?.mode === 'shared'
|
|
11686
|
+
? 'reading your context up to this point'
|
|
11687
|
+
: 'with a copy of your context';
|
|
11688
|
+
const route = record.relationships.resultTo;
|
|
11689
|
+
const home = this.channelOfHome(record.homeChannel);
|
|
11690
|
+
const result = !route
|
|
11691
|
+
? home
|
|
11692
|
+
? `it speaks for itself in ${this.channelShown(home)}`
|
|
11693
|
+
: 'it returns nothing to you'
|
|
11694
|
+
: route.to === from
|
|
11695
|
+
? `its result returns to you as ${route.as === 'message' ? 'a message' : 'a tool result'}`
|
|
11696
|
+
: `its result goes to ${route.to}`;
|
|
11697
|
+
const lifetime = record.lifetime;
|
|
11698
|
+
const minutes = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1_000)} s` : `${Math.round(ms / 60_000)} min`);
|
|
11699
|
+
const ends = lifetime.kind === 'task'
|
|
11700
|
+
? 'it ends when its task does' +
|
|
11701
|
+
(lifetime.deadlineMs !== undefined || lifetime.idleTimeoutMs !== undefined
|
|
11702
|
+
? ` (${[
|
|
11703
|
+
lifetime.deadlineMs !== undefined ? `deadline ${minutes(lifetime.deadlineMs)}` : '',
|
|
11704
|
+
lifetime.idleTimeoutMs !== undefined ? `idle ${minutes(lifetime.idleTimeoutMs)}` : '',
|
|
11705
|
+
].filter(Boolean).join(', ')})`
|
|
11706
|
+
: '')
|
|
11707
|
+
: lifetime.kind === 'idle'
|
|
11708
|
+
? `it ends after ${minutes(lifetime.idleTtlMs)} idle`
|
|
11709
|
+
: 'it persists';
|
|
11710
|
+
const parentEnd = record.relationships.spawnedBy === from
|
|
11711
|
+
? record.onParentEnd === 'end' ? ', and ends with you' : ', and is orphaned if you end'
|
|
11712
|
+
: '';
|
|
11713
|
+
return `a ${record.kind}${weights}, ${inherited}; ${result}; ${ends}${parentEnd}`;
|
|
11714
|
+
}
|
|
11715
|
+
|
|
11716
|
+
/**
|
|
11717
|
+
* Remove an ended agent's live object from the framework. A bounded run
|
|
11718
|
+
* is ended through its awaiting caller (whose teardown owns the
|
|
11719
|
+
* name-keyed state); any other agent is torn down here.
|
|
11720
|
+
*/
|
|
11721
|
+
private teardownEndedAgent(record: AgentRecord, detail?: string): void {
|
|
11722
|
+
const name = record.name;
|
|
11723
|
+
const reason = record.ended?.reason ?? 'stopped';
|
|
11724
|
+
const run = this.ephemeralRuns.get(name);
|
|
11725
|
+
if (run) {
|
|
11726
|
+
run.endReason = reason;
|
|
11727
|
+
run.endedBy = record.ended?.by;
|
|
11728
|
+
run.settle.reject(new AgentStoppedError(name, reason, record.ended?.by, detail));
|
|
11729
|
+
return;
|
|
11730
|
+
}
|
|
11731
|
+
const agent = this.agents.get(name);
|
|
11732
|
+
if (!agent) return;
|
|
11733
|
+
this.releaseAgentRuntime(agent, this.channelOfHome(record.homeChannel), undefined, record.lifetime.kind === 'persistent');
|
|
11734
|
+
}
|
|
11735
|
+
|
|
11736
|
+
/**
|
|
11737
|
+
* Everything the process holds for a live, non-ephemeral agent that has
|
|
11738
|
+
* ended, released once, on every path an agent ends by — stopAgent, the
|
|
11739
|
+
* idle reap of a conversation fork, an end that cascades from a parent's
|
|
11740
|
+
* (nissa's #546 finding 5: a stop that skipped the window release dropped a
|
|
11741
|
+
* write queued for the agent at the next flush, and a failure notice it
|
|
11742
|
+
* never read reached no one). The window release delivers what the agent
|
|
11743
|
+
* never read to the primary, now or after a resume, as the idle reap did.
|
|
11744
|
+
*/
|
|
11745
|
+
private releaseAgentRuntime(agent: Agent, home?: ChannelRef, why = 'its agent ended', declared = false): void {
|
|
11746
|
+
const name = agent.name;
|
|
11747
|
+
if (home && this.conversationRouter?.getBinding(home)?.agentName === name) {
|
|
11748
|
+
this.conversationRouter.unbind(home);
|
|
11749
|
+
}
|
|
11750
|
+
this.closingConversationAgents.delete(name);
|
|
11751
|
+
// A continuation of its turn still on its way goes with it.
|
|
11752
|
+
this.dropContinuations(name, { kind: 'dropped', why });
|
|
11753
|
+
// A batch its guard still holds is settled as `aborted`: the stream's
|
|
11754
|
+
// own finally does neither once the agent has gone.
|
|
11755
|
+
if (agent.toolResultGuard.hasPending) {
|
|
11756
|
+
try {
|
|
11757
|
+
agent.toolResultGuard.abandon('aborted');
|
|
11758
|
+
} catch (error) {
|
|
11759
|
+
console.error(`[tool-result-guard] agent=${name} failed to record a batch the fork left:`, error);
|
|
11760
|
+
}
|
|
11761
|
+
}
|
|
11762
|
+
// Cancel before deregistration: a late stream event is discarded by
|
|
11763
|
+
// driveStream's Agent-identity check once the name no longer maps to it.
|
|
11764
|
+
if (this.activeStreams.has(name)) {
|
|
11765
|
+
try {
|
|
11766
|
+
agent.cancelStream();
|
|
11767
|
+
} catch (error) {
|
|
11768
|
+
console.error(`[dendrite] agent=${name} its stream's cancel failed as it ended; it is released all the same:`, error);
|
|
11769
|
+
}
|
|
11770
|
+
}
|
|
11771
|
+
this.activeStreams.delete(name);
|
|
11772
|
+
this.pendingAssistantBlocks.delete(name);
|
|
11773
|
+
this.eventGate?.onInferenceEnded(name);
|
|
11774
|
+
// Its sleep and self-wake end with it (the gate's sleep holds back every
|
|
11775
|
+
// agent's wakes), and neither wake goes to a name that's gone.
|
|
11776
|
+
this.eventGate?.agentGone(name);
|
|
11777
|
+
this.agents.delete(name);
|
|
11778
|
+
this.agentConfigs.delete(name);
|
|
11779
|
+
this.thinkingLossIncidents.forget(name);
|
|
11780
|
+
this.reasoningLoss.forget(name);
|
|
11781
|
+
// main's manager release: the context manager an ephemeral run or a fork
|
|
11782
|
+
// opened is released with the agent (releaseManager).
|
|
11783
|
+
this.releaseManager(agent);
|
|
11784
|
+
this.toolImageLedgers.delete(name);
|
|
11785
|
+
this.evictTurnCheckpoints(name);
|
|
11786
|
+
this.activeTurnTokens.delete(name);
|
|
11787
|
+
this.activeTurnTriggers.delete(name);
|
|
11788
|
+
this.logicalTurnToolCalls.delete(agent);
|
|
11789
|
+
this.pendingRequests = this.pendingRequests.filter((request) => request.agentName !== name);
|
|
11790
|
+
this.releaseWindowNowOrAfterResume(agent);
|
|
11791
|
+
this.turnRouting.delete(name);
|
|
11792
|
+
this.proseSpeechChains.delete(name);
|
|
11793
|
+
this.openLogical.delete(name);
|
|
11794
|
+
// What it was last told of where its speech goes goes with it, kept
|
|
11795
|
+
// only for an agent declared by configuration, which comes back under
|
|
11796
|
+
// its name at the next boot: so the stored baselines are those of
|
|
11797
|
+
// agents that are live or come back.
|
|
11798
|
+
if (!declared) this.forgetAnnouncedLocus(name);
|
|
11799
|
+
// Its scripts go with it, and leave the map once they've ended: no later
|
|
11800
|
+
// run of the name trims them.
|
|
11801
|
+
this.codeExecution?.scripts.agentGone(name);
|
|
11802
|
+
// A provider-wait hold (nonOwningProviderWait) goes with the agent: left,
|
|
11803
|
+
// its timer would requeue held wakes for an agent that is gone, and a wait
|
|
11804
|
+
// held until released would re-arm for the name indefinitely. The recorded
|
|
11805
|
+
// wait stays, and binds the name if its channel binds again.
|
|
11806
|
+
const cooldown = this.providerAccelerationCooldowns.get(name);
|
|
11807
|
+
if (cooldown) {
|
|
11808
|
+
clearTimeout(cooldown.timer);
|
|
11809
|
+
this.providerAccelerationCooldowns.delete(name);
|
|
11810
|
+
console.error(`[provider-wait] agent=${name} ended while held` +
|
|
11811
|
+
`${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` +
|
|
11812
|
+
`${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`);
|
|
11813
|
+
}
|
|
11814
|
+
// main's per-name turn state goes too (#846's re-arm count and run of
|
|
11815
|
+
// stated waits, #840's exhausted turn), so a name bound again (a
|
|
11816
|
+
// channel's next conversation, a resume) starts afresh; a run of stated
|
|
11817
|
+
// waits ends here as at a turn's end, alerted if it ran long
|
|
11818
|
+
// (endStatedWaitRun).
|
|
11819
|
+
this.turnRearms.delete(name);
|
|
11820
|
+
this.exhaustingRearms.delete(name);
|
|
11821
|
+
this.exhaustedTurns.delete(name);
|
|
11822
|
+
this.endStatedWaitRun(name);
|
|
11823
|
+
if (name === this.subconsciousAgentName) {
|
|
11824
|
+
this.tuneOutCoordinator?.stopReader();
|
|
11825
|
+
this.subconsciousAgentName = null;
|
|
11826
|
+
}
|
|
11827
|
+
}
|
|
11828
|
+
|
|
11829
|
+
/**
|
|
11830
|
+
* Carry out what the registry decided when an agent ended: tear down the
|
|
11831
|
+
* attention tenants that end with it, and tell each orphan — and the
|
|
11832
|
+
* first agent that could adopt it — what happened.
|
|
11833
|
+
*/
|
|
11834
|
+
private settleEndedAgent(outcome: EndOutcome | null): void {
|
|
11835
|
+
if (!outcome) return;
|
|
11836
|
+
for (const record of [...outcome.cascaded, outcome.ended]) {
|
|
11837
|
+
this.emitTrace({
|
|
11838
|
+
type: 'dendrite:agent-ended',
|
|
11839
|
+
agentName: record.name,
|
|
11840
|
+
kind: record.kind,
|
|
11841
|
+
incarnation: record.incarnation,
|
|
11842
|
+
reason: record.ended?.reason ?? 'stopped',
|
|
11843
|
+
...(record.ended?.by ? { by: record.ended.by } : {}),
|
|
11844
|
+
...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}),
|
|
11845
|
+
});
|
|
11846
|
+
}
|
|
11847
|
+
for (const record of outcome.cascaded) this.teardownEndedAgent(record);
|
|
11848
|
+
// An orphan that ends with a result it delivered while it ran still held
|
|
11849
|
+
// for a recipient that is gone: its first candidate hears of it now.
|
|
11850
|
+
for (const record of [...outcome.cascaded, outcome.ended]) {
|
|
11851
|
+
if (!record.orphaned) continue;
|
|
11852
|
+
const held = this.registry.listMail({ from: record.name })
|
|
11853
|
+
.find((mail) => mail.kind === 'result' && mail.heldBecause === 'recipient-gone');
|
|
11854
|
+
if (held) this.tellResultHeld(record, held.id);
|
|
11855
|
+
}
|
|
11856
|
+
|
|
11857
|
+
const parent = outcome.ended;
|
|
11858
|
+
for (const { record, candidates } of outcome.orphaned) {
|
|
11859
|
+
this.announceOrphaned(
|
|
11860
|
+
record,
|
|
11861
|
+
candidates,
|
|
11862
|
+
record.orphaned?.formerParent.agent ?? parent.name,
|
|
11863
|
+
record.orphaned?.reason ?? parent.ended?.reason ?? 'stopped',
|
|
11864
|
+
);
|
|
11865
|
+
}
|
|
11866
|
+
}
|
|
11867
|
+
|
|
11868
|
+
/** An agent whose spawner has ended, and its first adoption candidate, are told; the trace says so. */
|
|
11869
|
+
private announceOrphaned(record: AgentRecord, candidates: string[], former: string, reason: string): void {
|
|
11870
|
+
this.emitTrace({
|
|
11871
|
+
type: 'dendrite:agent-orphaned',
|
|
11872
|
+
agentName: record.name,
|
|
11873
|
+
formerParent: former,
|
|
11874
|
+
reason,
|
|
11875
|
+
candidates,
|
|
11876
|
+
});
|
|
11877
|
+
const route = record.relationships.resultTo;
|
|
11878
|
+
const resultHeld = route?.as === 'message' && !this.registry.has(route.to);
|
|
11879
|
+
this.lifecycleNotice(
|
|
11880
|
+
record.name,
|
|
11881
|
+
`[${NOTICE_TAG.agentLifecycle}: ${former}, the agent that started you, has ended (${reason}). ` +
|
|
11882
|
+
'Your own work is not cancelled.' +
|
|
11883
|
+
(resultHeld ? ' Your result will be held until another agent becomes its recipient.' : '') +
|
|
11884
|
+
']',
|
|
11885
|
+
{ event: 'orphaned', formerParent: record.orphaned?.formerParent, reason, candidates },
|
|
11886
|
+
);
|
|
11887
|
+
const candidate = candidates[0];
|
|
11888
|
+
if (candidate && this.announceOrphans) {
|
|
11889
|
+
this.lifecycleNotice(
|
|
11890
|
+
candidate,
|
|
11891
|
+
`[${NOTICE_TAG.agentLifecycle}: ${former} has ended (${reason}) while its ${record.kind} agent ` +
|
|
11892
|
+
`"${record.name}" was still running. "${record.name}" now has no parent` +
|
|
11893
|
+
(resultHeld ? ' and nowhere to return its result' : '') +
|
|
11894
|
+
'; it can be reparented to you.]',
|
|
11895
|
+
{ event: 'adoption-offered', agent: record.name, formerParent: record.orphaned?.formerParent, reason },
|
|
11896
|
+
);
|
|
11897
|
+
}
|
|
11898
|
+
}
|
|
11899
|
+
|
|
11900
|
+
/** The registry's snapshot: the recovery file first (atomic, fsynced; it
|
|
11901
|
+
* wins at the next boot), then the branch-local state, kept for inspection
|
|
11902
|
+
* and for stores read by older code. Either write failing fails the call
|
|
11903
|
+
* that changed the registry (nissa's #546 finding 2): a change that isn't
|
|
11904
|
+
* durable isn't a success. */
|
|
11905
|
+
private persistAgentRegistry(snapshot: RegistrySnapshot): void {
|
|
11906
|
+
if (this.agentRegistryPath) this.writeRecoveryFile(this.agentRegistryPath, snapshot);
|
|
11907
|
+
this.store.setStateJson(AGENT_REGISTRY_STATE_ID, snapshot);
|
|
11908
|
+
}
|
|
11909
|
+
|
|
11910
|
+
/** An ended record: appended to the branch-local log, and the recovery
|
|
11911
|
+
* file rewritten with the newest records (as many as a restart reads). */
|
|
11912
|
+
private appendEndedAgentRecord(record: AgentRecord): void {
|
|
11913
|
+
this.rewriteEndedRecovery([...this.endedRecovery, record]);
|
|
11914
|
+
this.store.appendToStateJson(AGENT_REGISTRY_ENDED_ID, record);
|
|
11915
|
+
}
|
|
11916
|
+
|
|
11917
|
+
/**
|
|
11918
|
+
* A requested change was put back in the registry's memory: the recovery
|
|
11919
|
+
* files, each written before the branch-local store, are rewritten from it
|
|
11920
|
+
* (the snapshot's and the ended log's), so neither keeps the change the
|
|
11921
|
+
* caller was told failed. The branch-local snapshot needs no rewrite: it is
|
|
11922
|
+
* the last write a change makes, so a change that failed never reached it.
|
|
11923
|
+
* The branch-local ended log is append-only, so an end appended before the
|
|
11924
|
+
* failure stays there. It is read at boot only where no ended recovery
|
|
11925
|
+
* file exists (or the rewrite of that file failed too); the restore then
|
|
11926
|
+
* drops a bounded agent it lists as ended for the reason recorded, with no
|
|
11927
|
+
* restart notice to its recipient, while a persistent agent waits to be
|
|
11928
|
+
* re-declared as it would anyway. A rewrite that fails is reported, the
|
|
11929
|
+
* next is still made, and the caller's own error goes on.
|
|
11930
|
+
*/
|
|
11931
|
+
private rewriteAgentRegistryAfterRollback(): void {
|
|
11932
|
+
const rewrites: Array<[string, string, () => void]> = [
|
|
11933
|
+
['the ended log recovery file', 'the next end written', () => this.rewriteEndedRecovery(this.registry.endedHistory())],
|
|
11934
|
+
...(this.agentRegistryPath
|
|
11935
|
+
? [['the registry recovery file', 'the next registry write', () => this.writeRecoveryFile(this.agentRegistryPath!, this.registry.snapshot())] as [string, string, () => void]]
|
|
11936
|
+
: []),
|
|
11937
|
+
];
|
|
11938
|
+
for (const [what, next, rewrite] of rewrites) {
|
|
11939
|
+
try {
|
|
11940
|
+
rewrite();
|
|
11941
|
+
} catch (error) {
|
|
11942
|
+
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);
|
|
11943
|
+
}
|
|
11944
|
+
}
|
|
11945
|
+
}
|
|
11946
|
+
|
|
11947
|
+
/**
|
|
11948
|
+
* An ended record past the registry's memory, from the branch-local ended
|
|
11949
|
+
* log. A scan of the whole log takes tens of milliseconds at a few thousand
|
|
11950
|
+
* ends, and a name nobody used (a tool call's typo) is a miss, so the log is
|
|
11951
|
+
* indexed by name once, at the first lookup, and each later lookup indexes
|
|
11952
|
+
* only what was appended since. A log that changed under the index (a
|
|
11953
|
+
* branch switch) is indexed again: one that shrank, one whose last indexed
|
|
11954
|
+
* entry is no longer the one indexed, or one whose entry no longer carries
|
|
11955
|
+
* the name indexed for it.
|
|
11956
|
+
*/
|
|
11957
|
+
private findEndedAgentRecord(name: string): AgentRecord | undefined {
|
|
11958
|
+
return this.findInEndedLog('name', name);
|
|
11959
|
+
}
|
|
11960
|
+
|
|
11961
|
+
/**
|
|
11962
|
+
* The ended log's latest record whose own context a namespace holds. A
|
|
11963
|
+
* shared fork's record names its parent's namespace, where it owns only a
|
|
11964
|
+
* branch, so it is indexed by name alone.
|
|
11965
|
+
*/
|
|
11966
|
+
private findInEndedLog(by: 'name' | 'namespace', value: string): AgentRecord | undefined {
|
|
11967
|
+
const byKey = by === 'name' ? this.endedLogIndex : this.endedLogNamespaceIndex;
|
|
11968
|
+
const reset = () => {
|
|
11969
|
+
this.endedLogIndex.clear();
|
|
11970
|
+
this.endedLogNamespaceIndex.clear();
|
|
11971
|
+
this.endedLogIndexed = 0;
|
|
11972
|
+
};
|
|
11973
|
+
const read = (index: number) => this.store.getStateItemJson(AGENT_REGISTRY_ENDED_ID, index) as AgentRecord | null;
|
|
11974
|
+
const mark = (record: AgentRecord | null) => (record ? `${record.name}#${record.incarnation}@${record.ended?.at}` : '');
|
|
11975
|
+
// A store that can't be read fails the lookup rather than answering
|
|
11976
|
+
// "never ended", which would let a namespace check pass.
|
|
11977
|
+
const length = this.store.getStateLen(AGENT_REGISTRY_ENDED_ID) ?? 0;
|
|
11978
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
11979
|
+
if (length < this.endedLogIndexed
|
|
11980
|
+
|| (this.endedLogIndexed > 0 && mark(read(this.endedLogIndexed - 1)) !== this.endedLogTail)) {
|
|
11981
|
+
reset();
|
|
11982
|
+
}
|
|
11983
|
+
for (let index = this.endedLogIndexed; index < length; index++) {
|
|
11984
|
+
const record = read(index);
|
|
11985
|
+
if (!record?.ended || typeof record.name !== 'string') continue;
|
|
11986
|
+
this.endedLogIndex.set(record.name, index);
|
|
11987
|
+
if (typeof record.namespace === 'string' && record.inherit?.mode !== 'shared') {
|
|
11988
|
+
this.endedLogNamespaceIndex.set(record.namespace, index);
|
|
11989
|
+
}
|
|
11990
|
+
}
|
|
11991
|
+
this.endedLogIndexed = length;
|
|
11992
|
+
this.endedLogTail = length > 0 ? mark(read(length - 1)) : '';
|
|
11993
|
+
const at = byKey.get(value);
|
|
11994
|
+
if (at === undefined) return undefined;
|
|
11995
|
+
const record = read(at);
|
|
11996
|
+
if (record?.ended && record[by] === value) return record;
|
|
11997
|
+
reset();
|
|
11998
|
+
}
|
|
11999
|
+
return undefined;
|
|
12000
|
+
}
|
|
12001
|
+
|
|
12002
|
+
/** Another agent whose context a namespace holds: opened in this process, in the registry, or in the ended log. */
|
|
12003
|
+
private namespaceHolder(namespace: string, except: string): string | undefined {
|
|
12004
|
+
const opened = this.namespaceOwners.get(namespace);
|
|
12005
|
+
if (opened !== undefined && opened !== except) return opened;
|
|
12006
|
+
const held = this.registry.namespaceHolder(namespace, except);
|
|
12007
|
+
if (held) return held;
|
|
12008
|
+
const ended = this.findInEndedLog('namespace', namespace);
|
|
12009
|
+
return ended && ended.name !== except ? ended.name : undefined;
|
|
12010
|
+
}
|
|
12011
|
+
|
|
12012
|
+
/** The ended log's recovery file, rewritten as the newest records (as many as a restart reads). */
|
|
12013
|
+
private rewriteEndedRecovery(ended: AgentRecord[]): void {
|
|
12014
|
+
this.endedRecovery = ended.slice(-AGENT_REGISTRY_ENDED_RELOAD);
|
|
12015
|
+
if (this.agentRegistryEndedPath) this.writeRecoveryFile(this.agentRegistryEndedPath, { version: 1, ended: this.endedRecovery });
|
|
12016
|
+
}
|
|
12017
|
+
|
|
12018
|
+
/**
|
|
12019
|
+
* Load the previous process's registry. Returns the bounded work it
|
|
12020
|
+
* interrupted. A registry that can't be read, or that reads as anything
|
|
12021
|
+
* but a version-1 registry, refuses the start and names the state (nissa's
|
|
12022
|
+
* #546 finding 1, main's rule for the framework's own state): taken as
|
|
12023
|
+
* empty, the results it holds for delivery and the records of the work it
|
|
12024
|
+
* ran would be overwritten by this boot's first registration.
|
|
12025
|
+
*/
|
|
12026
|
+
private restoreAgentRegistry(): AgentRecord[] {
|
|
12027
|
+
const refuse = (what: string, problem: string, cause?: unknown): Error => new Error(
|
|
12028
|
+
`the agent registry (${what}) ${problem}. The framework doesn't start without it: taken as empty, the results ` +
|
|
12029
|
+
'it holds for delivery and the records of the work it ran would be overwritten at this boot. ' +
|
|
12030
|
+
(cause === undefined
|
|
12031
|
+
? "It's unchanged, and the framework starts once it holds a version-1 registry, such as " +
|
|
12032
|
+
'{"version":1,"records":[],"mail":[],"incarnations":{}} for an empty one.'
|
|
12033
|
+
: "It's unchanged, and the framework starts once it can be read."),
|
|
12034
|
+
cause === undefined ? undefined : { cause },
|
|
12035
|
+
);
|
|
12036
|
+
let snapshot: unknown;
|
|
12037
|
+
let snapshotFrom = this.agentRegistryPath ?? AGENT_REGISTRY_STATE_ID;
|
|
12038
|
+
// Only a registry that was never written is absent: a recovery file that
|
|
12039
|
+
// exists is the registry, whatever it holds, so one that reads as `null`
|
|
12040
|
+
// is refused like any other that isn't a version-1 registry.
|
|
12041
|
+
let absent = false;
|
|
12042
|
+
try {
|
|
12043
|
+
snapshot = this.agentRegistryPath ? this.readRecoveryFile(this.agentRegistryPath) : undefined;
|
|
12044
|
+
if (snapshot === undefined) {
|
|
12045
|
+
snapshotFrom = AGENT_REGISTRY_STATE_ID;
|
|
12046
|
+
snapshot = this.store.getStateJson(AGENT_REGISTRY_STATE_ID) ?? null;
|
|
12047
|
+
absent = snapshot === null;
|
|
12048
|
+
}
|
|
12049
|
+
} catch (error) {
|
|
12050
|
+
throw refuse(snapshotFrom, "can't be read", error);
|
|
12051
|
+
}
|
|
12052
|
+
if (!absent && (snapshot === null || typeof snapshot !== 'object' || (snapshot as { version?: unknown }).version !== 1)) {
|
|
12053
|
+
const read = snapshot === null ? 'null' : `version ${JSON.stringify((snapshot as { version?: unknown }).version)}`;
|
|
12054
|
+
throw refuse(snapshotFrom, `reads as something other than a version-1 registry (${read})`);
|
|
12055
|
+
}
|
|
12056
|
+
if (!absent) {
|
|
12057
|
+
const { records, mail, incarnations } = snapshot as { records?: unknown; mail?: unknown; incarnations?: unknown };
|
|
12058
|
+
if (!Array.isArray(records) || !Array.isArray(mail)) {
|
|
12059
|
+
throw refuse(snapshotFrom, 'reads as a version-1 registry whose records or mail are not lists');
|
|
12060
|
+
}
|
|
12061
|
+
if (incarnations === null || typeof incarnations !== 'object' || Array.isArray(incarnations)) {
|
|
12062
|
+
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');
|
|
12063
|
+
}
|
|
12064
|
+
}
|
|
12065
|
+
|
|
12066
|
+
let ended: unknown;
|
|
12067
|
+
let endedFrom = this.agentRegistryEndedPath ?? AGENT_REGISTRY_ENDED_ID;
|
|
12068
|
+
try {
|
|
12069
|
+
const file = this.agentRegistryEndedPath ? this.readRecoveryFile(this.agentRegistryEndedPath) : undefined;
|
|
12070
|
+
if (file !== undefined) {
|
|
12071
|
+
ended = (file as { ended?: unknown } | null)?.ended;
|
|
12072
|
+
} else {
|
|
12073
|
+
endedFrom = AGENT_REGISTRY_ENDED_ID;
|
|
12074
|
+
const tail = this.store.getStateTail(AGENT_REGISTRY_ENDED_ID, AGENT_REGISTRY_ENDED_RELOAD);
|
|
12075
|
+
ended = tail ? JSON.parse(tail.toString('utf8')) : [];
|
|
12076
|
+
}
|
|
12077
|
+
} catch (error) {
|
|
12078
|
+
throw refuse(endedFrom, "has an ended log that can't be read", error);
|
|
12079
|
+
}
|
|
12080
|
+
if (!Array.isArray(ended)) throw refuse(endedFrom, 'has an ended log that reads as something other than a list of records');
|
|
12081
|
+
this.endedRecovery = (ended as AgentRecord[]).slice(-AGENT_REGISTRY_ENDED_RELOAD);
|
|
12082
|
+
return this.registry.restore(snapshot as RegistrySnapshot | null, ended as AgentRecord[]).interrupted;
|
|
12083
|
+
}
|
|
12084
|
+
|
|
12085
|
+
/**
|
|
12086
|
+
* Boot reconciliation, once configuration has declared its agents.
|
|
12087
|
+
* Restart behaviour follows from lifetime: persistent agents were just
|
|
12088
|
+
* re-created; everything else that was live was interrupted, and the
|
|
12089
|
+
* recipient of an interrupted job's result is told so. Results held from
|
|
12090
|
+
* before the restart are delivered now, exactly once.
|
|
12091
|
+
*/
|
|
12092
|
+
private reconcileAgentRegistry(interrupted: AgentRecord[]): void {
|
|
12093
|
+
for (const outcome of this.registry.reconcile().unconfigured) this.settleEndedAgent(outcome);
|
|
12094
|
+
|
|
12095
|
+
const byRecipient = new Map<string, AgentRecord[]>();
|
|
12096
|
+
for (const record of interrupted) {
|
|
12097
|
+
this.emitTrace({
|
|
12098
|
+
type: 'dendrite:agent-ended',
|
|
12099
|
+
agentName: record.name,
|
|
12100
|
+
kind: record.kind,
|
|
12101
|
+
incarnation: record.incarnation,
|
|
12102
|
+
reason: 'host-restart',
|
|
12103
|
+
...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}),
|
|
12104
|
+
});
|
|
12105
|
+
const route = record.relationships.resultTo;
|
|
12106
|
+
if (route?.as !== 'message' || !this.agents.has(route.to)) continue;
|
|
12107
|
+
// A result it had already handed over needs no apology.
|
|
12108
|
+
if (this.registry.listMail({ from: record.name }).some((mail) => mail.kind === 'result')) continue;
|
|
12109
|
+
byRecipient.set(route.to, [...(byRecipient.get(route.to) ?? []), record]);
|
|
12110
|
+
}
|
|
12111
|
+
for (const [recipient, all] of byRecipient) {
|
|
12112
|
+
// The interrupted records stay in the snapshot until this notice is
|
|
12113
|
+
// durable and their ends are written (settleInterrupted below), so a
|
|
12114
|
+
// crash after it landed, or an end whose write failed, comes here
|
|
12115
|
+
// again: a record an earlier interrupted notice to this recipient names
|
|
12116
|
+
// (its key lists each name, incarnation and creation time, since a
|
|
12117
|
+
// single-use name that aged out of memory starts again at incarnation
|
|
12118
|
+
// 1), in the recipient's store or the restored deferred queue, has been
|
|
12119
|
+
// told, however many of that notice's records have ended since.
|
|
12120
|
+
const toldKeys = new Set<string>();
|
|
12121
|
+
const noteTold = (metadata: unknown) => {
|
|
12122
|
+
const key = (metadata as { dendrite?: { noticeKey?: unknown } } | undefined)?.dendrite?.noticeKey;
|
|
12123
|
+
if (typeof key === 'string') for (const one of key.split(',')) toldKeys.add(one);
|
|
12124
|
+
};
|
|
12125
|
+
for (const message of this.agents.get(recipient)!.getContextManager()
|
|
12126
|
+
.queryMessages({ metadata: { 'dendrite.event': 'interrupted' } }).messages) noteTold(message.metadata);
|
|
12127
|
+
for (const message of this.deferredMessages) noteTold(message.metadata);
|
|
12128
|
+
const keyOf = (record: AgentRecord) => `${record.name}#${record.incarnation}@${record.createdAt}`;
|
|
12129
|
+
const records = all.filter((record) => !toldKeys.has(keyOf(record)));
|
|
12130
|
+
if (records.length === 0) continue;
|
|
12131
|
+
const noticeKey = records.map(keyOf).sort().join(',');
|
|
12132
|
+
const names = records.map((record) => `"${record.name}"`).join(', ');
|
|
12133
|
+
this.lifecycleNotice(
|
|
12134
|
+
recipient,
|
|
12135
|
+
`[${NOTICE_TAG.agentLifecycle}: the host restarted while ${names} ${records.length === 1 ? 'was' : 'were'} ` +
|
|
12136
|
+
`still working for you. ${records.length === 1 ? 'It was' : 'They were'} ended without a result.]`,
|
|
12137
|
+
{ event: 'interrupted', agents: records.map((record) => record.name), reason: 'host-restart', noticeKey },
|
|
12138
|
+
);
|
|
12139
|
+
this.pendingRequests.push({
|
|
12140
|
+
agentName: recipient,
|
|
12141
|
+
reason: 'agent-interrupted',
|
|
12142
|
+
source: 'dendrite',
|
|
12143
|
+
timestamp: Date.now(),
|
|
12144
|
+
});
|
|
12145
|
+
}
|
|
12146
|
+
// The notices are durable once the store has synced (a quiesced host's
|
|
12147
|
+
// are in the deferred queue's recovery file already); only then are the
|
|
12148
|
+
// interrupted records' ends written and the snapshot let go of them.
|
|
12149
|
+
if (interrupted.length > 0) {
|
|
12150
|
+
try {
|
|
12151
|
+
this.store.sync();
|
|
12152
|
+
this.registry.settleInterrupted();
|
|
12153
|
+
} catch (error) {
|
|
12154
|
+
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);
|
|
12155
|
+
}
|
|
12156
|
+
}
|
|
12157
|
+
|
|
12158
|
+
for (const mail of this.registry.listMail()) this.deliverHeldMail(mail);
|
|
12159
|
+
}
|
|
12160
|
+
|
|
12161
|
+
/**
|
|
12162
|
+
* Deliver held mail that hasn't landed. Its copy may have reached the
|
|
12163
|
+
* recipient's store in the same sync that the release missed, and then it
|
|
12164
|
+
* is released instead; and a write the deferred queue still holds for it
|
|
12165
|
+
* lands at the next flush and releases it then (releaseLandedMail). Either
|
|
12166
|
+
* way a second delivery would put it in the window twice under one mail id.
|
|
12167
|
+
*/
|
|
12168
|
+
private deliverHeldMail(mail: HeldMail, wake = true): void {
|
|
12169
|
+
const recipient = this.agents.get(mail.to);
|
|
12170
|
+
if (recipient) {
|
|
12171
|
+
const already = recipient.getContextManager().queryMessages({ metadata: { 'dendrite.mailId': mail.id } });
|
|
12172
|
+
if (already.messages.length > 0) {
|
|
12173
|
+
this.registry.releaseMail(mail.id);
|
|
12174
|
+
return;
|
|
12175
|
+
}
|
|
12176
|
+
}
|
|
12177
|
+
const queued = this.deferredMessages.some(
|
|
12178
|
+
(m) => (m.metadata as { dendrite?: { mailId?: unknown } } | undefined)?.dendrite?.mailId === mail.id,
|
|
12179
|
+
);
|
|
12180
|
+
if (queued) return;
|
|
12181
|
+
this.attemptMailDelivery(mail, wake);
|
|
12182
|
+
}
|
|
12183
|
+
|
|
10303
12184
|
private createDeferred<T>(): Deferred<T> {
|
|
10304
12185
|
let resolve!: (value: T) => void;
|
|
10305
12186
|
let reject!: (reason?: unknown) => void;
|
|
@@ -10463,7 +12344,8 @@ export class AgentFramework {
|
|
|
10463
12344
|
// Only a fresh object returned by createEphemeralAgent may enter this path.
|
|
10464
12345
|
// Never overwrite a resident/conversation owner or a concurrent run: their
|
|
10465
12346
|
// cleanup is name-keyed and could cancel/deregister the legitimate owner.
|
|
10466
|
-
|
|
12347
|
+
const candidate = this.ephemeralCandidates.get(agent);
|
|
12348
|
+
if (!candidate || candidate.contextManager !== contextManager) {
|
|
10467
12349
|
throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
|
|
10468
12350
|
}
|
|
10469
12351
|
// A live surgery holds the whole store: an agent admitted now would
|
|
@@ -10478,11 +12360,27 @@ export class AgentFramework {
|
|
|
10478
12360
|
// Consume before any await/registration: the exact (Agent, ContextManager)
|
|
10479
12361
|
// generation is one-shot even if startup later fails.
|
|
10480
12362
|
this.ephemeralCandidates.delete(agent);
|
|
12363
|
+
if (candidate.resume && this.resumesPending.get(agent.name) === agent) this.resumesPending.delete(agent.name);
|
|
10481
12364
|
if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
|
|
10482
12365
|
throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
|
|
10483
12366
|
}
|
|
12367
|
+
// Admit it to the registry first: a spec the registry refuses (spawner
|
|
12368
|
+
// or result recipient gone since creation) must not leave a live agent.
|
|
12369
|
+
const registered = this.registry.register(candidate.spec, { resume: candidate.resume === true, after: candidate.after });
|
|
12370
|
+
this.announceAgentCreated(registered);
|
|
10484
12371
|
// Register temporarily so the event loop can drive it
|
|
10485
12372
|
this.agents.set(agent.name, agent);
|
|
12373
|
+
// Kept while the run is live so the agent can itself be derived from.
|
|
12374
|
+
this.agentConfigs.set(agent.name, candidate.config);
|
|
12375
|
+
// A spawner that ended after creating it: it registered as that
|
|
12376
|
+
// spawner's orphan, and is told so, as is its first candidate.
|
|
12377
|
+
if (registered.orphaned) {
|
|
12378
|
+
this.announceOrphaned(registered, registered.orphaned.candidates, registered.orphaned.formerParent.agent, registered.orphaned.reason);
|
|
12379
|
+
}
|
|
12380
|
+
// Mail held for this name (a resumed incarnation's, sent while it was
|
|
12381
|
+
// ended) reaches it now rather than at the next boot. Its run is
|
|
12382
|
+
// starting, so it wakes nothing.
|
|
12383
|
+
for (const mail of this.registry.listMail({ to: agent.name })) this.deliverHeldMail(mail, false);
|
|
10486
12384
|
const run: EphemeralRun = {
|
|
10487
12385
|
settle: this.createDeferred<AgentSettleResult>(),
|
|
10488
12386
|
inferenceStarted: false,
|
|
@@ -10491,15 +12389,39 @@ export class AgentFramework {
|
|
|
10491
12389
|
};
|
|
10492
12390
|
this.ephemeralRuns.set(agent.name, run);
|
|
10493
12391
|
|
|
12392
|
+
const lifetime = candidate.spec.lifetime;
|
|
10494
12393
|
const STARTUP_TIMEOUT_MS = watchdogs?.startupTimeoutMs ?? 30_000;
|
|
10495
12394
|
// After inference has started, give it 15 minutes of activity-bounded
|
|
10496
12395
|
// life. The stream driver refreshes the deadline as it makes progress;
|
|
10497
|
-
// sustained silence trips it.
|
|
10498
|
-
|
|
12396
|
+
// sustained silence trips it. An explicit watchdog wins; otherwise the
|
|
12397
|
+
// spec's task lifetime is the bound.
|
|
12398
|
+
const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs
|
|
12399
|
+
?? (lifetime.kind === 'task' ? lifetime.idleTimeoutMs : undefined)
|
|
12400
|
+
?? 15 * 60_000;
|
|
10499
12401
|
const IDLE_POLL_MS = watchdogs?.idlePollMs ?? 30_000;
|
|
10500
12402
|
|
|
10501
12403
|
let startupWatchdog: ReturnType<typeof setTimeout> | null = null;
|
|
10502
12404
|
let completionWatchdog: ReturnType<typeof setInterval> | null = null;
|
|
12405
|
+
let deadlineWatchdog: ReturnType<typeof setTimeout> | null = null;
|
|
12406
|
+
|
|
12407
|
+
// A task lifetime's wall-clock deadline: the identity must end, however
|
|
12408
|
+
// lively its stream is.
|
|
12409
|
+
// A timer holds at most MAX_TIMER_MS, so a longer deadline waits in
|
|
12410
|
+
// steps, each re-armed until the deadline is due.
|
|
12411
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
12412
|
+
if (lifetime.kind !== 'task' || lifetime.deadlineMs === undefined) return;
|
|
12413
|
+
const deadlineMs = lifetime.deadlineMs;
|
|
12414
|
+
const dueAt = Date.now() + deadlineMs;
|
|
12415
|
+
const arm = (): void => {
|
|
12416
|
+
deadlineWatchdog = setTimeout(() => {
|
|
12417
|
+
if (Date.now() < dueAt) return arm();
|
|
12418
|
+
run.endReason = 'deadline';
|
|
12419
|
+
reject(new AgentStoppedError(agent.name, 'deadline', undefined, `${deadlineMs}ms elapsed`));
|
|
12420
|
+
}, Math.max(0, Math.min(dueAt - Date.now(), MAX_TIMER_MS)));
|
|
12421
|
+
deadlineWatchdog.unref?.();
|
|
12422
|
+
};
|
|
12423
|
+
arm();
|
|
12424
|
+
});
|
|
10503
12425
|
// Whether the completion watchdog ended the run as stalled, for a
|
|
10504
12426
|
// tool-result batch the run leaves behind (see the finally).
|
|
10505
12427
|
let stalled = false;
|
|
@@ -10552,11 +12474,14 @@ export class AgentFramework {
|
|
|
10552
12474
|
run.settle.promise,
|
|
10553
12475
|
startupTimeout,
|
|
10554
12476
|
completionIdleTimeout,
|
|
12477
|
+
deadline,
|
|
10555
12478
|
]);
|
|
12479
|
+
run.endReason ??= 'completed';
|
|
10556
12480
|
return { speech: result.speech, toolCallsCount: result.toolCallsCount };
|
|
10557
12481
|
} finally {
|
|
10558
12482
|
if (startupWatchdog) clearTimeout(startupWatchdog);
|
|
10559
12483
|
if (completionWatchdog) clearInterval(completionWatchdog);
|
|
12484
|
+
if (deadlineWatchdog) clearTimeout(deadlineWatchdog);
|
|
10560
12485
|
// A batch still pending here has nothing left to settle it: once the
|
|
10561
12486
|
// run is deregistered, driveStream's finally abandons nothing (it does
|
|
10562
12487
|
// only while the agent is registered), and no later turn comes. The
|
|
@@ -10613,6 +12538,7 @@ export class AgentFramework {
|
|
|
10613
12538
|
this.turnRouting.delete(agent.name);
|
|
10614
12539
|
this.proseSpeechChains.delete(agent.name);
|
|
10615
12540
|
this.openLogical.delete(agent.name);
|
|
12541
|
+
this.forgetAnnouncedLocus(agent.name);
|
|
10616
12542
|
// The late stream finalizer intentionally refuses name-keyed cleanup once
|
|
10617
12543
|
// deregistered, so disposal must release EventGate liveness itself.
|
|
10618
12544
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
@@ -10624,6 +12550,8 @@ export class AgentFramework {
|
|
|
10624
12550
|
this.releaseWindowNowOrAfterResume(agent);
|
|
10625
12551
|
}
|
|
10626
12552
|
this.toolImageLedgers.delete(agent.name);
|
|
12553
|
+
if (this.agentConfigs.get(agent.name) === candidate.config) this.agentConfigs.delete(agent.name);
|
|
12554
|
+
this.toolSurfaceOwners.delete(agent.name);
|
|
10627
12555
|
// Its scripts end with it: no turn of its is left for them to outlive.
|
|
10628
12556
|
this.codeExecution?.scripts.agentGone(agent.name);
|
|
10629
12557
|
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
@@ -10641,6 +12569,21 @@ export class AgentFramework {
|
|
|
10641
12569
|
// A continuation of its turn still on its way goes too: the run, and
|
|
10642
12570
|
// with it the turn, has ended above.
|
|
10643
12571
|
this.dropContinuations(agent.name, null);
|
|
12572
|
+
if (run.endReason === 'host-stop') {
|
|
12573
|
+
// Cut off by stop(): left live for the next boot to end as interrupted.
|
|
12574
|
+
const record = this.registry.inspect(agent.name);
|
|
12575
|
+
this.emitTrace({
|
|
12576
|
+
type: 'dendrite:agent-interrupted',
|
|
12577
|
+
agentName: agent.name,
|
|
12578
|
+
kind: record?.kind ?? candidate.spec.kind,
|
|
12579
|
+
incarnation: record?.incarnation ?? 0,
|
|
12580
|
+
reason: 'host-stop',
|
|
12581
|
+
});
|
|
12582
|
+
} else {
|
|
12583
|
+
// The identity ends with its run. Whatever it spawned is settled by
|
|
12584
|
+
// the registry's rules (attention tenants end, task work is orphaned).
|
|
12585
|
+
this.settleEndedAgent(this.registry.end(agent.name, run.endReason ?? 'failed', run.endedBy, { record: true }));
|
|
12586
|
+
}
|
|
10644
12587
|
}
|
|
10645
12588
|
}
|
|
10646
12589
|
|
|
@@ -10674,12 +12617,22 @@ export class AgentFramework {
|
|
|
10674
12617
|
'its messages will reach your subconscious where that\'s known. ' +
|
|
10675
12618
|
'Calling tune_out again for a channel already tuned out keeps its ' +
|
|
10676
12619
|
'settings, and, where this host can open the channel, tries the open ' +
|
|
10677
|
-
'again if it isn\'t open.'
|
|
12620
|
+
'again if it isn\'t open. Without a subconscious it is a silent hold: ' +
|
|
12621
|
+
'nothing summarizes or wakes you; the backlog is delivered when you ' +
|
|
12622
|
+
'cancel. scope "all-except" holds EVERY channel (DMs and new channels ' +
|
|
12623
|
+
'included) except channelId, for durationSeconds (required); your plain ' +
|
|
12624
|
+
'speech lands in that channel meanwhile; the reader pauses rather than ' +
|
|
12625
|
+
'cancels when its wake budget is spent. mode "check" reports what is ' +
|
|
12626
|
+
'held without releasing it (`peek` shows one channel\'s newest held lines).',
|
|
10678
12627
|
inputSchema: {
|
|
10679
12628
|
type: 'object',
|
|
10680
12629
|
properties: {
|
|
10681
|
-
channelId: { type: 'string' },
|
|
10682
|
-
|
|
12630
|
+
channelId: { type: 'string', description: 'The channel to tune out — or, with scope "all-except", the one channel to keep.' },
|
|
12631
|
+
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").' },
|
|
12632
|
+
mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' },
|
|
12633
|
+
scope: { type: 'string', enum: ['channel', 'all-except'], description: 'Default: channel.' },
|
|
12634
|
+
peek: { type: 'string', description: 'mode "check": a held channel whose newest held lines to show (nothing is released).' },
|
|
12635
|
+
peekLimit: { type: 'number', description: 'mode "check": how many lines to peek (default 5, max 50).' },
|
|
10683
12636
|
cadenceSeconds: {
|
|
10684
12637
|
type: 'number',
|
|
10685
12638
|
description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
|
|
@@ -10701,7 +12654,33 @@ export class AgentFramework {
|
|
|
10701
12654
|
'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
|
|
10702
12655
|
},
|
|
10703
12656
|
},
|
|
10704
|
-
required: [
|
|
12657
|
+
required: [],
|
|
12658
|
+
},
|
|
12659
|
+
};
|
|
12660
|
+
|
|
12661
|
+
/** `focus`: the resident-facing alias for `tune_out` with scope "all-except" (#232 decision 2). */
|
|
12662
|
+
private static readonly FOCUS_TOOL: import('./types/index.js').ToolDefinition = {
|
|
12663
|
+
name: 'focus',
|
|
12664
|
+
description:
|
|
12665
|
+
'Focus on one channel for a bounded time: every other channel — DMs ' +
|
|
12666
|
+
'and channels that appear later included — is held and stops waking ' +
|
|
12667
|
+
'you; your plain speech lands in the focused channel. The same hold as ' +
|
|
12668
|
+
'tune_out with scope "all-except". mode "cancel" releases the held ' +
|
|
12669
|
+
'backlog per channel; mode "check" reports what is held.',
|
|
12670
|
+
inputSchema: {
|
|
12671
|
+
type: 'object',
|
|
12672
|
+
properties: {
|
|
12673
|
+
channelId: { type: 'string', description: 'The one channel to keep.' },
|
|
12674
|
+
serverId: { type: 'string' },
|
|
12675
|
+
durationSeconds: { type: 'number', description: 'Required to enter; floors at 60.' },
|
|
12676
|
+
mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' },
|
|
12677
|
+
cadenceSeconds: { type: 'number' },
|
|
12678
|
+
backlogCap: { type: 'number', description: 'Per held channel, at release.' },
|
|
12679
|
+
maxWakes: { type: 'number' },
|
|
12680
|
+
peek: { type: 'string' },
|
|
12681
|
+
peekLimit: { type: 'number' },
|
|
12682
|
+
},
|
|
12683
|
+
required: [],
|
|
10705
12684
|
},
|
|
10706
12685
|
};
|
|
10707
12686
|
|
|
@@ -12677,16 +14656,19 @@ export class AgentFramework {
|
|
|
12677
14656
|
|
|
12678
14657
|
private disposeEmptiedForks(failures: ManagerReinitFailure[]): ManagerReinitFailure[] {
|
|
12679
14658
|
const disposals: ManagerReinitFailure[] = [];
|
|
12680
|
-
|
|
14659
|
+
// The forks are the registry's conversation-fork records; a fork's home is
|
|
14660
|
+
// the channel on its server (homeOf), which the router's binding is keyed by.
|
|
14661
|
+
const forks = this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ agentName: r.name, home: this.homeOf(r.name) }));
|
|
14662
|
+
for (const { agentName, home } of forks) {
|
|
12681
14663
|
const agent = this.agents.get(agentName);
|
|
12682
14664
|
if (!agent || agent.getContextManager().getMessageCount() > 0) continue;
|
|
12683
14665
|
const heldWrites = this.deferredMessages.filter((msg) => (msg.forAgent ?? this.primaryAgentName) === agentName).length;
|
|
12684
14666
|
let cleanupFailed: string | undefined;
|
|
12685
14667
|
try {
|
|
12686
|
-
if (this.conversationRouter
|
|
12687
|
-
this.conversationRouter.unbind(
|
|
14668
|
+
if (home && this.conversationRouter && this.conversationRouter.getBinding(home)?.agentName === agentName) {
|
|
14669
|
+
this.conversationRouter.unbind(home);
|
|
12688
14670
|
}
|
|
12689
|
-
this.disposeConversationAgent(agentName);
|
|
14671
|
+
this.disposeConversationAgent(agentName, 'context-emptied');
|
|
12690
14672
|
} catch (error) {
|
|
12691
14673
|
cleanupFailed = failureDescription(error);
|
|
12692
14674
|
}
|
|
@@ -13699,8 +15681,13 @@ export class AgentFramework {
|
|
|
13699
15681
|
}
|
|
13700
15682
|
|
|
13701
15683
|
private async createAgent(config: AgentConfig): Promise<Agent> {
|
|
15684
|
+
// The residents' view (RESIDENT_VIEW_FILTER: held rows out, read by
|
|
15685
|
+
// heldFromResidents) composed with this agent's own: another agent's
|
|
15686
|
+
// stored context injections out. Main's boundary and arrival checks read
|
|
15687
|
+
// the same predicate (storeSees, agentSees), so they and the compiled
|
|
15688
|
+
// view agree.
|
|
13702
15689
|
const sees = (metadata: unknown): boolean =>
|
|
13703
|
-
!(metadata as
|
|
15690
|
+
!heldFromResidents(metadata as Record<string, unknown> | undefined)
|
|
13704
15691
|
&& !isAnotherAgentsInjection(metadata, config.name);
|
|
13705
15692
|
// Create context manager for this agent
|
|
13706
15693
|
const contextManager = await ContextManager.open({
|
|
@@ -13721,7 +15708,8 @@ export class AgentFramework {
|
|
|
13721
15708
|
// fetch_history, and audit.
|
|
13722
15709
|
// Another agent's stored context injections (stored-injections.ts) are
|
|
13723
15710
|
// that agent's context, not conversation: they stay out of this view,
|
|
13724
|
-
// and so out of its folds, as the residents share this slot.
|
|
15711
|
+
// and so out of its folds, as the residents share this slot. The
|
|
15712
|
+
// resident filter keeps held (tuned-out) rows out as before (`sees`).
|
|
13725
15713
|
viewFilter: (message) => sees(message.metadata),
|
|
13726
15714
|
});
|
|
13727
15715
|
this.trackManager(contextManager);
|
|
@@ -13731,6 +15719,10 @@ export class AgentFramework {
|
|
|
13731
15719
|
this.adoptToolResultGuard(agent);
|
|
13732
15720
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
13733
15721
|
this.sharedSlotAgents.add(agent);
|
|
15722
|
+
// The first resident declared owns default delivery.
|
|
15723
|
+
this.announceAgentCreated(
|
|
15724
|
+
this.registry.register({ ...residentSpec(config.name, { primary: !this.primaryAgentName }), model: config.model }),
|
|
15725
|
+
);
|
|
13734
15726
|
let restoredSettings: AgentRuntimeSettingsOverrides | null;
|
|
13735
15727
|
try {
|
|
13736
15728
|
restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
@@ -13776,7 +15768,7 @@ export class AgentFramework {
|
|
|
13776
15768
|
* passthrough, no memory pyramid: its durable output is what it delivers
|
|
13777
15769
|
* into the resident's window, which accumulates there.
|
|
13778
15770
|
*/
|
|
13779
|
-
private async createSubconsciousAgent(cfg:
|
|
15771
|
+
private async createSubconsciousAgent(cfg: PersistentReaderConfig): Promise<Agent> {
|
|
13780
15772
|
const primaryName = this.primaryAgentName;
|
|
13781
15773
|
const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
|
|
13782
15774
|
if (!primaryName || !primaryConfig) {
|
|
@@ -13843,6 +15835,12 @@ export class AgentFramework {
|
|
|
13843
15835
|
this.adoptToolResultGuard(agent);
|
|
13844
15836
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
13845
15837
|
this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
|
|
15838
|
+
// An attention tenant of the resident it serves: reads the shared slot
|
|
15839
|
+
// through its merged view (held traffic included), never a broadcast
|
|
15840
|
+
// recipient, and outlives policy epochs.
|
|
15841
|
+
this.announceAgentCreated(
|
|
15842
|
+
this.registry.register({ ...subconsciousSpec(name, primaryName), model: agentConfig.model }),
|
|
15843
|
+
);
|
|
13846
15844
|
this.agents.set(name, agent);
|
|
13847
15845
|
this.agentConfigs.set(name, agentConfig);
|
|
13848
15846
|
this.subconsciousAgentName = name;
|
|
@@ -13851,6 +15849,193 @@ export class AgentFramework {
|
|
|
13851
15849
|
return agent;
|
|
13852
15850
|
}
|
|
13853
15851
|
|
|
15852
|
+
/**
|
|
15853
|
+
* `subconscious.reader: 'forks'`: no persistent agent. The coordinator
|
|
15854
|
+
* asks for a fork per invocation (see invokeReaderFork); the config is
|
|
15855
|
+
* kept for the voice block, the speech switch and the strategy factory.
|
|
15856
|
+
*/
|
|
15857
|
+
private configureReaderForks(cfg: ForkReaderConfig): void {
|
|
15858
|
+
const primaryName = this.primaryAgentName;
|
|
15859
|
+
const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
|
|
15860
|
+
if (!primaryName || !primaryConfig) {
|
|
15861
|
+
throw new Error('subconscious requires a primary agent to attend to');
|
|
15862
|
+
}
|
|
15863
|
+
const strategy = primaryConfig.strategy;
|
|
15864
|
+
if (!cfg.strategyFactory && strategy && !(strategy instanceof PassthroughStrategy)) {
|
|
15865
|
+
throw new Error(
|
|
15866
|
+
`subconscious.reader "forks": the primary agent "${primaryName}" runs a ${strategy.name} strategy, ` +
|
|
15867
|
+
'so a reader fork needs `subconscious.strategyFactory` returning a fresh instance of the same ' +
|
|
15868
|
+
'class and configuration to reuse its fold state and rendering',
|
|
15869
|
+
);
|
|
15870
|
+
}
|
|
15871
|
+
// Whose weights read the resident's prefix is never defaulted. The
|
|
15872
|
+
// resident's own model is the copy; another is allowed when stated, so
|
|
15873
|
+
// the resident's consent can name it (architecture channel, 10-09).
|
|
15874
|
+
if (typeof cfg.model !== 'string' || cfg.model.length === 0) {
|
|
15875
|
+
throw new Error(
|
|
15876
|
+
`subconscious.reader "forks": a reader fork holds the whole prefix of "${primaryName}", so \`model\` ` +
|
|
15877
|
+
`must say whose weights run it — "${primaryConfig.model}" for a copy of the resident, or another model ` +
|
|
15878
|
+
'by name. It is not defaulted',
|
|
15879
|
+
);
|
|
15880
|
+
}
|
|
15881
|
+
if (typeof cfg.voice !== 'string' || cfg.voice.length === 0) {
|
|
15882
|
+
throw new Error('subconscious.reader "forks": `voice` (the reader\'s framing block) is required');
|
|
15883
|
+
}
|
|
15884
|
+
this.readerForks = true;
|
|
15885
|
+
this.subconsciousConfig = cfg;
|
|
15886
|
+
}
|
|
15887
|
+
|
|
15888
|
+
/**
|
|
15889
|
+
* One reader invocation: derive a fork of the resident at its head with
|
|
15890
|
+
* the resident's refusals, hand it the held traffic since the last look
|
|
15891
|
+
* as ordinary framing, and run it. It reports through `deliver_summary`
|
|
15892
|
+
* (attributed mail), may cancel the hold, and ends when its turn ends.
|
|
15893
|
+
*/
|
|
15894
|
+
private invokeReaderFork(invocation: ReaderInvocation): void {
|
|
15895
|
+
const primary = this.primaryAgentName;
|
|
15896
|
+
const cfg = this.subconsciousConfig;
|
|
15897
|
+
if (!primary || cfg?.reader !== 'forks' || !this.agents.has(primary)) return;
|
|
15898
|
+
const name = `reader/${primary}/${invocation.epochId.slice(0, 8)}/${++this.readerForkCounter}`;
|
|
15899
|
+
|
|
15900
|
+
void (async () => {
|
|
15901
|
+
try {
|
|
15902
|
+
// The queued messages are rows of the backlog a framing reads, after
|
|
15903
|
+
// the stored ones, so any framing that shows `backlog` shows them.
|
|
15904
|
+
const backlog = [...invocation.backlog, ...this.queuedReaderRows(primary, invocation.queued)];
|
|
15905
|
+
const content = (cfg.framing ?? defaultReaderFraming)({
|
|
15906
|
+
resident: primary,
|
|
15907
|
+
fork: name,
|
|
15908
|
+
voice: cfg.voice,
|
|
15909
|
+
channelId: invocation.channelId,
|
|
15910
|
+
epochId: invocation.epochId,
|
|
15911
|
+
trigger: invocation.trigger,
|
|
15912
|
+
notice: invocation.notice,
|
|
15913
|
+
dispositions: invocation.dispositions,
|
|
15914
|
+
backlog,
|
|
15915
|
+
queuedCount: invocation.queued.length,
|
|
15916
|
+
});
|
|
15917
|
+
const fork = await this.deriveAgent({
|
|
15918
|
+
name,
|
|
15919
|
+
from: primary,
|
|
15920
|
+
kind: 'subconscious-fork',
|
|
15921
|
+
...(cfg.strategyFactory ? { strategy: cfg.strategyFactory() } : {}),
|
|
15922
|
+
// Whose weights, as the configuration states it; its report is a
|
|
15923
|
+
// tool call, so bare prose never routes anywhere.
|
|
15924
|
+
config: {
|
|
15925
|
+
model: cfg.model,
|
|
15926
|
+
proseRouting: 'disabled',
|
|
15927
|
+
},
|
|
15928
|
+
inheritRefusals: true,
|
|
15929
|
+
onParentEnd: 'end',
|
|
15930
|
+
resultTo: { to: primary, as: 'message' },
|
|
15931
|
+
idleTimeoutMs: cfg.forkIdleTimeoutMs ?? 10 * 60_000,
|
|
15932
|
+
framing: [{
|
|
15933
|
+
participant: 'user',
|
|
15934
|
+
content,
|
|
15935
|
+
metadata: {
|
|
15936
|
+
system: true,
|
|
15937
|
+
kind: 'tune-out-reader-framing',
|
|
15938
|
+
channelId: invocation.channelId,
|
|
15939
|
+
epochId: invocation.epochId,
|
|
15940
|
+
trigger: invocation.trigger,
|
|
15941
|
+
heldMessageIds: backlog.map((m) => m.id),
|
|
15942
|
+
} as MessageMetadata,
|
|
15943
|
+
}],
|
|
15944
|
+
metadata: { epochId: invocation.epochId, channelId: invocation.channelId, trigger: invocation.trigger },
|
|
15945
|
+
});
|
|
15946
|
+
this.emitTrace({
|
|
15947
|
+
type: 'tune-out:reader-fork',
|
|
15948
|
+
agentName: name,
|
|
15949
|
+
serverId: invocation.serverId,
|
|
15950
|
+
channelId: invocation.channelId,
|
|
15951
|
+
epochId: invocation.epochId,
|
|
15952
|
+
trigger: invocation.trigger,
|
|
15953
|
+
held: invocation.backlog.length + invocation.queued.length,
|
|
15954
|
+
queued: invocation.queued.length,
|
|
15955
|
+
});
|
|
15956
|
+
const outcome = await this.runEphemeralToCompletion(fork.agent, fork.contextManager);
|
|
15957
|
+
// The report is the fork's whole job. A reader that ends in prose
|
|
15958
|
+
// without `deliver_summary` has still reported — its final prose is
|
|
15959
|
+
// delivered as its report, under its own name, instead of vanishing
|
|
15960
|
+
// into proseRouting=disabled (first live run, 2026-10-12, finding A).
|
|
15961
|
+
const prose = outcome.speech.trim();
|
|
15962
|
+
if (prose && !this.tuneOutCoordinator?.readerReported(name)) {
|
|
15963
|
+
this.deliverFromReader(name, [{ type: 'text', text: prose }], {
|
|
15964
|
+
kind: 'subconscious-summary',
|
|
15965
|
+
channelId: invocation.channelId,
|
|
15966
|
+
epochId: invocation.epochId,
|
|
15967
|
+
report: 'prose',
|
|
15968
|
+
});
|
|
15969
|
+
this.emitTrace({
|
|
15970
|
+
type: 'tune-out:reader-prose-delivered',
|
|
15971
|
+
agentName: name,
|
|
15972
|
+
serverId: invocation.serverId,
|
|
15973
|
+
channelId: invocation.channelId,
|
|
15974
|
+
epochId: invocation.epochId,
|
|
15975
|
+
chars: prose.length,
|
|
15976
|
+
});
|
|
15977
|
+
}
|
|
15978
|
+
} catch (error) {
|
|
15979
|
+
console.error(`[tune-out] reader fork ${name} failed:`, error instanceof Error ? error.message : error);
|
|
15980
|
+
// A fork that failed before it reported never read what it was
|
|
15981
|
+
// handed: those messages go back to the channel's backlog, so the
|
|
15982
|
+
// next reader for it is handed them instead of no reader ever being.
|
|
15983
|
+
const reported = this.tuneOutCoordinator?.readerReported(name) ?? false;
|
|
15984
|
+
const returned = reported ? 0 : (this.tuneOutCoordinator?.readerFailed(invocation) ?? 0);
|
|
15985
|
+
this.emitTrace({
|
|
15986
|
+
type: 'tune-out:reader-fork-failed',
|
|
15987
|
+
agentName: name,
|
|
15988
|
+
serverId: invocation.serverId,
|
|
15989
|
+
channelId: invocation.channelId,
|
|
15990
|
+
epochId: invocation.epochId,
|
|
15991
|
+
trigger: invocation.trigger,
|
|
15992
|
+
error: error instanceof Error ? error.message : String(error),
|
|
15993
|
+
reported,
|
|
15994
|
+
returned,
|
|
15995
|
+
});
|
|
15996
|
+
} finally {
|
|
15997
|
+
this.tuneOutCoordinator?.forgetReader(name);
|
|
15998
|
+
}
|
|
15999
|
+
})();
|
|
16000
|
+
}
|
|
16001
|
+
|
|
16002
|
+
/**
|
|
16003
|
+
* Held messages still queued behind the resident's turn, as rows of a
|
|
16004
|
+
* reader fork's backlog: each named by its held handle, as module rows name
|
|
16005
|
+
* it until it lands (heldRows), at a provisional sequence past the
|
|
16006
|
+
* resident's last stored message, and timed when it was first held.
|
|
16007
|
+
*/
|
|
16008
|
+
private queuedReaderRows(primary: string, queued: QueuedHeldMessage[]): StoredMessage[] {
|
|
16009
|
+
if (queued.length === 0) return [];
|
|
16010
|
+
const cm = this.agents.get(primary)!.getContextManager();
|
|
16011
|
+
const count = cm.getMessageCount();
|
|
16012
|
+
const last = count > 0 ? cm.getMessageWindow(count - 1, 1, { resolveBlobs: false }).messages[0]?.sequence ?? 0 : 0;
|
|
16013
|
+
return queued.map((m, i) => ({
|
|
16014
|
+
id: heldHandle(m.writeId),
|
|
16015
|
+
sequence: last + 1 + i,
|
|
16016
|
+
participant: m.participant,
|
|
16017
|
+
content: m.content,
|
|
16018
|
+
metadata: m.metadata as MessageMetadata | undefined,
|
|
16019
|
+
timestamp: new Date(m.heldAt ?? Date.now()),
|
|
16020
|
+
}));
|
|
16021
|
+
}
|
|
16022
|
+
|
|
16023
|
+
/** A reader fork's own words to the resident: attributed mail when it has a route, else under its name. */
|
|
16024
|
+
private deliverFromReader(reader: string, content: ContentBlock[], metadata: Record<string, unknown>): void {
|
|
16025
|
+
const route = this.registry.inspect(reader)?.relationships.resultTo;
|
|
16026
|
+
if (route?.as === 'message') {
|
|
16027
|
+
const mail = this.registry.holdMail({
|
|
16028
|
+
kind: 'result',
|
|
16029
|
+
from: this.registry.ref(reader),
|
|
16030
|
+
to: route.to,
|
|
16031
|
+
content,
|
|
16032
|
+
});
|
|
16033
|
+
this.attemptMailDelivery(mail);
|
|
16034
|
+
return;
|
|
16035
|
+
}
|
|
16036
|
+
this.addMessage(reader, content, metadata as MessageMetadata);
|
|
16037
|
+
}
|
|
16038
|
+
|
|
13854
16039
|
private async runLoop(): Promise<void> {
|
|
13855
16040
|
// A loop ends with its lifecycle. A stop() whose budget ran out while
|
|
13856
16041
|
// this loop was in a call (a module's onProcess) returns before the call
|
|
@@ -14164,7 +16349,7 @@ export class AgentFramework {
|
|
|
14164
16349
|
// stand between a tool's call and its results, so this clock
|
|
14165
16350
|
// reads the time it's written (clock-rows.ts).
|
|
14166
16351
|
const clock = this.storeDueClock(agent, 'tool-boundary');
|
|
14167
|
-
const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent
|
|
16352
|
+
const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent));
|
|
14168
16353
|
// Stored in queue order, cut at the first that can't be stored: it
|
|
14169
16354
|
// and what follows wait for the turn's end, and only what was
|
|
14170
16355
|
// stored is injected (the round's results go on either way). One
|
|
@@ -14177,10 +16362,17 @@ export class AgentFramework {
|
|
|
14177
16362
|
const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) =>
|
|
14178
16363
|
agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
|
|
14179
16364
|
for (const [msg, { id, content }] of stored) {
|
|
16365
|
+
this.releaseLandedMail(msg.metadata, agent.name);
|
|
14180
16366
|
this.noteSpokenTo(agent.name, msg.metadata);
|
|
14181
16367
|
// Every other resident reads what this boundary stores; for its
|
|
14182
16368
|
// own agent, the boundary weighs what it injects below.
|
|
14183
16369
|
this.noteForOtherResidents(agent, msg.metadata, id);
|
|
16370
|
+
// A message the agent's own view leaves out is stored and not
|
|
16371
|
+
// injected: a held message (the reader has it; admission's
|
|
16372
|
+
// predicate, heldFromResidents, is in every resident's view
|
|
16373
|
+
// filter) or another agent's stored context injection. Injecting
|
|
16374
|
+
// it here would put in the live turn what the next compile
|
|
16375
|
+
// hides (#232 gap 5; main's #853 reads the view itself).
|
|
14184
16376
|
if (!this.agentSees(agent, msg.metadata)) continue;
|
|
14185
16377
|
heard.push({ msg, id, content });
|
|
14186
16378
|
}
|
|
@@ -14306,6 +16498,34 @@ export class AgentFramework {
|
|
|
14306
16498
|
: 0;
|
|
14307
16499
|
const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
|
|
14308
16500
|
|
|
16501
|
+
// Under `speech.locus: 'delivery'` incoming traffic never moves the
|
|
16502
|
+
// pin — so when someone addresses the agent mid-turn from another
|
|
16503
|
+
// channel, the agent must be told who and from where, or a neutral
|
|
16504
|
+
// message reads as part of the conversation it landed in and gets
|
|
16505
|
+
// answered there (first live run, 2026-10-12, finding C). One
|
|
16506
|
+
// notice per channel per turn; it rides the same injection batch.
|
|
16507
|
+
// The turn's own mode: a silent turn routes no prose, whatever the
|
|
16508
|
+
// agent's mode, so its boundary neither notices nor re-pins. The
|
|
16509
|
+
// trigger is this stream's: its start holds the agent's turn token
|
|
16510
|
+
// while the agent waits on this round's tools.
|
|
16511
|
+
const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
|
|
16512
|
+
&& !this.activeTurnTriggers.get(agent.name)?.suppressProse;
|
|
16513
|
+
if (this.speechLocus === 'delivery' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
|
|
16514
|
+
const routing = this.routingOf(agent.name);
|
|
16515
|
+
for (const inj of [...midTurnInjections]) {
|
|
16516
|
+
const m = inj.metadata as Record<string, unknown> | undefined;
|
|
16517
|
+
if (!isConversationalInjection(inj.metadata)) continue;
|
|
16518
|
+
if (typeof m?.channelId !== 'string') continue;
|
|
16519
|
+
if (!isAddressedMessage(m.tags as string[] | undefined)) continue;
|
|
16520
|
+
// Its channel as a re-pin reads it (repinChannel): on the
|
|
16521
|
+
// message's own server.
|
|
16522
|
+
const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined);
|
|
16523
|
+
if (!channel) continue;
|
|
16524
|
+
const notice = this.addressedElsewhereNotice(agent, routing, channel, authorName(m));
|
|
16525
|
+
if (notice) midTurnInjections.push(notice);
|
|
16526
|
+
}
|
|
16527
|
+
}
|
|
16528
|
+
|
|
14309
16529
|
// Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
|
|
14310
16530
|
// when someone ADDRESSES the agent mid-turn from another channel
|
|
14311
16531
|
// (mention / reply / DM — the same chat:addressed signal the
|
|
@@ -14336,9 +16556,7 @@ export class AgentFramework {
|
|
|
14336
16556
|
// agent's mode, so its boundary neither re-pins nor notes. The
|
|
14337
16557
|
// trigger is this stream's: its start holds the agent's turn token
|
|
14338
16558
|
// while the agent waits on this round's tools.
|
|
14339
|
-
|
|
14340
|
-
&& !this.activeTurnTriggers.get(agent.name)?.suppressProse;
|
|
14341
|
-
if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
|
|
16559
|
+
if (this.speechLocus === 'latest-inbound' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
|
|
14342
16560
|
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
14343
16561
|
// - chat:addressed — someone explicitly spoke TO the agent
|
|
14344
16562
|
// (mention / reply / DM);
|
|
@@ -14738,12 +16956,12 @@ export class AgentFramework {
|
|
|
14738
16956
|
if (!decision.trigger) return;
|
|
14739
16957
|
}
|
|
14740
16958
|
|
|
14741
|
-
//
|
|
14742
|
-
//
|
|
16959
|
+
// A broadcast request reaches the agents whose role says they take
|
|
16960
|
+
// untargeted inbound; channel-bound and bounded-job agents are driven
|
|
16961
|
+
// by their own channel or task, not by framework-wide events.
|
|
14743
16962
|
const targetAgents =
|
|
14744
16963
|
response.requestInference === true
|
|
14745
|
-
?
|
|
14746
|
-
(n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
|
|
16964
|
+
? this.registry.untargetedRecipients()
|
|
14747
16965
|
: response.requestInference;
|
|
14748
16966
|
|
|
14749
16967
|
for (const agentName of targetAgents) {
|
|
@@ -14794,6 +17012,27 @@ export class AgentFramework {
|
|
|
14794
17012
|
* Convert an incoming MCPL channel message to a context message.
|
|
14795
17013
|
* This replaces the old MCPLModule.onProcess() message conversion.
|
|
14796
17014
|
*/
|
|
17015
|
+
/**
|
|
17016
|
+
* A delivered inbound message becomes the latest delivered inbound (the
|
|
17017
|
+
* channel context's reply anchor; the legacy speech fallback) through the
|
|
17018
|
+
* registry's noteDelivered — at once for a plain channels/incoming, whose
|
|
17019
|
+
* acceptance the registry answers synchronously; parked for a coalesced one
|
|
17020
|
+
* until its acceptance is acknowledged (see parkedAnchors).
|
|
17021
|
+
*/
|
|
17022
|
+
private noteDeliveredOrPark(event: {
|
|
17023
|
+
serverId: string; channelId: string; messageId: string; threadId?: string; tags?: string[];
|
|
17024
|
+
eventId?: string; coalescingSubject?: string; firstView?: boolean;
|
|
17025
|
+
}): void {
|
|
17026
|
+
// On the server the message came from (noteDelivered).
|
|
17027
|
+
const anchor = { channel: { serverId: event.serverId, channelId: event.channelId }, messageId: event.messageId, threadId: event.threadId, tags: event.tags, firstView: event.firstView === true };
|
|
17028
|
+
if (event.coalescingSubject && typeof event.eventId === 'string') {
|
|
17029
|
+
this.parkedAnchors.set(`${event.serverId}:${event.eventId}`, anchor);
|
|
17030
|
+
if (this.parkedAnchors.size > 1000) this.parkedAnchors.delete(this.parkedAnchors.keys().next().value!);
|
|
17031
|
+
return;
|
|
17032
|
+
}
|
|
17033
|
+
this.channelRegistry?.noteDelivered(anchor.channel, anchor.messageId, anchor.threadId, anchor.tags, anchor.firstView);
|
|
17034
|
+
}
|
|
17035
|
+
|
|
14797
17036
|
private async handleMcplChannelIncoming(event: {
|
|
14798
17037
|
type: 'mcpl:channel-incoming';
|
|
14799
17038
|
serverId: string;
|
|
@@ -14877,6 +17116,53 @@ export class AgentFramework {
|
|
|
14877
17116
|
// each storage site below, over exactly the blocks stored there.
|
|
14878
17117
|
metadata.sourceBodyDigest = sourceBodyDigest(event.content);
|
|
14879
17118
|
|
|
17119
|
+
// Stored as the model reads it: `author: body`. The wire formatter
|
|
17120
|
+
// writes the participant, and channel traffic is stored under the
|
|
17121
|
+
// anonymous `user`, so a body stored bare reaches the model as
|
|
17122
|
+
// `user: <text>` with the author nowhere (Weft's rig, 2026-10-13). A
|
|
17123
|
+
// server that renders the name into the text itself is left as is.
|
|
17124
|
+
const content = presentAuthor({ participant: 'user', content: event.content, metadata }).content;
|
|
17125
|
+
|
|
17126
|
+
// Admission (Dendrite §D, #232 root cause A): decided once, here, before
|
|
17127
|
+
// anything downstream acts — the hold, the wake gate, who may be woken,
|
|
17128
|
+
// whether this counts as the latest delivered inbound. Every path below
|
|
17129
|
+
// consumes `decision`; none re-asks the policy.
|
|
17130
|
+
const addressed = isAddressedMessage(event.tags);
|
|
17131
|
+
const decision = this.admitInbound({
|
|
17132
|
+
lane: 'channel',
|
|
17133
|
+
serverId: event.serverId,
|
|
17134
|
+
channelId: event.channelId,
|
|
17135
|
+
messageId: event.messageId,
|
|
17136
|
+
authorId: event.author?.id,
|
|
17137
|
+
tags: event.tags,
|
|
17138
|
+
addressed,
|
|
17139
|
+
vetoed: event.triggerInference === false,
|
|
17140
|
+
targetAgents: event.targetAgents,
|
|
17141
|
+
gateContent: event.content
|
|
17142
|
+
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
|
17143
|
+
.map((b) => b.text)
|
|
17144
|
+
.join('\n'),
|
|
17145
|
+
gateMetadata: {
|
|
17146
|
+
...event.metadata,
|
|
17147
|
+
eventType: 'mcpl:channel-incoming',
|
|
17148
|
+
serverId: event.serverId,
|
|
17149
|
+
channelId: event.channelId,
|
|
17150
|
+
messageId: event.messageId,
|
|
17151
|
+
threadId: event.threadId,
|
|
17152
|
+
author: event.author,
|
|
17153
|
+
...(event.tags ? { tags: event.tags } : {}),
|
|
17154
|
+
},
|
|
17155
|
+
});
|
|
17156
|
+
metadata.triggered = decision.wake;
|
|
17157
|
+
if (decision.held) {
|
|
17158
|
+
// The admission record rides the message; the hold's own bookkeeping
|
|
17159
|
+
// (cadence tally, suppressed-mention ack, reader wake) follows.
|
|
17160
|
+
metadata.tuneOut = { epochId: decision.held.epochId };
|
|
17161
|
+
this.tuneOutCoordinator?.recordHeld(
|
|
17162
|
+
event.serverId, event.channelId, event.messageId, event.tags, event.author?.id, decision.gatePass,
|
|
17163
|
+
);
|
|
17164
|
+
}
|
|
17165
|
+
|
|
14880
17166
|
// Per-channel conversation routing: messages go to the channel's fork
|
|
14881
17167
|
// agent (spawned from the template on first qualifying message), never
|
|
14882
17168
|
// to the primary conversation.
|
|
@@ -14886,14 +17172,19 @@ export class AgentFramework {
|
|
|
14886
17172
|
if (event.deliverTo) {
|
|
14887
17173
|
const target = this.agents.get(event.deliverTo);
|
|
14888
17174
|
if (!target) return undefined;
|
|
14889
|
-
if (this.conversationRouter && this.
|
|
17175
|
+
if (this.conversationRouter && this.registry.homeChannel(target.name) !== undefined) {
|
|
14890
17176
|
metadata.triggered = event.triggerInference ?? false;
|
|
14891
17177
|
// A fork's channel messages are stored past its deferral; a clock goes
|
|
14892
17178
|
// ahead of one only while the fork has no turn alive (storeDueClock).
|
|
14893
17179
|
if (!this.turnAlive(target) && takesClockBefore('user', event.content, metadata, target.name)) {
|
|
14894
17180
|
this.storeDueClock(target, 'event');
|
|
14895
17181
|
}
|
|
14896
|
-
|
|
17182
|
+
// The stored copy carries the author as the fork reads them (presentAuthor), and its digest is of that copy.
|
|
17183
|
+
const placement = this.writeToFork(target, content, AgentFramework.withStoredDigest(metadata, content));
|
|
17184
|
+
// Delivered to the fork, so it is the latest delivered inbound too
|
|
17185
|
+
// (the channel context's `incoming`; the fallback locus under the
|
|
17186
|
+
// legacy rule), as a resident's delivery is below.
|
|
17187
|
+
if (decision.locus) this.noteDeliveredOrPark(event);
|
|
14897
17188
|
// A correction may target an older engagement. Refresh only the
|
|
14898
17189
|
// binding whose agent actually received the message.
|
|
14899
17190
|
const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId };
|
|
@@ -14901,12 +17192,12 @@ export class AgentFramework {
|
|
|
14901
17192
|
this.conversationRouter.touch(channel);
|
|
14902
17193
|
}
|
|
14903
17194
|
this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' });
|
|
14904
|
-
if (
|
|
17195
|
+
if (decision.wake) {
|
|
14905
17196
|
this.pendingRequests.push({
|
|
14906
17197
|
agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
|
|
14907
17198
|
channelId: event.channelId, channelServerId: event.serverId,
|
|
14908
17199
|
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
14909
|
-
addressed
|
|
17200
|
+
addressed,
|
|
14910
17201
|
...systemNoticeMark(event.tags),
|
|
14911
17202
|
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
14912
17203
|
});
|
|
@@ -14914,14 +17205,14 @@ export class AgentFramework {
|
|
|
14914
17205
|
return placement;
|
|
14915
17206
|
}
|
|
14916
17207
|
if (this.conversationRouter) return undefined; // the trunk never holds channel traffic
|
|
14917
|
-
|
|
17208
|
+
decision.recipients = [target.name];
|
|
14918
17209
|
} else if (this.conversationRouter) {
|
|
14919
|
-
return this.routeConversationIncoming(event, metadata);
|
|
17210
|
+
return this.routeConversationIncoming({ ...event, content }, metadata, decision);
|
|
14920
17211
|
}
|
|
14921
17212
|
|
|
14922
17213
|
// Addressed-while-closed invitation — parity with the push-event path
|
|
14923
17214
|
// (channels.publish surfaces like portal-mcpl deliver mentions here).
|
|
14924
|
-
const incomingContent = [...
|
|
17215
|
+
const incomingContent = [...content];
|
|
14925
17216
|
{
|
|
14926
17217
|
const invitation = this.buildClosedChannelInvitation({
|
|
14927
17218
|
serverId: event.serverId,
|
|
@@ -14938,7 +17229,11 @@ export class AgentFramework {
|
|
|
14938
17229
|
missedCharacters: typeof event.metadata?.missedCharacters === 'number'
|
|
14939
17230
|
? event.metadata.missedCharacters
|
|
14940
17231
|
: undefined,
|
|
14941
|
-
|
|
17232
|
+
// A correction's one agent (deliverTo) is the agent whose window the
|
|
17233
|
+
// invitation goes into, so its mode is the one read (main's #852):
|
|
17234
|
+
// admission recorded it as the recipients, where main's code
|
|
17235
|
+
// rewrites the event's targetAgents.
|
|
17236
|
+
targetAgents: event.deliverTo ? decision.recipients : event.targetAgents,
|
|
14942
17237
|
});
|
|
14943
17238
|
if (invitation) {
|
|
14944
17239
|
incomingContent.push(invitation.block);
|
|
@@ -14946,38 +17241,17 @@ export class AgentFramework {
|
|
|
14946
17241
|
}
|
|
14947
17242
|
}
|
|
14948
17243
|
|
|
14949
|
-
//
|
|
14950
|
-
//
|
|
14951
|
-
//
|
|
14952
|
-
//
|
|
14953
|
-
const divert = this.tuneOutCoordinator?.onIncoming(
|
|
14954
|
-
event.serverId,
|
|
14955
|
-
event.channelId,
|
|
14956
|
-
event.messageId,
|
|
14957
|
-
event.tags,
|
|
14958
|
-
event.author?.id,
|
|
14959
|
-
// The resident's gate verdict rides the event; it preconditions
|
|
14960
|
-
// subconscious wakes (gate composes, it is not replaced).
|
|
14961
|
-
event.triggerInference !== false,
|
|
14962
|
-
) ?? null;
|
|
14963
|
-
if (divert) {
|
|
14964
|
-
metadata.tuneOut = { epochId: divert.epochId };
|
|
14965
|
-
}
|
|
14966
|
-
|
|
17244
|
+
// Captured where it arrived: a held message is stored with its admission
|
|
17245
|
+
// record and never enters a resident's view; a delivered one is also
|
|
17246
|
+
// the latest delivered inbound (the channel context's `incoming`, and
|
|
17247
|
+
// the fallback locus under the legacy rule) — a held one never is.
|
|
14967
17248
|
const placement: CoalescingPlacement = { agent: '' };
|
|
14968
17249
|
const id = this.addMessage('user', incomingContent, AgentFramework.withStoredDigest(metadata, incomingContent), { placement, bypassDeferralFor: event.assemblingFor });
|
|
14969
17250
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
17251
|
+
if (decision.locus) this.noteDeliveredOrPark(event);
|
|
14970
17252
|
|
|
14971
|
-
if (
|
|
14972
|
-
const
|
|
14973
|
-
// Honor explicit targeting, mirroring the push-event path: an event
|
|
14974
|
-
// that names its agents wakes exactly those; an untargeted event
|
|
14975
|
-
// keeps the historical broadcast. (targetAgents was declared on
|
|
14976
|
-
// McplChannelIncomingEvent from the start but never honored here —
|
|
14977
|
-
// tune-out's wake routing is the first setter.)
|
|
14978
|
-
const targetAgents = event.targetAgents
|
|
14979
|
-
?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
|
|
14980
|
-
for (const agentName of targetAgents) {
|
|
17253
|
+
if (decision.wake) {
|
|
17254
|
+
for (const agentName of decision.recipients) {
|
|
14981
17255
|
if (!this.agents.has(agentName)) continue;
|
|
14982
17256
|
this.pendingRequests.push({
|
|
14983
17257
|
agentName,
|
|
@@ -15021,6 +17295,7 @@ export class AgentFramework {
|
|
|
15021
17295
|
coalescingSubject?: string;
|
|
15022
17296
|
},
|
|
15023
17297
|
messageMetadata: Record<string, unknown>,
|
|
17298
|
+
admission: AdmissionDecision,
|
|
15024
17299
|
): Promise<CoalescingPlacement | undefined> {
|
|
15025
17300
|
const router = this.conversationRouter!;
|
|
15026
17301
|
const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId };
|
|
@@ -15083,9 +17358,9 @@ export class AgentFramework {
|
|
|
15083
17358
|
}
|
|
15084
17359
|
}
|
|
15085
17360
|
|
|
15086
|
-
//
|
|
15087
|
-
//
|
|
15088
|
-
const trigger = decision.trigger &&
|
|
17361
|
+
// The admission decision (server veto, wake gate) composes with the
|
|
17362
|
+
// router's own trigger policy.
|
|
17363
|
+
const trigger = decision.trigger && admission.wake;
|
|
15089
17364
|
messageMetadata.triggered = trigger;
|
|
15090
17365
|
|
|
15091
17366
|
// As for a correction above: a clock ahead only while no turn is alive.
|
|
@@ -15093,6 +17368,10 @@ export class AgentFramework {
|
|
|
15093
17368
|
this.storeDueClock(agent, 'event');
|
|
15094
17369
|
}
|
|
15095
17370
|
const placement = this.writeToFork(agent, event.content, AgentFramework.withStoredDigest(messageMetadata, event.content));
|
|
17371
|
+
// Delivered to the channel's fork: the latest delivered inbound, as for a
|
|
17372
|
+
// resident (the channel context's `incoming`; the fallback locus under
|
|
17373
|
+
// the legacy rule). A held or dropped message never is.
|
|
17374
|
+
if (admission.locus) this.noteDeliveredOrPark(event as Parameters<AgentFramework['noteDeliveredOrPark']>[0]);
|
|
15096
17375
|
router.touch(channel);
|
|
15097
17376
|
this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' });
|
|
15098
17377
|
|
|
@@ -15106,7 +17385,7 @@ export class AgentFramework {
|
|
|
15106
17385
|
// triggering channel too so the trunk/active path stays consistent.
|
|
15107
17386
|
channelId: event.channelId, channelServerId: event.serverId,
|
|
15108
17387
|
counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
|
|
15109
|
-
addressed: isAddressedMessage(event.tags
|
|
17388
|
+
addressed: isAddressedMessage(event.tags),
|
|
15110
17389
|
...systemNoticeMark(event.tags),
|
|
15111
17390
|
...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
|
|
15112
17391
|
});
|
|
@@ -15250,10 +17529,17 @@ export class AgentFramework {
|
|
|
15250
17529
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
15251
17530
|
this.adoptToolResultGuard(agent);
|
|
15252
17531
|
agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
|
|
17532
|
+
this.announceAgentCreated(this.registry.register({
|
|
17533
|
+
...conversationForkSpec(name, {
|
|
17534
|
+
template: router.templateAgent,
|
|
17535
|
+
homeChannel: home,
|
|
17536
|
+
idleTtlMs: router.idleTtlMs,
|
|
17537
|
+
}),
|
|
17538
|
+
model: config.model,
|
|
17539
|
+
}));
|
|
15253
17540
|
this.managerReleases.set(agent, release);
|
|
15254
17541
|
this.agents.set(name, agent);
|
|
15255
17542
|
this.agentConfigs.set(name, config);
|
|
15256
|
-
this.conversationAgentHomes.set(name, home);
|
|
15257
17543
|
return agent;
|
|
15258
17544
|
} catch (error) {
|
|
15259
17545
|
if (release.manager) this.closeManager(release.manager);
|
|
@@ -15399,61 +17685,27 @@ export class AgentFramework {
|
|
|
15399
17685
|
* stop() has ended binds nothing (spawnConversationAgent). The fork's
|
|
15400
17686
|
* context stays in Chronicle for investigation.
|
|
15401
17687
|
*/
|
|
15402
|
-
private disposeConversationAgent(agentName: string): void {
|
|
15403
|
-
this.
|
|
15404
|
-
// A continuation of its turn still on its way goes with it.
|
|
15405
|
-
this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
|
|
15406
|
-
const home = this.conversationAgentHomes.get(agentName);
|
|
17688
|
+
private disposeConversationAgent(agentName: string, reason: AgentEndReason = 'idle-ttl'): void {
|
|
17689
|
+
const home = this.homeOf(agentName);
|
|
15407
17690
|
const agent = this.agents.get(agentName);
|
|
15408
|
-
//
|
|
15409
|
-
//
|
|
15410
|
-
// activeStreams and pendingAssistantBlocks entries, as a run's end does
|
|
15411
|
-
// (runEphemeralToCompletion), and settles a batch its guard still holds,
|
|
15412
|
-
// withheld as `aborted`: the stream's own finally does neither once the
|
|
15413
|
-
// fork has gone. A fork the idle sweep ends has no stream entries left,
|
|
15414
|
-
// and a batch still pending there is one whose successor never ran,
|
|
15415
|
-
// which a run's end settles too.
|
|
15416
|
-
if (agent?.toolResultGuard.hasPending) {
|
|
15417
|
-
try {
|
|
15418
|
-
agent.toolResultGuard.abandon('aborted');
|
|
15419
|
-
} catch (error) {
|
|
15420
|
-
console.error(`[tool-result-guard] agent=${agentName} failed to record a batch the fork left:`, error);
|
|
15421
|
-
}
|
|
15422
|
-
}
|
|
15423
|
-
this.activeStreams.delete(agentName);
|
|
15424
|
-
this.pendingAssistantBlocks.delete(agentName);
|
|
15425
|
-
this.agents.delete(agentName);
|
|
15426
|
-
this.thinkingLossIncidents.forget(agentName);
|
|
15427
|
-
this.reasoningLoss.forget(agentName);
|
|
15428
|
-
// Its pending self-wake ends with it, rather than wake a name that's gone.
|
|
15429
|
-
this.eventGate?.agentGone(agentName);
|
|
17691
|
+
// The one release every ended agent gets (releaseAgentRuntime), then the
|
|
17692
|
+
// registry's end, whose outcome settles what the fork spawned.
|
|
15430
17693
|
if (agent) {
|
|
15431
|
-
this.
|
|
15432
|
-
|
|
15433
|
-
|
|
15434
|
-
|
|
15435
|
-
|
|
15436
|
-
|
|
15437
|
-
|
|
15438
|
-
|
|
15439
|
-
|
|
15440
|
-
|
|
15441
|
-
|
|
15442
|
-
|
|
15443
|
-
|
|
15444
|
-
|
|
15445
|
-
|
|
15446
|
-
// its timer would requeue held wakes for an agent that is gone, and a wait
|
|
15447
|
-
// held until released would re-arm for the name indefinitely. The recorded
|
|
15448
|
-
// wait stays, and binds the name if its channel binds again.
|
|
15449
|
-
const cooldown = this.providerAccelerationCooldowns.get(agentName);
|
|
15450
|
-
if (cooldown) {
|
|
15451
|
-
clearTimeout(cooldown.timer);
|
|
15452
|
-
this.providerAccelerationCooldowns.delete(agentName);
|
|
15453
|
-
console.error(`[provider-wait] agent=${agentName} disposed while held` +
|
|
15454
|
-
`${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` +
|
|
15455
|
-
`${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`);
|
|
15456
|
-
}
|
|
17694
|
+
this.releaseAgentRuntime(agent, home, 'its agent was disposed');
|
|
17695
|
+
} else {
|
|
17696
|
+
// Nothing live under the name: only what the process keeps by name.
|
|
17697
|
+
this.closingConversationAgents.delete(agentName);
|
|
17698
|
+
this.thinkingLossIncidents.forget(agentName);
|
|
17699
|
+
this.reasoningLoss.forget(agentName);
|
|
17700
|
+
// A continuation of its turn still on its way goes with it.
|
|
17701
|
+
this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
|
|
17702
|
+
this.turnRouting.delete(agentName);
|
|
17703
|
+
this.proseSpeechChains.delete(agentName);
|
|
17704
|
+
this.openLogical.delete(agentName);
|
|
17705
|
+
this.forgetAnnouncedLocus(agentName);
|
|
17706
|
+
this.codeExecution?.scripts.agentGone(agentName);
|
|
17707
|
+
}
|
|
17708
|
+
this.settleEndedAgent(this.registry.end(agentName, reason, 'framework', { record: true }));
|
|
15457
17709
|
this.emitTrace({
|
|
15458
17710
|
type: 'mcpl:conversation-disposed',
|
|
15459
17711
|
agentName,
|
|
@@ -15475,7 +17727,7 @@ export class AgentFramework {
|
|
|
15475
17727
|
agentName: string,
|
|
15476
17728
|
injections: ContextInjection[],
|
|
15477
17729
|
): ContextInjection[] {
|
|
15478
|
-
const home = this.
|
|
17730
|
+
const home = this.homeOf(agentName);
|
|
15479
17731
|
const registry = this.channelRegistry;
|
|
15480
17732
|
if (!home || !registry || injections.length === 0) {
|
|
15481
17733
|
return injections;
|
|
@@ -15519,7 +17771,7 @@ export class AgentFramework {
|
|
|
15519
17771
|
// Agent vanished (external reset) — nothing to close, just make sure
|
|
15520
17772
|
// its bookkeeping doesn't linger.
|
|
15521
17773
|
this.agentConfigs.delete(binding.agentName);
|
|
15522
|
-
this.
|
|
17774
|
+
this.settleEndedAgent(this.registry.end(binding.agentName, 'idle-ttl', 'framework', { record: true }));
|
|
15523
17775
|
continue;
|
|
15524
17776
|
}
|
|
15525
17777
|
this.writeToFork(
|
|
@@ -15552,7 +17804,7 @@ export class AgentFramework {
|
|
|
15552
17804
|
!this.activeStreams.has(agentName) &&
|
|
15553
17805
|
!this.pendingRequests.some((r) => r.agentName === agentName)
|
|
15554
17806
|
) {
|
|
15555
|
-
this.disposeConversationAgent(agentName);
|
|
17807
|
+
this.disposeConversationAgent(agentName, 'idle-ttl');
|
|
15556
17808
|
}
|
|
15557
17809
|
}
|
|
15558
17810
|
}
|
|
@@ -16067,7 +18319,11 @@ export class AgentFramework {
|
|
|
16067
18319
|
if (restored.recent) this.coalescingRecentReceipts = restored.recent;
|
|
16068
18320
|
this.pushCoalescer = restored.coalescer;
|
|
16069
18321
|
// Batches restored from a snapshot still owe the agent a wake (§14 v41).
|
|
16070
|
-
for (const occ of restored.coalescer.pendingBatchOccurrences())
|
|
18322
|
+
for (const occ of restored.coalescer.pendingBatchOccurrences()) {
|
|
18323
|
+
void this.wakeForCoalescedBatch(occ).catch((error: unknown) => {
|
|
18324
|
+
console.error(`[coalescing] ${occ.serverId}: the wake a restored batch (${occ.eventId}) owes its agent failed:`, error);
|
|
18325
|
+
});
|
|
18326
|
+
}
|
|
16071
18327
|
}
|
|
16072
18328
|
|
|
16073
18329
|
/**
|
|
@@ -16175,6 +18431,13 @@ export class AgentFramework {
|
|
|
16175
18431
|
// counts what was stored before a restart as shown: every agent's
|
|
16176
18432
|
// shown watermark starts at the head at boot.
|
|
16177
18433
|
const firstView = this.pushCoalescer.receiptFirstView(serverId, this.coalescingBinding(serverId), message.eventId);
|
|
18434
|
+
// Acknowledged: the anchor the delivery parked is committed now (and a
|
|
18435
|
+
// retry answered from the receipt commits its first attempt's).
|
|
18436
|
+
const parked = this.parkedAnchors.get(`${serverId}:${message.eventId}`);
|
|
18437
|
+
if (parked) {
|
|
18438
|
+
this.parkedAnchors.delete(`${serverId}:${message.eventId}`);
|
|
18439
|
+
this.channelRegistry?.noteDelivered(parked.channel, parked.messageId, parked.threadId, parked.tags, parked.firstView || firstView === true);
|
|
18440
|
+
}
|
|
16178
18441
|
return { messageId: message.messageId, accepted: true, coalesce: result.coalesce, ...(firstView ? { firstView: true as const } : {}) };
|
|
16179
18442
|
});
|
|
16180
18443
|
}
|
|
@@ -16303,7 +18566,7 @@ export class AgentFramework {
|
|
|
16303
18566
|
return [];
|
|
16304
18567
|
}
|
|
16305
18568
|
}
|
|
16306
|
-
return
|
|
18569
|
+
return this.registry.untargetedRecipients();
|
|
16307
18570
|
}
|
|
16308
18571
|
|
|
16309
18572
|
/** Deliver through the lane's ordinary path; report where it landed. */
|
|
@@ -16391,11 +18654,58 @@ export class AgentFramework {
|
|
|
16391
18654
|
private async queueCoalescedWakes(occ: CoalescedOccurrence<CoalescedDelivery>, plain = false, readsDeferredWrite?: string, allAmbient = false): Promise<void> {
|
|
16392
18655
|
const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
|
|
16393
18656
|
if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return;
|
|
16394
|
-
const
|
|
18657
|
+
const push = occ.event.event;
|
|
18658
|
+
const origin = push.origin;
|
|
16395
18659
|
// The wake carries the batch's channel so the turn freezes its reply
|
|
16396
18660
|
// locus there (the materialized delivery at assembly wakes nobody).
|
|
16397
18661
|
const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
|
|
16398
18662
|
const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
|
|
18663
|
+
// A deferred batch is admitted when it asks for its wake: the hold and
|
|
18664
|
+
// the gate decide now; its materialized delivery at assembly is vetoed
|
|
18665
|
+
// (`triggerInference: false`) and so is not shown to the gate again.
|
|
18666
|
+
const decision = this.admitInbound({
|
|
18667
|
+
lane: 'push',
|
|
18668
|
+
serverId: occ.serverId,
|
|
18669
|
+
channelId,
|
|
18670
|
+
messageId: occ.eventId,
|
|
18671
|
+
authorId,
|
|
18672
|
+
tags: occ.tags,
|
|
18673
|
+
addressed: isAddressedMessage(occ.tags),
|
|
18674
|
+
vetoed: false,
|
|
18675
|
+
targetAgents: push.targetAgents,
|
|
18676
|
+
gateContent: (push.content ?? [])
|
|
18677
|
+
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
|
18678
|
+
.map((b) => b.text)
|
|
18679
|
+
.join('\n'),
|
|
18680
|
+
gateMetadata: {
|
|
18681
|
+
...(origin ?? {}),
|
|
18682
|
+
serverId: occ.serverId,
|
|
18683
|
+
featureSet: push.featureSet,
|
|
18684
|
+
eventId: occ.eventId,
|
|
18685
|
+
eventType: 'mcpl:push-event',
|
|
18686
|
+
...(occ.tags ? { tags: occ.tags } : {}),
|
|
18687
|
+
},
|
|
18688
|
+
});
|
|
18689
|
+
if (decision.held && !plain && this.pushCoalescer) {
|
|
18690
|
+
// A held batch wakes nobody, so no turn's assembly would store it while
|
|
18691
|
+
// the hold lasts, and readers of held traffic and the release would
|
|
18692
|
+
// never see it. It's stored now, stamped with its hold as a plain push
|
|
18693
|
+
// under the hold is, and consumed, so the turn after the hold doesn't
|
|
18694
|
+
// take it again. Outside the coalescer's lock: this runs inside it.
|
|
18695
|
+
// A hold that ended before the batch was stored left it stored unheld:
|
|
18696
|
+
// it then takes the wake an unheld push gets, every recipient's.
|
|
18697
|
+
const coalescer = this.pushCoalescer;
|
|
18698
|
+
void this.coalescedAudience(occ)
|
|
18699
|
+
.then((audience) => (audience[0] !== undefined ? coalescer.materializeNow(audience[0], subject) : false))
|
|
18700
|
+
.then(async (stored) => {
|
|
18701
|
+
if (stored && !(channelId !== undefined && this.tuneOutCoordinator?.holdFor(occ.serverId, channelId))) {
|
|
18702
|
+
await this.queueCoalescedWakes(occ, true);
|
|
18703
|
+
}
|
|
18704
|
+
})
|
|
18705
|
+
.catch((err) => console.error(`[coalescing] a held batch for ${logValue(subject)} could not be stored:`, err));
|
|
18706
|
+
return;
|
|
18707
|
+
}
|
|
18708
|
+
if (!decision.wake) return;
|
|
16399
18709
|
for (const agentName of await this.coalescedAudience(occ)) {
|
|
16400
18710
|
this.pendingRequests.push({
|
|
16401
18711
|
agentName,
|
|
@@ -16404,7 +18714,7 @@ export class AgentFramework {
|
|
|
16404
18714
|
timestamp: Date.now(),
|
|
16405
18715
|
channelId, ...(channelId !== undefined ? { channelServerId: occ.serverId } : {}),
|
|
16406
18716
|
counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
|
|
16407
|
-
addressed: isAddressedMessage(occ.tags
|
|
18717
|
+
addressed: isAddressedMessage(occ.tags),
|
|
16408
18718
|
// No systemNotice mark here: a render folds the batch's every
|
|
16409
18719
|
// occurrence, and only its latest's tags are at hand, so marking it
|
|
16410
18720
|
// would hide a folded message's channel whenever a notice came last.
|
|
@@ -16493,9 +18803,13 @@ export class AgentFramework {
|
|
|
16493
18803
|
// not the agent that happened to be asked). A reader is one whose view
|
|
16494
18804
|
// filter keeps the message: a tune-out-diverted message never enters a
|
|
16495
18805
|
// resident's compiled view, and only the subconscious reads it through
|
|
16496
|
-
// its merged view — no subconscious, nobody reads it.
|
|
16497
|
-
|
|
16498
|
-
|
|
18806
|
+
// its merged view — no subconscious, nobody reads it. The slot's readers
|
|
18807
|
+
// are the registry's (residents and the subconscious), each asked its own
|
|
18808
|
+
// view (agentSees, main's #853).
|
|
18809
|
+
const readers = (this.registry.readsSharedSlot(agent.name)
|
|
18810
|
+
? this.registry.sharedSlotReaders()
|
|
18811
|
+
.map((name) => this.agents.get(name))
|
|
18812
|
+
.filter((reader): reader is Agent => reader !== undefined)
|
|
16499
18813
|
: [agent]).filter((a) => this.agentSees(a, message.metadata));
|
|
16500
18814
|
const branch = cm.currentBranch().name;
|
|
16501
18815
|
const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
|
|
@@ -16646,6 +18960,41 @@ export class AgentFramework {
|
|
|
16646
18960
|
// content merely by setting `origin.silent`.
|
|
16647
18961
|
const silentHeartbeat = isSilentHeartbeatMarker({ ...event, content });
|
|
16648
18962
|
|
|
18963
|
+
// Admission (Dendrite §D): the same decision as the channel lane. A DM
|
|
18964
|
+
// arrives here, so this is where tuning out a DM takes effect (#232 gap
|
|
18965
|
+
// 1). An event with no channel provenance cannot be held.
|
|
18966
|
+
const decision = this.admitInbound({
|
|
18967
|
+
lane: 'push',
|
|
18968
|
+
serverId: event.serverId,
|
|
18969
|
+
channelId: triggerChannel?.channelId,
|
|
18970
|
+
messageId: event.eventId,
|
|
18971
|
+
authorId: typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined,
|
|
18972
|
+
tags: event.tags,
|
|
18973
|
+
addressed: isAddressedMessage(event.tags),
|
|
18974
|
+
vetoed: event.triggerInference === false,
|
|
18975
|
+
targetAgents: event.targetAgents,
|
|
18976
|
+
gateContent: content
|
|
18977
|
+
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
|
18978
|
+
.map((b) => b.text)
|
|
18979
|
+
.join('\n'),
|
|
18980
|
+
gateMetadata: {
|
|
18981
|
+
...(event.origin ?? {}),
|
|
18982
|
+
serverId: event.serverId,
|
|
18983
|
+
featureSet: event.featureSet,
|
|
18984
|
+
eventId: event.eventId,
|
|
18985
|
+
eventType: 'mcpl:push-event',
|
|
18986
|
+
...(event.tags ? { tags: event.tags } : {}),
|
|
18987
|
+
},
|
|
18988
|
+
});
|
|
18989
|
+
metadata.triggered = decision.wake;
|
|
18990
|
+
if (decision.held && triggerChannel) {
|
|
18991
|
+
metadata.tuneOut = { epochId: decision.held.epochId };
|
|
18992
|
+
this.tuneOutCoordinator?.recordHeld(
|
|
18993
|
+
event.serverId, triggerChannel.channelId, event.eventId, event.tags,
|
|
18994
|
+
typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined, decision.gatePass,
|
|
18995
|
+
);
|
|
18996
|
+
}
|
|
18997
|
+
|
|
16649
18998
|
const placement: CoalescingPlacement = { agent: '' };
|
|
16650
18999
|
if (!silentHeartbeat) {
|
|
16651
19000
|
const id = this.addMessage('user', content, AgentFramework.withStoredDigest(metadata, content), { placement, bypassDeferralFor: event.assemblingFor });
|
|
@@ -16654,12 +19003,8 @@ export class AgentFramework {
|
|
|
16654
19003
|
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`);
|
|
16655
19004
|
}
|
|
16656
19005
|
|
|
16657
|
-
if (
|
|
16658
|
-
|
|
16659
|
-
const targetAgents = event.targetAgents
|
|
16660
|
-
?? [...this.agents.keys()].filter(
|
|
16661
|
-
(n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
|
|
16662
|
-
for (const agentName of targetAgents) {
|
|
19006
|
+
if (decision.wake) {
|
|
19007
|
+
for (const agentName of decision.recipients) {
|
|
16663
19008
|
this.pendingRequests.push({
|
|
16664
19009
|
agentName,
|
|
16665
19010
|
reason: 'mcpl:push-event',
|
|
@@ -16669,7 +19014,7 @@ export class AgentFramework {
|
|
|
16669
19014
|
// DMs and addressed-while-closed messages arrive as push events;
|
|
16670
19015
|
// they must outrank ambient chatter in a batched wake's locus
|
|
16671
19016
|
// selection just like their channels/incoming counterparts.
|
|
16672
|
-
addressed: isAddressedMessage(event.tags
|
|
19017
|
+
addressed: isAddressedMessage(event.tags),
|
|
16673
19018
|
...ambientMark(event.tags),
|
|
16674
19019
|
...systemNoticeMark(event.tags),
|
|
16675
19020
|
...(silentHeartbeat ? {
|
|
@@ -16696,10 +19041,10 @@ export class AgentFramework {
|
|
|
16696
19041
|
* a fork is that fork's conversation, so no other agent takes it.
|
|
16697
19042
|
*/
|
|
16698
19043
|
private mayTakeGateRoute(agentName: string, route: ChannelRef): boolean {
|
|
16699
|
-
const home = this.
|
|
19044
|
+
const home = this.homeOf(agentName);
|
|
16700
19045
|
if (home !== undefined) return sameChannel(home, route);
|
|
16701
|
-
for (const
|
|
16702
|
-
if (sameChannel(
|
|
19046
|
+
for (const record of this.registry.list()) {
|
|
19047
|
+
if (sameChannel(this.channelOfHome(record.homeChannel), route)) return false;
|
|
16703
19048
|
}
|
|
16704
19049
|
return true;
|
|
16705
19050
|
}
|
|
@@ -16718,6 +19063,35 @@ export class AgentFramework {
|
|
|
16718
19063
|
return named.length === 1 ? named[0] : undefined;
|
|
16719
19064
|
}
|
|
16720
19065
|
|
|
19066
|
+
/**
|
|
19067
|
+
* A live agent's home channel, from its registry record (channelOfHome):
|
|
19068
|
+
* defined for a conversation fork, and only for one. Undefined for an agent
|
|
19069
|
+
* with none, and for one that has ended.
|
|
19070
|
+
*/
|
|
19071
|
+
private homeOf(agentName: string): ChannelRef | undefined {
|
|
19072
|
+
return this.channelOfHome(this.registry.homeChannel(agentName));
|
|
19073
|
+
}
|
|
19074
|
+
|
|
19075
|
+
/**
|
|
19076
|
+
* The channel a registry record's `homeChannel` names: the channel on its
|
|
19077
|
+
* server. A bare channel id, as a record written before homes carried
|
|
19078
|
+
* their server keeps, is read as a wake that names its channel without its
|
|
19079
|
+
* server is (channelNamed): the one registered channel it names, read when
|
|
19080
|
+
* it's used, since the servers registered at that moment are what it can
|
|
19081
|
+
* mean. An id that names no one registered channel, as one no server
|
|
19082
|
+
* registers now or two servers do, is the home on no server (an empty
|
|
19083
|
+
* serverId, which channelNamed reads as none given), so the agent keeps a
|
|
19084
|
+
* home. A path that reads the pair finds no server registering it, and
|
|
19085
|
+
* refuses it; one that reads the name reads what the name means then: none
|
|
19086
|
+
* or two channels, which it refuses, or, once one server alone registers
|
|
19087
|
+
* the id, that server's channel.
|
|
19088
|
+
*/
|
|
19089
|
+
private channelOfHome(home: AgentRecord['homeChannel']): ChannelRef | undefined {
|
|
19090
|
+
if (home === undefined) return undefined;
|
|
19091
|
+
if (typeof home !== 'string') return home;
|
|
19092
|
+
return this.channelNamed(home) ?? { serverId: '', channelId: home };
|
|
19093
|
+
}
|
|
19094
|
+
|
|
16721
19095
|
/**
|
|
16722
19096
|
* Explicit mode's typing channel for one start of a turn, which has no
|
|
16723
19097
|
* locus, read once as the start opens (driveStream then shows what the
|
|
@@ -17297,6 +19671,42 @@ export class AgentFramework {
|
|
|
17297
19671
|
}
|
|
17298
19672
|
}
|
|
17299
19673
|
|
|
19674
|
+
/**
|
|
19675
|
+
* `channel_publish` with no channel goes where the turn's plain speech
|
|
19676
|
+
* would: the frozen turn locus (the waking delivery's channel, or one
|
|
19677
|
+
* opened this turn), then the registry's own resolution. Under
|
|
19678
|
+
* `speech.locus: 'delivery'` the registry alone has no default, and the
|
|
19679
|
+
* tool failed on the very turn its own delivery woke (first live run,
|
|
19680
|
+
* 2026-10-12). Both dispatch paths — the stream driver's and
|
|
19681
|
+
* executeToolCall's — pass through here. Only a call made inside the turn
|
|
19682
|
+
* (callingTurnOf) takes the turn's locus: one made outside it, as a script
|
|
19683
|
+
* left running after its turn makes or a host's executeToolCall naming the
|
|
19684
|
+
* agent, takes none of the turn's channels. A conversation fork's home
|
|
19685
|
+
* isn't the turn's but the fork's own, so such a call from a fork naming
|
|
19686
|
+
* no channel still goes there (homeOf), as the fork's dispatched calls do
|
|
19687
|
+
* (dispatchChannelToolCall); anyone else's takes the registry's own
|
|
19688
|
+
* default.
|
|
19689
|
+
*/
|
|
19690
|
+
private publishInputWithTurnLocus(input: unknown, agentName: string, callingTurn: TurnRouting | undefined): unknown {
|
|
19691
|
+
const given = (input as { channelId?: unknown } | null | undefined)?.channelId;
|
|
19692
|
+
if (typeof given === 'string') return input;
|
|
19693
|
+
const locus = callingTurn
|
|
19694
|
+
? callingTurn.locus ?? this.channelRegistry?.resolveLocus(agentName) ?? null
|
|
19695
|
+
: this.homeOf(agentName) ?? null;
|
|
19696
|
+
if (!locus) return input;
|
|
19697
|
+
// The pair: a serverId beside a channelId names that server's channel.
|
|
19698
|
+
// A serverId given with no channelId names the locus's id on that
|
|
19699
|
+
// server, as the registry reads one given beside its own default: the
|
|
19700
|
+
// default's id on the server given (handleToolPublish). So a fork's call
|
|
19701
|
+
// naming another server and no channel reaches that server's channel
|
|
19702
|
+
// with its home's id, where one is registered: its dispatched calls are
|
|
19703
|
+
// refused that (dispatchChannelToolCall), and executeToolCall applies
|
|
19704
|
+
// none of that guard, on main as here.
|
|
19705
|
+
const record = (input as Record<string, unknown> | null | undefined) ?? {};
|
|
19706
|
+
const serverId = typeof record.serverId === 'string' && record.serverId ? record.serverId : locus.serverId;
|
|
19707
|
+
return { ...record, channelId: locus.channelId, serverId };
|
|
19708
|
+
}
|
|
19709
|
+
|
|
17300
19710
|
/**
|
|
17301
19711
|
* Stamps for the rows the agent's reply stores in its current turn: its
|
|
17302
19712
|
* assistant responses, its tool_result rows (through `storeResults`), and
|
|
@@ -18077,7 +20487,7 @@ export class AgentFramework {
|
|
|
18077
20487
|
}
|
|
18078
20488
|
const registry = this.channelRegistry;
|
|
18079
20489
|
if (!registry) return refuse('No channels are configured, so there is nowhere to resend to.');
|
|
18080
|
-
const home = this.
|
|
20490
|
+
const home = this.homeOf(agentName);
|
|
18081
20491
|
let spec = typeof input.destination === 'string' ? input.destination.trim() : '';
|
|
18082
20492
|
if (present(input.destination) && typeof input.destination !== 'string') {
|
|
18083
20493
|
return refuse('destination must be "#channel", "@person" or a channel id.');
|
|
@@ -18513,17 +20923,56 @@ export class AgentFramework {
|
|
|
18513
20923
|
}
|
|
18514
20924
|
}
|
|
18515
20925
|
|
|
20926
|
+
private rememberAnnouncedLocus(agentName: string, locus: ChannelRef | null): void {
|
|
20927
|
+
this.lastAnnouncedLocus.set(agentName, locus);
|
|
20928
|
+
this.writeAnnouncedLocus(agentName, locus ? { serverId: locus.serverId, channelId: locus.channelId } : null);
|
|
20929
|
+
}
|
|
20930
|
+
|
|
20931
|
+
private forgetAnnouncedLocus(agentName: string): void {
|
|
20932
|
+
this.lastAnnouncedLocus.delete(agentName);
|
|
20933
|
+
this.writeAnnouncedLocus(agentName, undefined);
|
|
20934
|
+
}
|
|
20935
|
+
|
|
20936
|
+
/** The stored baseline's entry for the agent set (rememberAnnouncedLocus),
|
|
20937
|
+
* or removed (undefined) when the agent ends without coming back. */
|
|
20938
|
+
private writeAnnouncedLocus(agentName: string, locus: ChannelRef | null | undefined): void {
|
|
20939
|
+
try {
|
|
20940
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
20941
|
+
const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
|
|
20942
|
+
const loci = { ...((state.announcedLoci as Record<string, ChannelRef | null> | undefined) ?? {}) };
|
|
20943
|
+
if (locus === undefined) {
|
|
20944
|
+
if (!Object.hasOwn(loci, agentName)) return;
|
|
20945
|
+
delete loci[agentName];
|
|
20946
|
+
} else {
|
|
20947
|
+
loci[agentName] = locus;
|
|
20948
|
+
}
|
|
20949
|
+
state.announcedLoci = loci;
|
|
20950
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
20951
|
+
} catch (err) {
|
|
20952
|
+
console.error(`[routing] ${agentName}: the announced locus wasn't ${locus === undefined ? 'removed from' : 'written to'} the store:`, err);
|
|
20953
|
+
}
|
|
20954
|
+
}
|
|
20955
|
+
|
|
18516
20956
|
/**
|
|
18517
20957
|
* Announce-on-change locus notice (turn-frozen routing). Appends a durable
|
|
18518
20958
|
* `[routing]` window message when the turn's effective outbound locus
|
|
18519
20959
|
* differs from the last one announced — the agent must never have to guess
|
|
18520
20960
|
* where its plain prose lands. Durable append (never retracted) keeps the
|
|
18521
|
-
* KV prefix stable; change-only keeps it out of steady-state turns.
|
|
18522
|
-
*
|
|
18523
|
-
* no destination" is the unremarkable
|
|
20961
|
+
* KV prefix stable; change-only keeps it out of steady-state turns. An
|
|
20962
|
+
* agent never told of a locus, here or before a restart, hears only of a
|
|
20963
|
+
* non-null one ("prose has no destination" is the unremarkable start).
|
|
20964
|
+
* Never triggers
|
|
18524
20965
|
* inference; never locus-eligible itself (system + no channel metadata).
|
|
18525
20966
|
*/
|
|
18526
20967
|
private announceLocusIfChanged(agentName: string, locus: ChannelRef | null): void {
|
|
20968
|
+
if (!this.lastAnnouncedLocus.has(agentName)) {
|
|
20969
|
+
try {
|
|
20970
|
+
const stored = (this.store.getStateJson(FRAMEWORK_STATE_ID) as { announcedLoci?: Record<string, ChannelRef | null> } | null)?.announcedLoci;
|
|
20971
|
+
if (stored && Object.hasOwn(stored, agentName)) this.lastAnnouncedLocus.set(agentName, stored[agentName] ?? null);
|
|
20972
|
+
} catch (err) {
|
|
20973
|
+
console.error(`[routing] ${agentName}: the announced locus couldn't be read back, so this turn compares with none:`, err);
|
|
20974
|
+
}
|
|
20975
|
+
}
|
|
18527
20976
|
const hasBaseline = this.lastAnnouncedLocus.has(agentName);
|
|
18528
20977
|
const prev = this.lastAnnouncedLocus.get(agentName) ?? null;
|
|
18529
20978
|
if (hasBaseline ? sameChannel(prev, locus) : locus === null) {
|
|
@@ -18557,7 +21006,7 @@ export class AgentFramework {
|
|
|
18557
21006
|
);
|
|
18558
21007
|
// The baseline is what the agent was told: it moves once the notice is
|
|
18559
21008
|
// stored, so a notice that fails is announced again at the next turn.
|
|
18560
|
-
this.
|
|
21009
|
+
this.rememberAnnouncedLocus(agentName, locus);
|
|
18561
21010
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
18562
21011
|
console.error(
|
|
18563
21012
|
`[routing] ${agentName}: locus ${prev ? qualifiedChannelName(prev) : '(none)'} -> ${locus ? qualifiedChannelName(locus) : '(none)'} (announced in window)`,
|
|
@@ -18575,7 +21024,11 @@ export class AgentFramework {
|
|
|
18575
21024
|
* as the agent's first view) that addressed the agent, or that follows up
|
|
18576
21025
|
* in a channel the turn sent into (TurnRouting.engaged). Undefined for any
|
|
18577
21026
|
* other message. A tool boundary weighs the messages it injects by it,
|
|
18578
|
-
* and noteArrival the ones no boundary of the turn weighs.
|
|
21027
|
+
* and noteArrival the ones no boundary of the turn weighs. The rule is
|
|
21028
|
+
* pure; what is done with its answer is `speech.locus`'s: under
|
|
21029
|
+
* `'latest-inbound'` the locus moves, under `'delivery'` (the default) the
|
|
21030
|
+
* agent is told it was addressed from elsewhere and the locus stays
|
|
21031
|
+
* (addressedElsewhereNotice).
|
|
18579
21032
|
*/
|
|
18580
21033
|
private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): ChannelRef | undefined {
|
|
18581
21034
|
if (!isConversationalInjection(metadata)) return undefined;
|
|
@@ -18586,7 +21039,7 @@ export class AgentFramework {
|
|
|
18586
21039
|
// registers that id (channelNamed).
|
|
18587
21040
|
const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined);
|
|
18588
21041
|
if (!channel) return undefined;
|
|
18589
|
-
if (isAddressedMessage(m.tags as string[] | undefined
|
|
21042
|
+
if (isAddressedMessage(m.tags as string[] | undefined)) return channel;
|
|
18590
21043
|
return routing.engaged.has(destinationKey(channel)) ? channel : undefined;
|
|
18591
21044
|
}
|
|
18592
21045
|
|
|
@@ -18834,8 +21287,10 @@ export class AgentFramework {
|
|
|
18834
21287
|
* Weigh a message that no tool boundary of the agent's open logical turn
|
|
18835
21288
|
* will weigh: one that would re-pin the locus becomes one of the turn's
|
|
18836
21289
|
* arrivals (TurnRouting.arrivals). A message the agent's own view leaves
|
|
18837
|
-
* out, such as one
|
|
18838
|
-
*
|
|
21290
|
+
* out (agentSees), such as one held from the residents (heldFromResidents,
|
|
21291
|
+
* the admission stage's one predicate, which every resident's view filter
|
|
21292
|
+
* reads) or another agent's stored context injection, never enters its
|
|
21293
|
+
* compiled view, so the agent won't see it, and it moves nothing.
|
|
18839
21294
|
*/
|
|
18840
21295
|
private noteArrival(agentName: string, metadata: MessageMetadata | undefined, messageId: MessageId): void {
|
|
18841
21296
|
const open = this.openLogical.get(agentName);
|
|
@@ -18844,17 +21299,19 @@ export class AgentFramework {
|
|
|
18844
21299
|
if (agent && !this.agentSees(agent, metadata)) return;
|
|
18845
21300
|
if (isConversationalInjection(metadata)) (open.heard ??= new Map()).set(messageId, ++lastHeardAt);
|
|
18846
21301
|
const channel = this.repinChannel(metadata, open);
|
|
18847
|
-
if (channel)
|
|
21302
|
+
if (!channel) return;
|
|
21303
|
+
const tags = (metadata as Record<string, unknown> | undefined)?.tags as string[] | undefined;
|
|
21304
|
+
open.arrivals.push({ messageId, channel, addressed: isAddressedMessage(tags) });
|
|
18848
21305
|
}
|
|
18849
21306
|
|
|
18850
21307
|
/**
|
|
18851
21308
|
* Whether `agent`'s store is the residents' one message slot, which every
|
|
18852
|
-
* ordinary resident reads (
|
|
21309
|
+
* ordinary resident reads (`readsSharedSlot` in the registry). The subconscious reads that
|
|
18853
21310
|
* slot through a merged view, but its own store is isolated, as a fork's
|
|
18854
21311
|
* or an ephemeral agent's is.
|
|
18855
21312
|
*/
|
|
18856
21313
|
private isOrdinaryResident(agent: Agent): boolean {
|
|
18857
|
-
return this.
|
|
21314
|
+
return this.registry.readsSharedSlot(agent.name) && agent.name !== this.subconsciousAgentName;
|
|
18858
21315
|
}
|
|
18859
21316
|
|
|
18860
21317
|
/**
|
|
@@ -18865,8 +21322,9 @@ export class AgentFramework {
|
|
|
18865
21322
|
*/
|
|
18866
21323
|
private noteForOtherResidents(agent: Agent, metadata: MessageMetadata | undefined, messageId: MessageId): void {
|
|
18867
21324
|
if (!this.isOrdinaryResident(agent)) return;
|
|
18868
|
-
for (const
|
|
18869
|
-
|
|
21325
|
+
for (const name of this.registry.sharedSlotReaders()) {
|
|
21326
|
+
const reader = this.agents.get(name);
|
|
21327
|
+
if (reader && reader !== agent && this.isOrdinaryResident(reader)) {
|
|
18870
21328
|
this.noteSpokenTo(reader.name, metadata);
|
|
18871
21329
|
this.noteArrival(reader.name, metadata, messageId);
|
|
18872
21330
|
}
|
|
@@ -18948,7 +21406,7 @@ export class AgentFramework {
|
|
|
18948
21406
|
const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata;
|
|
18949
21407
|
try {
|
|
18950
21408
|
const id = agent.getContextManager().addMessage('user', content, metadata);
|
|
18951
|
-
this.
|
|
21409
|
+
this.rememberAnnouncedLocus(agent.name, channel);
|
|
18952
21410
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
18953
21411
|
console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ? qualifiedChannelName(prevPin) : '(none)'} -> ${qualifiedChannelName(channel)}`);
|
|
18954
21412
|
return { participant: 'user', content, metadata, id };
|
|
@@ -18958,6 +21416,78 @@ export class AgentFramework {
|
|
|
18958
21416
|
}
|
|
18959
21417
|
}
|
|
18960
21418
|
|
|
21419
|
+
/**
|
|
21420
|
+
* Under `speech.locus: 'delivery'`: tell the agent that someone addressed
|
|
21421
|
+
* it from another channel while its turn was busy, since incoming traffic
|
|
21422
|
+
* never moves the locus and a neutral message carries nothing that names
|
|
21423
|
+
* its channel (first live run, 2026-10-12, finding C). Once per channel
|
|
21424
|
+
* per turn (TurnRouting.addressedElsewhere); nothing for the turn's own
|
|
21425
|
+
* locus. Stored durably; returned as stored for a live stream to inject
|
|
21426
|
+
* with its boundary's other messages. Undefined when nothing was stored.
|
|
21427
|
+
*/
|
|
21428
|
+
private addressedElsewhereNotice(
|
|
21429
|
+
agent: Agent,
|
|
21430
|
+
routing: TurnRouting,
|
|
21431
|
+
channel: ChannelRef,
|
|
21432
|
+
who: string,
|
|
21433
|
+
when = 'while you were busy',
|
|
21434
|
+
): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
|
|
21435
|
+
const pin = routing.locus ?? null;
|
|
21436
|
+
const key = destinationKey(channel);
|
|
21437
|
+
if (sameChannel(channel, pin) || routing.addressedElsewhere.has(key)) return undefined;
|
|
21438
|
+
const where = pin ? `still lands in ${this.channelShown(pin)}` : 'stays in your archive (no channel)';
|
|
21439
|
+
const content: ContentBlock[] = [{
|
|
21440
|
+
type: 'text',
|
|
21441
|
+
text:
|
|
21442
|
+
// The author's name is a value someone supplied (main's #852: one
|
|
21443
|
+
// line, its notice openings marked); the channel is shown as
|
|
21444
|
+
// channelShown gives it.
|
|
21445
|
+
`[${NOTICE_TAG.routing}] ${suppliedValue(who)} addressed you from ${this.channelShown(channel)} ${when}. ` +
|
|
21446
|
+
`Your plain speech ${where} this turn; reaching them takes an explicit send.`,
|
|
21447
|
+
}];
|
|
21448
|
+
const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata;
|
|
21449
|
+
const before = this.storedCountOf(agent);
|
|
21450
|
+
try {
|
|
21451
|
+
const id = agent.getContextManager().addMessage('user', content, metadata);
|
|
21452
|
+
routing.addressedElsewhere.add(key);
|
|
21453
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
|
|
21454
|
+
return { participant: 'user', content, metadata };
|
|
21455
|
+
} catch (err) {
|
|
21456
|
+
// A write can throw after its row landed, as storeInjections' can: a
|
|
21457
|
+
// notice that landed is the turn's notice for the channel, as one
|
|
21458
|
+
// stored without a throw is.
|
|
21459
|
+
const text = (content[0] as { text: string }).text;
|
|
21460
|
+
const landed = before === undefined ? undefined : this.storedSince(agent, before, (m) =>
|
|
21461
|
+
(m.metadata as { kind?: unknown } | undefined)?.kind === 'routing-notice'
|
|
21462
|
+
&& m.content.some((b) => b.type === 'text' && (b as { text: string }).text === text),
|
|
21463
|
+
'routing', 'the addressed-elsewhere notice');
|
|
21464
|
+
if (landed && landed.length > 0) {
|
|
21465
|
+
routing.addressedElsewhere.add(key);
|
|
21466
|
+
console.error(`addressed-elsewhere notice: ${agent.name}: the notice landed though its write threw:`, err);
|
|
21467
|
+
return { participant: 'user', content, metadata };
|
|
21468
|
+
}
|
|
21469
|
+
console.error(`addressed-elsewhere notice: failed to record routing notice${landed ? '' : ' (whether it landed is unknown)'}:`, err);
|
|
21470
|
+
return undefined;
|
|
21471
|
+
}
|
|
21472
|
+
}
|
|
21473
|
+
|
|
21474
|
+
/**
|
|
21475
|
+
* The arrivals a start of the turn consumes under `speech.locus:
|
|
21476
|
+
* 'delivery'`: each one that addressed the agent, whose message is still
|
|
21477
|
+
* in the store, gets the addressed-elsewhere notice, oldest first. A
|
|
21478
|
+
* follow-up in an engaged channel addressed nobody and is not announced.
|
|
21479
|
+
*/
|
|
21480
|
+
private noticeAddressedArrivals(agent: Agent, routing: TurnRouting, arrivals: Arrival[], when: string): void {
|
|
21481
|
+
if (arrivals.length === 0) return;
|
|
21482
|
+
const cm = agent.getContextManager();
|
|
21483
|
+
for (const { messageId, channel, addressed } of arrivals) {
|
|
21484
|
+
if (!addressed) continue;
|
|
21485
|
+
const message = cm.getMessage(messageId);
|
|
21486
|
+
if (!message) continue;
|
|
21487
|
+
this.addressedElsewhereNotice(agent, routing, channel, authorName(message.metadata as Record<string, unknown> | undefined), when);
|
|
21488
|
+
}
|
|
21489
|
+
}
|
|
21490
|
+
|
|
18961
21491
|
/**
|
|
18962
21492
|
* Durable window notice for a channel the agent did not ask to open: a
|
|
18963
21493
|
* subscription-policy admission, announced as the decision before its
|
|
@@ -19147,7 +21677,7 @@ export class AgentFramework {
|
|
|
19147
21677
|
*/
|
|
19148
21678
|
private canCloseChannels(who: string | undefined, channels: readonly ChannelRef[]): boolean {
|
|
19149
21679
|
if (who === undefined) return false;
|
|
19150
|
-
const home = this.
|
|
21680
|
+
const home = this.homeOf(who);
|
|
19151
21681
|
return channels.every((channel) => !home || sameChannel(home, channel)) && this.canAgentUseTool(who, 'channel_close');
|
|
19152
21682
|
}
|
|
19153
21683
|
|
|
@@ -19170,7 +21700,7 @@ export class AgentFramework {
|
|
|
19170
21700
|
*/
|
|
19171
21701
|
private holdsChannelTraffic(who: string | undefined, channels: readonly ChannelRef[]): boolean {
|
|
19172
21702
|
if (who === undefined) return false;
|
|
19173
|
-
const home = this.
|
|
21703
|
+
const home = this.homeOf(who);
|
|
19174
21704
|
if (home !== undefined) {
|
|
19175
21705
|
return channels.every((channel) => sameChannel(channel, home) && this.conversationRouter?.getBinding(channel)?.agentName === who);
|
|
19176
21706
|
}
|
|
@@ -19187,7 +21717,12 @@ export class AgentFramework {
|
|
|
19187
21717
|
* otherwise the delivery agent and the readers of the residents' shared slot do. An ephemeral run holds none.
|
|
19188
21718
|
*/
|
|
19189
21719
|
private holdsChannelTrafficForTools(who: string): boolean {
|
|
19190
|
-
if (this.
|
|
21720
|
+
if (this.registry.homeChannel(who) !== undefined) return true;
|
|
21721
|
+
// An agent that shares its source's context (a reader fork) shares its
|
|
21722
|
+
// source's words too: its tool block is the source's prefix, and a
|
|
21723
|
+
// sentence that differed would send the whole window uncached.
|
|
21724
|
+
const inherit = this.registry.get(who)?.inherit;
|
|
21725
|
+
if (inherit?.mode === 'shared' && inherit.from !== who) return this.holdsChannelTrafficForTools(inherit.from);
|
|
19191
21726
|
if (this.conversationRouter) return false;
|
|
19192
21727
|
return this.residentHoldsTraffic(who);
|
|
19193
21728
|
}
|
|
@@ -19210,7 +21745,7 @@ export class AgentFramework {
|
|
|
19210
21745
|
* notice passed on later, at resume or at boot, names it by its name.
|
|
19211
21746
|
*/
|
|
19212
21747
|
private agentForPrimary(agentName: string): { first: string; again: string } {
|
|
19213
|
-
const home = this.
|
|
21748
|
+
const home = this.homeOf(agentName);
|
|
19214
21749
|
if (home === undefined) return { first: agentName, again: agentName };
|
|
19215
21750
|
const label = this.channelRegistry?.getDescriptor(home)?.label;
|
|
19216
21751
|
const bound = this.labelledChannel({ ...home, ...(label ? { label } : {}) });
|
|
@@ -19232,7 +21767,7 @@ export class AgentFramework {
|
|
|
19232
21767
|
/** Whether `agentName` is a conversation fork made from the primary: the
|
|
19233
21768
|
* router's template is the primary (connectome-host's is). */
|
|
19234
21769
|
private forkOfPrimary(agentName: string): boolean {
|
|
19235
|
-
return this.
|
|
21770
|
+
return this.registry.homeChannel(agentName) !== undefined && this.primaryAgentName !== null &&
|
|
19236
21771
|
this.conversationRouter?.templateAgent === this.primaryAgentName;
|
|
19237
21772
|
}
|
|
19238
21773
|
|
|
@@ -19304,7 +21839,7 @@ export class AgentFramework {
|
|
|
19304
21839
|
return [];
|
|
19305
21840
|
}
|
|
19306
21841
|
const who = ended ? primary : agentName || this.deliveryAgent()?.name;
|
|
19307
|
-
const canOpen = who !== undefined &&
|
|
21842
|
+
const canOpen = who !== undefined && this.registry.homeChannel(who) === undefined && this.canAgentUseTool(who, 'channel_open');
|
|
19308
21843
|
const canClose = this.canCloseChannels(who, [{ serverId, channelId: channel.channelId }]);
|
|
19309
21844
|
const selected = selectedForOpening === 'unrecorded'
|
|
19310
21845
|
? " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again"
|
|
@@ -19346,7 +21881,7 @@ export class AgentFramework {
|
|
|
19346
21881
|
};
|
|
19347
21882
|
const notices: ChannelNotice[] = [];
|
|
19348
21883
|
if (tellPrimary) {
|
|
19349
|
-
const primaryCanOpen =
|
|
21884
|
+
const primaryCanOpen = this.registry.homeChannel(primary!) === undefined && this.canAgentUseTool(primary!, 'channel_open');
|
|
19350
21885
|
const reader = this.agentForPrimary(who!);
|
|
19351
21886
|
notices.push({
|
|
19352
21887
|
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.` +
|
|
@@ -19429,7 +21964,7 @@ export class AgentFramework {
|
|
|
19429
21964
|
if (trigger?.suppressProse) return;
|
|
19430
21965
|
// A subagent's run ends with its result; a conversation fork answers
|
|
19431
21966
|
// its home channel's message.
|
|
19432
|
-
if (this.ephemeralRuns.has(name) || this.
|
|
21967
|
+
if (this.ephemeralRuns.has(name) || this.registry.homeChannel(name) !== undefined) return;
|
|
19433
21968
|
if (this.providerAdmissionClosed || this.agents.get(name) !== agent) {
|
|
19434
21969
|
console.error(`[continue] ${name}: not continued — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`);
|
|
19435
21970
|
return;
|
|
@@ -20388,8 +22923,7 @@ export class AgentFramework {
|
|
|
20388
22923
|
attempt = 0,
|
|
20389
22924
|
providerGateAlreadyHeld = false,
|
|
20390
22925
|
): Promise<void> {
|
|
20391
|
-
const ownsProviderGate =
|
|
20392
|
-
!this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name);
|
|
22926
|
+
const ownsProviderGate = this.registry.ownsProviderScheduling(agent.name);
|
|
20393
22927
|
// A stopping framework starts no turn, whoever owns provider admission
|
|
20394
22928
|
// (conversation agents and ephemeral runs do not take the gate).
|
|
20395
22929
|
if (this.providerAdmissionClosed) {
|
|
@@ -21248,11 +23782,28 @@ export class AgentFramework {
|
|
|
21248
23782
|
// A turn in a mode with a locus reads its arrivals' messages, a turn
|
|
21249
23783
|
// with no channel (noChannel) included: an addressed message, or a
|
|
21250
23784
|
// follow-up where the turn sent, gives it its channel, as a re-pin does.
|
|
21251
|
-
|
|
23785
|
+
// Under `speech.locus: 'delivery'` an arrival moves nothing; one that
|
|
23786
|
+
// addressed the agent from another channel is announced instead, as a
|
|
23787
|
+
// boundary would have announced it (addressedElsewhereNotice).
|
|
23788
|
+
const routesLocus = turnProseRouting === 'locus' || turnProseRouting === 'hybrid';
|
|
23789
|
+
const arrivals = routing.arrivals;
|
|
23790
|
+
const arrival = routesLocus && this.speechLocus === 'latest-inbound'
|
|
21252
23791
|
? this.remainingArrival(agent, routing)
|
|
21253
23792
|
: undefined;
|
|
21254
23793
|
routing.arrivals = [];
|
|
21255
23794
|
if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival');
|
|
23795
|
+
else if (routesLocus && this.speechLocus === 'delivery') {
|
|
23796
|
+
// Which start this is, since from inside they look alike and the
|
|
23797
|
+
// reason says whether the earlier prose landed (Linn, 2026-10-14).
|
|
23798
|
+
const when = attempt > 0
|
|
23799
|
+
? 'while your turn was retried'
|
|
23800
|
+
: trigger?.reason === 'context_budget_restart'
|
|
23801
|
+
? 'at your budget restart'
|
|
23802
|
+
: isTurnContinuation(trigger?.reason ?? '')
|
|
23803
|
+
? 'before this re-inference on your tool results'
|
|
23804
|
+
: 'while your turn waited on a provider hold';
|
|
23805
|
+
this.noticeAddressedArrivals(agent, routing, arrivals, when);
|
|
23806
|
+
}
|
|
21256
23807
|
}
|
|
21257
23808
|
// A silent start, or a disabled agent's, has no locus to show, whatever a
|
|
21258
23809
|
// channel_open in a silent turn left on its object.
|
|
@@ -21474,7 +24025,7 @@ export class AgentFramework {
|
|
|
21474
24025
|
// can record a wait while this turn compiles, after the last boundary
|
|
21475
24026
|
// checked. Check its waits again here, where nothing has been sent, and
|
|
21476
24027
|
// hold the turn as the boundary would rather than send it into the wait.
|
|
21477
|
-
if (this.
|
|
24028
|
+
if (this.registry.homeChannel(agent.name) !== undefined) {
|
|
21478
24029
|
const wait = this.providerWaits?.active(agent.name, agent.model);
|
|
21479
24030
|
// The turn has begun (inference:started), so the hold keeps its retry.
|
|
21480
24031
|
if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(withTurnCarry(trigger, carry))] : [], false, () => {
|
|
@@ -21495,6 +24046,7 @@ export class AgentFramework {
|
|
|
21495
24046
|
})) {
|
|
21496
24047
|
console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
|
|
21497
24048
|
`(recorded while the turn compiled); ${this.providerAdmissionClosed ? 'the framework is stopping, so nothing is held'
|
|
24049
|
+
: this.agents.get(agent.name) !== agent ? 'the agent ended as the failure was reported, so nothing is held'
|
|
21498
24050
|
: this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held'
|
|
21499
24051
|
: 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`);
|
|
21500
24052
|
this.endFailureAtStop(agent.name, turnToken, turnStartedAt, routing);
|
|
@@ -23206,7 +25758,7 @@ export class AgentFramework {
|
|
|
23206
25758
|
.filter((c): c is { id?: unknown; name: string; input?: unknown; unparseableInput?: string } =>
|
|
23207
25759
|
typeof c.name === 'string' && !cutIds.has(c.id as string)
|
|
23208
25760
|
&& markedInputRefusal({ ...c, name: c.name }) === undefined
|
|
23209
|
-
&& this.
|
|
25761
|
+
&& this.mayCallTool(agent.name, c.name));
|
|
23210
25762
|
const toolNames = toolCalls.map((c) => c.name);
|
|
23211
25763
|
// A refused skip_reply isn't a silencing call; a plain one is.
|
|
23212
25764
|
const fallbackCause = liveProseRouting
|
|
@@ -24101,7 +26653,7 @@ export class AgentFramework {
|
|
|
24101
26653
|
// turn isn't over (pendingContinuationAgents).
|
|
24102
26654
|
if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)
|
|
24103
26655
|
&& !this.pendingContinuationAgents().has(agent.name)) {
|
|
24104
|
-
this.disposeConversationAgent(agent.name);
|
|
26656
|
+
this.disposeConversationAgent(agent.name, 'idle-ttl');
|
|
24105
26657
|
}
|
|
24106
26658
|
}
|
|
24107
26659
|
}
|
|
@@ -24122,8 +26674,10 @@ export class AgentFramework {
|
|
|
24122
26674
|
* (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt
|
|
24123
26675
|
* from agent allowlists. Registered agents' allowedTools is enforced at
|
|
24124
26676
|
* dispatch, including namespaced utility names. Genuinely unidentified public callers (including
|
|
24125
|
-
* unknown legacy names) remain unrestricted.
|
|
24126
|
-
*
|
|
26677
|
+
* unknown legacy names) remain unrestricted. A name the framework made in
|
|
26678
|
+
* this process is refused while it isn't registered, even if its configured
|
|
26679
|
+
* list allows the tool; after a restart, such a name is a host's caller name
|
|
26680
|
+
* like any other.
|
|
24127
26681
|
*/
|
|
24128
26682
|
async executeToolCall(call: ToolCall): Promise<ToolResult> {
|
|
24129
26683
|
return this.executeToolCallFrom(call, {
|
|
@@ -24400,7 +26954,11 @@ export class AgentFramework {
|
|
|
24400
26954
|
// synthesized verbs (think, skip_reply) are deliberately not routed
|
|
24401
26955
|
// here.
|
|
24402
26956
|
if (call.name.startsWith('channel_') && this.channelRegistry) {
|
|
24403
|
-
|
|
26957
|
+
const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined;
|
|
26958
|
+
const input = call.name === 'channel_publish' && call.callerAgentName
|
|
26959
|
+
? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn)
|
|
26960
|
+
: call.input;
|
|
26961
|
+
return this.channelRegistry.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin);
|
|
24404
26962
|
}
|
|
24405
26963
|
|
|
24406
26964
|
// drafts: the calling resident's own held drafts. A puppeted call has
|
|
@@ -24423,6 +26981,20 @@ export class AgentFramework {
|
|
|
24423
26981
|
// Module tools. The caller uses the result in process, so it gets the
|
|
24424
26982
|
// module's own result, as the module returned it, less a refusal it
|
|
24425
26983
|
// claims, which is the dispatcher's to say.
|
|
26984
|
+
// The tune-out tool, in process: the same body the dispatcher runs, its
|
|
26985
|
+
// answer awaited here (it can wait for the tune-out's transport).
|
|
26986
|
+
if ((call.name === 'tune_out' || call.name === 'focus') && this.tuneOutCoordinator) {
|
|
26987
|
+
const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__';
|
|
26988
|
+
const input = (call.input ?? {}) as Record<string, unknown>;
|
|
26989
|
+
// The calling turn's own channels, as the channel tools read them here (origin.own).
|
|
26990
|
+
const own = origin.kind === 'agent' ? origin.own ?? [] : [];
|
|
26991
|
+
const turn = this.callingTurnOf(agentName, call);
|
|
26992
|
+
return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn);
|
|
26993
|
+
}
|
|
26994
|
+
if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(call.name)) {
|
|
26995
|
+
const routed = this.readerToolCall(origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__', call);
|
|
26996
|
+
return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result;
|
|
26997
|
+
}
|
|
24426
26998
|
return this.moduleRegistry.handleToolCall(call);
|
|
24427
26999
|
}
|
|
24428
27000
|
|
|
@@ -24463,6 +27035,38 @@ export class AgentFramework {
|
|
|
24463
27035
|
return { ok: true, call: inner, serverId: target.serverId, prefix, ...(target.instance !== undefined ? { instance: target.instance } : {}) };
|
|
24464
27036
|
}
|
|
24465
27037
|
|
|
27038
|
+
/**
|
|
27039
|
+
* A reader reads and reports: a reader fork (registry kind
|
|
27040
|
+
* 'subconscious-fork', in every incarnation) may call only its own tools,
|
|
27041
|
+
* and speak_in_channel where allowChannelSpeech allows it. Every other
|
|
27042
|
+
* tool the resident has stays in its block (the shared prefix) and is
|
|
27043
|
+
* refused here, so with speech off a fork can post nowhere (scout's
|
|
27044
|
+
* condition, 2026-10-19). It's decided at each call from what the reader
|
|
27045
|
+
* may call, not from what it is shown, so a tool added or moved out of
|
|
27046
|
+
* the block later is refused too.
|
|
27047
|
+
*/
|
|
27048
|
+
private readerRefuses(agentName: string, toolName: string): boolean {
|
|
27049
|
+
if (this.registry.get(agentName)?.kind !== 'subconscious-fork') return false;
|
|
27050
|
+
return !this.readerMayCall(agentName, toolName);
|
|
27051
|
+
}
|
|
27052
|
+
|
|
27053
|
+
/**
|
|
27054
|
+
* What a reader fork may call: exactly its own tools and the speech its
|
|
27055
|
+
* configuration allows, never widened or narrowed by its resident's
|
|
27056
|
+
* allowedTools. The resident's list bounds the resident's own surface;
|
|
27057
|
+
* the reader's tools are the framework's, for reading on its behalf. A
|
|
27058
|
+
* denyToolsAtDispatch entry the fork takes from its resident that names
|
|
27059
|
+
* one of them still refuses it (mayCallTool checks the deny first): a
|
|
27060
|
+
* resident never holds a reader tool, so such an entry is about readers.
|
|
27061
|
+
* The fork still shows its resident's block (the shared prefix): what it
|
|
27062
|
+
* may call is decided here, at each call, apart from what it is shown.
|
|
27063
|
+
*/
|
|
27064
|
+
private readerMayCall(agentName: string, toolName: string): boolean {
|
|
27065
|
+
if (this.registry.get(agentName)?.kind !== 'subconscious-fork') return false;
|
|
27066
|
+
if (toolName === 'speak_in_channel') return this.subconsciousConfig?.allowChannelSpeech === true;
|
|
27067
|
+
return (SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(toolName);
|
|
27068
|
+
}
|
|
27069
|
+
|
|
24466
27070
|
/** A toolsets--act call a host toolset resolved (HostPresentation.resolveAct):
|
|
24467
27071
|
* its refusal answered, or the tool's own call dispatched in its place,
|
|
24468
27072
|
* with the act's id, so its result is the act's. */
|
|
@@ -26128,23 +28732,41 @@ export class AgentFramework {
|
|
|
26128
28732
|
return best;
|
|
26129
28733
|
}
|
|
26130
28734
|
|
|
26131
|
-
/** Registered policy
|
|
26132
|
-
* while
|
|
28735
|
+
/** Registered policy. Within this process, a name the framework made has no
|
|
28736
|
+
* dispatch authority while it isn't registered; after a restart, a host call names
|
|
28737
|
+
* its caller as any host call does. Genuine legacy caller names retain
|
|
28738
|
+
* their surface. */
|
|
26133
28739
|
private canAgentUseTool(agentName: string, toolName: string): boolean {
|
|
26134
28740
|
const agent = this.agents.get(agentName);
|
|
26135
28741
|
return agent ? agent.canUseTool(toolName) : !this.usedEphemeralAgentNames.has(agentName);
|
|
26136
28742
|
}
|
|
26137
28743
|
|
|
28744
|
+
/** Whether a call of `toolName` as `agentName` is refused at dispatch (AgentConfig.denyToolsAtDispatch, or a reader's own rule). */
|
|
28745
|
+
private deniedAtDispatch(agentName: string, toolName: string): boolean {
|
|
28746
|
+
return this.agents.get(agentName)?.deniesAtDispatch(toolName) === true || this.readerRefuses(agentName, toolName);
|
|
28747
|
+
}
|
|
28748
|
+
|
|
28749
|
+
/** Whether `agentName` may call `toolName`: the one rule dispatch applies (agentToolPermissionFailure), for whatever else judges a call. */
|
|
28750
|
+
private mayCallTool(agentName: string, toolName: string): boolean {
|
|
28751
|
+
return !this.deniedAtDispatch(agentName, toolName)
|
|
28752
|
+
&& (this.canAgentUseTool(agentName, toolName) || this.readerMayCall(agentName, toolName));
|
|
28753
|
+
}
|
|
28754
|
+
|
|
26138
28755
|
private agentToolPermissionFailure(agentName: string, call: ToolCall, reportRefusal = true): ToolResult | undefined {
|
|
26139
|
-
|
|
26140
|
-
|
|
26141
|
-
|
|
26142
|
-
|
|
28756
|
+
// A tool refused at dispatch is refused on every path that runs the
|
|
28757
|
+
// agent's call (a toolset's act, executeToolCall), not only the stream's.
|
|
28758
|
+
if (this.mayCallTool(agentName, call.name)) return undefined;
|
|
28759
|
+
const denied = this.deniedAtDispatch(agentName, call.name);
|
|
28760
|
+
const why = denied
|
|
28761
|
+
? `${call.name} is not available to ${agentName}`
|
|
28762
|
+
: !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName)
|
|
28763
|
+
? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered`
|
|
28764
|
+
: `Tool '${call.name}' is not permitted by this agent's allowedTools`;
|
|
26143
28765
|
// Off the dispatcher, nothing opened the call to refuse it in RFC-007's
|
|
26144
28766
|
// lifecycle, so it's a failure, not a refusal (ToolResult.refused), whose
|
|
26145
28767
|
// text says the call didn't run.
|
|
26146
28768
|
if (!reportRefusal) return { success: false, error: `${why}, so the call didn't run.`, isError: true };
|
|
26147
|
-
const error = `${why}.`;
|
|
28769
|
+
const error = denied ? why : `${why}.`;
|
|
26148
28770
|
const refusal = this.refusedResult(agentName, call.id, error);
|
|
26149
28771
|
this.emitTrace({
|
|
26150
28772
|
type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error,
|
|
@@ -26215,7 +28837,7 @@ export class AgentFramework {
|
|
|
26215
28837
|
* (`refusedHere`) counts as one the list refuses.
|
|
26216
28838
|
*/
|
|
26217
28839
|
private roundCallOf(agentName: string, call: { name: string; input?: unknown; unparseableInput?: string }, refusedHere = false): RoundCall {
|
|
26218
|
-
const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.
|
|
28840
|
+
const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.mayCallTool(agentName, call.name);
|
|
26219
28841
|
const refused = bareToolName(call.name) === 'skip_reply' && (!permitted || this.refusesSelfWake(call));
|
|
26220
28842
|
return { name: call.name, cause: permitted || refused ? silenceCauseOf([call]) : null, refused, permitted };
|
|
26221
28843
|
}
|
|
@@ -26239,6 +28861,23 @@ export class AgentFramework {
|
|
|
26239
28861
|
});
|
|
26240
28862
|
return;
|
|
26241
28863
|
}
|
|
28864
|
+
// Dispatch-time deny (AgentConfig.denyToolsAtDispatch): the tool stays
|
|
28865
|
+
// advertised so the agent's request prefix is unchanged; the call is
|
|
28866
|
+
// refused here, before anything runs.
|
|
28867
|
+
if (this.deniedAtDispatch(agentName, call.name)) {
|
|
28868
|
+
const error = `${call.name} is not available to ${agentName}`;
|
|
28869
|
+
const result = this.refusedResult(agentName, call.id, error);
|
|
28870
|
+
this.emitTrace({ type: 'tool:failed', module: 'dendrite', tool: call.name, callId: call.id, error });
|
|
28871
|
+
this.pushEvent({
|
|
28872
|
+
type: 'tool-result',
|
|
28873
|
+
callId: call.id,
|
|
28874
|
+
toolName: call.name,
|
|
28875
|
+
agentName,
|
|
28876
|
+
moduleName: 'dendrite',
|
|
28877
|
+
result,
|
|
28878
|
+
});
|
|
28879
|
+
return;
|
|
28880
|
+
}
|
|
26242
28881
|
const refusal = this.agentToolPermissionFailure(agentName, call) ?? this.markedInputFailure(agentName, call);
|
|
26243
28882
|
if (refusal) {
|
|
26244
28883
|
this.pushEvent({
|
|
@@ -26324,20 +28963,22 @@ export class AgentFramework {
|
|
|
26324
28963
|
}
|
|
26325
28964
|
|
|
26326
28965
|
// Route the tune-out tool (residents) and the subconscious's surface
|
|
26327
|
-
if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
|
|
28966
|
+
if ((enrichedCall.name === 'tune_out' || enrichedCall.name === 'focus') && this.tuneOutCoordinator) {
|
|
26328
28967
|
this.dispatchTuneOutToolCall(agentName, enrichedCall);
|
|
26329
28968
|
return;
|
|
26330
28969
|
}
|
|
26331
|
-
if (
|
|
26332
|
-
this.
|
|
26333
|
-
|
|
26334
|
-
|
|
26335
|
-
|
|
26336
|
-
|
|
26337
|
-
|
|
26338
|
-
|
|
26339
|
-
|
|
26340
|
-
|
|
28970
|
+
if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)) {
|
|
28971
|
+
const routed = this.readerToolCall(agentName, enrichedCall);
|
|
28972
|
+
if ('refused' in routed) {
|
|
28973
|
+
const error = routed.refused;
|
|
28974
|
+
const result = this.refusedResult(agentName, call.id, error);
|
|
28975
|
+
this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error });
|
|
28976
|
+
this.pushEvent({
|
|
28977
|
+
type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result,
|
|
28978
|
+
});
|
|
28979
|
+
return;
|
|
28980
|
+
}
|
|
28981
|
+
void routed.result
|
|
26341
28982
|
.then((result) => {
|
|
26342
28983
|
// Through pushEvent, like every other tool's result, so one that
|
|
26343
28984
|
// arrives after stop() is dropped rather than rejected unhandled.
|
|
@@ -26717,6 +29358,7 @@ export class AgentFramework {
|
|
|
26717
29358
|
opts?.fromQueue ? withDeferredWriteId(metadata, opts.fromQueue.id) : metadata,
|
|
26718
29359
|
);
|
|
26719
29360
|
if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; }
|
|
29361
|
+
this.releaseLandedMail(metadata, agent.name);
|
|
26720
29362
|
this.noteStored(agent, metadata, stored, opts?.bypassDeferralFor);
|
|
26721
29363
|
return stored;
|
|
26722
29364
|
}
|
|
@@ -26875,6 +29517,37 @@ export class AgentFramework {
|
|
|
26875
29517
|
this.noteForOtherResidents(agent, metadata, stored);
|
|
26876
29518
|
}
|
|
26877
29519
|
|
|
29520
|
+
/**
|
|
29521
|
+
* Held agent mail is released only when its message has actually entered
|
|
29522
|
+
* the recipient's store — never on a deferral. Called at every site that
|
|
29523
|
+
* writes a message into a context: the direct path above and the two
|
|
29524
|
+
* deferred-write flushes (turn start, tool boundary). Both writes reach
|
|
29525
|
+
* disk in the same sync, so a crash keeps either both or neither.
|
|
29526
|
+
*/
|
|
29527
|
+
private releaseLandedMail(metadata: MessageMetadata | undefined, recipient: string): void {
|
|
29528
|
+
const stamp = (metadata as { dendrite?: { mailId?: unknown; kind?: unknown; from?: { agent?: unknown } } } | undefined)?.dendrite;
|
|
29529
|
+
if (typeof stamp?.mailId !== 'string') return;
|
|
29530
|
+
// The message is in the recipient's store; the release is written only
|
|
29531
|
+
// once the store has synced it, so a hard exit between the two leaves
|
|
29532
|
+
// the mail held and the next boot's check of the recipient's store
|
|
29533
|
+
// settles it — never a release on disk for a message that isn't (nissa's
|
|
29534
|
+
// #546 review, round 2, K). A sync that fails leaves it held the same way.
|
|
29535
|
+
try {
|
|
29536
|
+
this.store.sync();
|
|
29537
|
+
} catch (error) {
|
|
29538
|
+
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);
|
|
29539
|
+
return;
|
|
29540
|
+
}
|
|
29541
|
+
this.registry.releaseMail(stamp.mailId);
|
|
29542
|
+
this.emitTrace({
|
|
29543
|
+
type: 'dendrite:mail-delivered',
|
|
29544
|
+
mailId: stamp.mailId,
|
|
29545
|
+
kind: String(stamp.kind ?? 'message'),
|
|
29546
|
+
from: String(stamp.from?.agent ?? ''),
|
|
29547
|
+
to: recipient,
|
|
29548
|
+
});
|
|
29549
|
+
}
|
|
29550
|
+
|
|
26878
29551
|
/**
|
|
26879
29552
|
* Splice out the deferred messages addressed to `agentName` (explicitly
|
|
26880
29553
|
* via forAgent, or implicitly when it is the primary), leaving other
|
|
@@ -27324,6 +29997,8 @@ export class AgentFramework {
|
|
|
27324
29997
|
const stored = this.writeDrainedInOrder(this.drainDeferredFor(agent.name), `${occasion} flush for ${agent.name}`, (msg) =>
|
|
27325
29998
|
agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
|
|
27326
29999
|
for (const [msg, { id }] of stored) {
|
|
30000
|
+
// Held mail whose message just landed is released (Dendrite).
|
|
30001
|
+
this.releaseLandedMail(msg.metadata, agent.name);
|
|
27327
30002
|
this.emitTrace({ type: 'message:added', messageId: id, source: `deferred-flush:${occasion}` });
|
|
27328
30003
|
this.noteSpokenTo(agent.name, msg.metadata);
|
|
27329
30004
|
this.noteArrival(agent.name, msg.metadata, id);
|
|
@@ -27911,7 +30586,7 @@ export class AgentFramework {
|
|
|
27911
30586
|
// target never read it (passesToPrimary); addMessage takes it off any
|
|
27912
30587
|
// other write.
|
|
27913
30588
|
const forRun = opts?.forAgent !== undefined
|
|
27914
|
-
&& (this.ephemeralRuns.has(opts.forAgent) || this.
|
|
30589
|
+
&& (this.ephemeralRuns.has(opts.forAgent) || this.registry.homeChannel(opts.forAgent) !== undefined);
|
|
27915
30590
|
const placement: { deferredId?: string } = {};
|
|
27916
30591
|
const id = this.addMessage(participant, content, forRun ? { ...metadata, [PRIMARY_IF_UNREAD_KEY]: true } as MessageMetadata : metadata, {
|
|
27917
30592
|
placement, ...(opts?.forAgent !== undefined ? { forAgent: opts.forAgent } : {}), ...(forRun ? { markedForRun: true as const } : {}),
|
|
@@ -27923,7 +30598,7 @@ export class AgentFramework {
|
|
|
27923
30598
|
* The writes the framework holds for the store `reader` reads, in queue
|
|
27924
30599
|
* order: those addressed to it and, when it's an ordinary resident, those
|
|
27925
30600
|
* addressed to any ordinary resident, since residents share one message
|
|
27926
|
-
* slot (
|
|
30601
|
+
* slot (`readsSharedSlot` in the registry). A write whose target is gone isn't among them:
|
|
27927
30602
|
* no module reads that agent's store, and the write waits for its window's
|
|
27928
30603
|
* release (drainDeferredFor) or, at its flush, is dropped (addMessage), a
|
|
27929
30604
|
* failed reply, or a channel notice its reader could act on, passing to the
|
|
@@ -28315,6 +30990,10 @@ export class AgentFramework {
|
|
|
28315
30990
|
if (typeof bounded.error === 'string') bounded.error = boundFailureText(bounded.error);
|
|
28316
30991
|
if (typeof bounded.stack === 'string') bounded.stack = boundFailureText(bounded.stack);
|
|
28317
30992
|
|
|
30993
|
+
if (this.earlyTraces) {
|
|
30994
|
+
if (this.earlyTraces.length < 1000) this.earlyTraces.push(traceEvent);
|
|
30995
|
+
else this.earlyTracesDropped++;
|
|
30996
|
+
}
|
|
28318
30997
|
// An event goes to the subscriptions present when its delivery starts,
|
|
28319
30998
|
// each one only if it is still active when its turn comes, as the DOM's
|
|
28320
30999
|
// EventTarget delivers: a subscription made during delivery waits for the
|
|
@@ -29023,10 +31702,11 @@ export class AgentFramework {
|
|
|
29023
31702
|
this.setMcplServerConfig(config);
|
|
29024
31703
|
}
|
|
29025
31704
|
|
|
29026
|
-
//
|
|
29027
|
-
//
|
|
29028
|
-
|
|
29029
|
-
|
|
31705
|
+
// The server-level veto on waking (a per-server shouldTriggerInference).
|
|
31706
|
+
// The wake gate is deliberately NOT wired into the inbound lanes any
|
|
31707
|
+
// more: it runs in the admission stage, after the hold decision, so a
|
|
31708
|
+
// held message never forms a gate batch (#232 gap 3, root cause A).
|
|
31709
|
+
const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference;
|
|
29030
31710
|
|
|
29031
31711
|
// Push events handler (Step 6)
|
|
29032
31712
|
this.installPushCoalescer(coalescing);
|
|
@@ -29081,14 +31761,28 @@ export class AgentFramework {
|
|
|
29081
31761
|
}
|
|
29082
31762
|
},
|
|
29083
31763
|
shouldTriggerInference: triggerFilter,
|
|
31764
|
+
latestInboundLocus: this.speechLocus === 'latest-inbound',
|
|
31765
|
+
// A tool close of the channel an all-except hold excepts ends that
|
|
31766
|
+
// hold through the cancel flow, since there is nothing left to speak
|
|
31767
|
+
// into. A tool close of a tuned-out channel is refused
|
|
31768
|
+
// before this hook is called (ChannelRegistry.tunedOutRefusal).
|
|
31769
|
+
onToolClose: (serverId, channelId) => {
|
|
31770
|
+
const coordinator = this.tuneOutCoordinator;
|
|
31771
|
+
if (!coordinator) return undefined;
|
|
31772
|
+
const scope = coordinator.getAllExcept();
|
|
31773
|
+
if (!scope || scope.params.except.serverId !== serverId || scope.params.except.channelId !== channelId) return undefined;
|
|
31774
|
+
coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
|
|
31775
|
+
return 'The focus on it ended all the same, and what the other channels held was delivered.';
|
|
31776
|
+
},
|
|
31777
|
+
focusChannelResolver: () => this.tuneOutCoordinator?.focusChannel() ?? null,
|
|
29084
31778
|
// Route a conversation fork's plain-text speech to its HOME channel, not
|
|
29085
31779
|
// the process-global most-recent-inbound locus (item 3). The trunk agent
|
|
29086
31780
|
// has no home entry, so this returns undefined and routeSpeech falls back
|
|
29087
|
-
// to defaultPublishChannel. `
|
|
31781
|
+
// to defaultPublishChannel. The registry's `homeChannel` is the permanent
|
|
29088
31782
|
// spawn-time binding; `channelForAgent` is the router's live binding as a
|
|
29089
31783
|
// belt-and-suspenders fallback.
|
|
29090
31784
|
homeChannelResolver: (agentName) =>
|
|
29091
|
-
this.
|
|
31785
|
+
this.homeOf(agentName)
|
|
29092
31786
|
?? this.conversationRouter?.channelForAgent(agentName),
|
|
29093
31787
|
// A single TRUNK agent's channel for its CURRENT turn (item-3 redux):
|
|
29094
31788
|
// its locus once the turn has one, a re-pin's or a channel_open's
|
|
@@ -29989,6 +32683,9 @@ export class AgentFramework {
|
|
|
29989
32683
|
*/
|
|
29990
32684
|
private makeHostPresentation(storePath: string | undefined): HostPresentation {
|
|
29991
32685
|
return new HostPresentation({
|
|
32686
|
+
// A derived agent showing its parent's tool list (toolSurfaceOwners) is
|
|
32687
|
+
// shown its parent's presentation too, so the two blocks stay one.
|
|
32688
|
+
ownerOf: (agentName) => this.toolSurfaceOwners.get(agentName) ?? agentName,
|
|
29992
32689
|
// As the agent is shown them: its description overrides applied.
|
|
29993
32690
|
available: (agentName) => (this.agents.has(agentName) ? this.compressionToolsForAgent(agentName) : this.getAllTools()),
|
|
29994
32691
|
placedInBuiltin: (name) => this.placedInBuiltin(name),
|
|
@@ -33229,7 +35926,7 @@ export class AgentFramework {
|
|
|
33229
35926
|
// ones; channel_open is rejected outright — opening channels mutates
|
|
33230
35927
|
// framework-global state (the open-channel set every agent's channel
|
|
33231
35928
|
// context is built from), which is not a fork's call to make.
|
|
33232
|
-
const home = this.
|
|
35929
|
+
const home = this.homeOf(agentName);
|
|
33233
35930
|
if (home) {
|
|
33234
35931
|
const reject = (error: string): void => {
|
|
33235
35932
|
// RFC-007: the guard refuses before the tool runs — no lifecycle events.
|
|
@@ -33333,8 +36030,11 @@ export class AgentFramework {
|
|
|
33333
36030
|
const readThrough = publisher ? this.publishReadThrough(publisher, call.id) : null;
|
|
33334
36031
|
const published: { outcome?: PublishOutcome } = {};
|
|
33335
36032
|
|
|
33336
|
-
|
|
33337
|
-
|
|
36033
|
+
const channelInput = call.name === 'channel_publish'
|
|
36034
|
+
? this.publishInputWithTurnLocus(call.input, agentName, callingTurn)
|
|
36035
|
+
: call.input;
|
|
36036
|
+
this.channelRegistry!.handleChannelToolCall(call.name, channelInput, {
|
|
36037
|
+
kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(callingTurn ? { inTurn: true } : {}), ...(earlierWake ? { earlierWake } : {}),
|
|
33338
36038
|
}, published)
|
|
33339
36039
|
.then((result) => {
|
|
33340
36040
|
const destination = published.outcome?.status === 'delivered' ? published.outcome.destination : undefined;
|
|
@@ -33382,7 +36082,7 @@ export class AgentFramework {
|
|
|
33382
36082
|
&& this.openLogical.get(agentName) === callingTurn) {
|
|
33383
36083
|
callingTurn.locus = opened;
|
|
33384
36084
|
noteTurnChannel(callingTurn, opened);
|
|
33385
|
-
this.
|
|
36085
|
+
this.rememberAnnouncedLocus(agentName, opened);
|
|
33386
36086
|
const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(opened);
|
|
33387
36087
|
// The routing word comes first, ahead of any backscroll, so a
|
|
33388
36088
|
// result cut to its head for being long keeps it.
|
|
@@ -33647,6 +36347,26 @@ export class AgentFramework {
|
|
|
33647
36347
|
}
|
|
33648
36348
|
}
|
|
33649
36349
|
|
|
36350
|
+
/**
|
|
36351
|
+
* A reader's own tool (SUBCONSCIOUS_TOOL_NAMES), for both entries that run
|
|
36352
|
+
* an agent's call: the dispatcher's, for the model's call, and
|
|
36353
|
+
* executeToolCallFrom's, for a host's or the puppet's. The tools are
|
|
36354
|
+
* advertised to residents so reader forks share their prefix, and only a
|
|
36355
|
+
* registered reader may use them. A rejection is answered with a failed
|
|
36356
|
+
* result, never left unhandled.
|
|
36357
|
+
*/
|
|
36358
|
+
private readerToolCall(agentName: string, call: ToolCall): { refused: string } | { result: Promise<ToolResult> } {
|
|
36359
|
+
const isReader = agentName === this.subconsciousAgentName || this.registry.get(agentName)?.kind === 'subconscious-fork';
|
|
36360
|
+
if (!this.tuneOutCoordinator || !isReader) return { refused: `${call.name} is available to the resident's reader, not to ${agentName}` };
|
|
36361
|
+
const epoch = this.registry.get(agentName)?.metadata?.epochId;
|
|
36362
|
+
const fork = agentName === this.subconsciousAgentName ? undefined : { epochId: typeof epoch === 'string' ? epoch : undefined };
|
|
36363
|
+
return {
|
|
36364
|
+
result: this.tuneOutCoordinator
|
|
36365
|
+
.handleSubconsciousTool(call.name, call.input as Record<string, unknown>, this.callOwnChannels(agentName, call), agentName, fork)
|
|
36366
|
+
.catch((err): ToolResult => ({ success: false, error: thrownText(err), isError: true })),
|
|
36367
|
+
};
|
|
36368
|
+
}
|
|
36369
|
+
|
|
33650
36370
|
/**
|
|
33651
36371
|
* Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
|
|
33652
36372
|
* suppression window, optionally announces in the sticky channel, and ends
|
|
@@ -33654,127 +36374,277 @@ export class AgentFramework {
|
|
|
33654
36374
|
*/
|
|
33655
36375
|
/** tune_out tool (residents): enter or cancel a channel tune-out. */
|
|
33656
36376
|
private dispatchTuneOutToolCall(agentName: string, call: ToolCall): void {
|
|
33657
|
-
const
|
|
33658
|
-
const input = (call.input ?? {}) as {
|
|
33659
|
-
channelId?: string; mode?: string;
|
|
33660
|
-
cadenceSeconds?: number; backlogCap?: number; maxWakes?: number;
|
|
33661
|
-
durationSeconds?: number;
|
|
33662
|
-
};
|
|
33663
|
-
const name = String(input.channelId ?? '');
|
|
36377
|
+
const input = (call.input ?? {}) as Record<string, unknown>;
|
|
33664
36378
|
// Inside the calling turn, a name one of the turn's own channels answers
|
|
33665
36379
|
// to by its id means that channel, as for the channel tools
|
|
33666
|
-
// (ChannelRegistry.ownChannelFor)
|
|
33667
|
-
//
|
|
33668
|
-
|
|
33669
|
-
|
|
33670
|
-
|
|
33671
|
-
|
|
33672
|
-
|
|
33673
|
-
|
|
33674
|
-
|
|
33675
|
-
|
|
33676
|
-
|
|
33677
|
-
|
|
33678
|
-
|
|
33679
|
-
|
|
33680
|
-
|
|
33681
|
-
|
|
33682
|
-
|
|
36380
|
+
// (ChannelRegistry.ownChannelFor): read here, while the call is the
|
|
36381
|
+
// turn's, before the answer can wait.
|
|
36382
|
+
const own = this.callOwnChannels(agentName, call);
|
|
36383
|
+
const turn = this.callingTurnOf(agentName, call);
|
|
36384
|
+
// The answer can wait for the tune-out's transport (openForTuneOut), so
|
|
36385
|
+
// the result goes through pushEvent when it is known, as every other
|
|
36386
|
+
// tool's does; a rejection is answered too, never left unhandled.
|
|
36387
|
+
void (call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn))
|
|
36388
|
+
.catch((err: unknown) => ({ success: false, error: thrownText(err), isError: true }))
|
|
36389
|
+
.then((result) => {
|
|
36390
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result });
|
|
36391
|
+
});
|
|
36392
|
+
}
|
|
36393
|
+
|
|
36394
|
+
/** `focus` is `tune_out` with scope "all-except". */
|
|
36395
|
+
private handleFocusTool(agentName: string, input: Record<string, unknown>, own: readonly ChannelRef[] = [], turn?: TurnRouting): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
|
|
36396
|
+
return this.handleTuneOutTool(agentName, { ...input, scope: 'all-except' }, own, turn);
|
|
36397
|
+
}
|
|
36398
|
+
|
|
36399
|
+
/**
|
|
36400
|
+
* A tune-out call's refusal for a channel it can't resolve: main's words
|
|
36401
|
+
* for a name no registered channel, and none of the calling turn's own,
|
|
36402
|
+
* answers to, and the registry's own otherwise, as for an id that is
|
|
36403
|
+
* ambiguous across servers (#232 gap 8).
|
|
36404
|
+
*/
|
|
36405
|
+
private unknownChannelWords(channelId: string, resolution: string, own: readonly ChannelRef[]): string {
|
|
36406
|
+
const known = (this.channelRegistry?.channelsNamed(channelId).length ?? 0) > 0
|
|
36407
|
+
|| own.some((c) => c.channelId === channelId);
|
|
36408
|
+
return known ? resolution : `unknown channel: ${channelId}`;
|
|
36409
|
+
}
|
|
36410
|
+
|
|
36411
|
+
/**
|
|
36412
|
+
* The tune_out tool's body. Channel resolution is server-qualified
|
|
36413
|
+
* (ChannelRegistry.resolveChannel): with a `serverId`, that server's
|
|
36414
|
+
* channel with the id; inside the calling turn, a name one of the turn's
|
|
36415
|
+
* own channels answers to by its id means that channel; otherwise the one
|
|
36416
|
+
* channel the name means, which may be server-qualified, and a name two
|
|
36417
|
+
* servers' channels answer to is refused with them as candidates (#232
|
|
36418
|
+
* gap 8). `turn` is the turn that made the call (callingTurnOf), the only
|
|
36419
|
+
* one whose speech an all-except entry moves.
|
|
36420
|
+
*/
|
|
36421
|
+
private async handleTuneOutTool(
|
|
36422
|
+
agentName: string,
|
|
36423
|
+
input: Record<string, unknown>,
|
|
36424
|
+
own: readonly ChannelRef[] = [],
|
|
36425
|
+
turn?: TurnRouting,
|
|
36426
|
+
): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
|
|
36427
|
+
const coordinator = this.tuneOutCoordinator;
|
|
36428
|
+
const channelRegistry = this.channelRegistry;
|
|
36429
|
+
if (!coordinator || !channelRegistry) {
|
|
36430
|
+
return { success: false, error: 'tune-out is not available (no MCPL channels)', isError: true };
|
|
36431
|
+
}
|
|
33683
36432
|
// An open that throws is answered too, never left unhandled.
|
|
33684
36433
|
const openThrew = (err: unknown): string =>
|
|
33685
36434
|
'The tune-out is recorded, but its messages may not reach the subconscious, since opening its channel ' +
|
|
33686
36435
|
`failed: ${thrownText(err)}`;
|
|
33687
|
-
|
|
33688
|
-
|
|
33689
|
-
|
|
33690
|
-
|
|
33691
|
-
|
|
33692
|
-
|
|
33693
|
-
|
|
33694
|
-
|
|
36436
|
+
const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined;
|
|
36437
|
+
const allExcept = input.scope === 'all-except';
|
|
36438
|
+
|
|
36439
|
+
if (input.mode === 'check') {
|
|
36440
|
+
// A peek's name means what the other modes' names mean: the one
|
|
36441
|
+
// channel it picks, by its id or the server-qualified name the agent
|
|
36442
|
+
// is shown where another server shares the id, or inside the turn one
|
|
36443
|
+
// of the turn's own channels (resolveChannel), and its lines are that
|
|
36444
|
+
// channel's, on its server.
|
|
36445
|
+
let peek: ChannelRef | undefined;
|
|
36446
|
+
if (typeof input.peek === 'string' && input.peek) {
|
|
36447
|
+
const resolved = channelRegistry.resolveChannel(input.peek, serverId, own);
|
|
36448
|
+
if ('error' in resolved) return { success: false, error: this.unknownChannelWords(input.peek, resolved.error, own), isError: true };
|
|
36449
|
+
peek = { serverId: resolved.serverId, channelId: resolved.channelId };
|
|
36450
|
+
}
|
|
36451
|
+
// Read as the other options are (readOptions): a numeric string is its
|
|
36452
|
+
// number, and a value that gives none is refused, named.
|
|
36453
|
+
const peekLimit = input.peekLimit == null ? undefined : Number(input.peekLimit);
|
|
36454
|
+
if (peekLimit !== undefined && !Number.isFinite(peekLimit)) {
|
|
36455
|
+
const got = typeof input.peekLimit === 'string' ? `the string ${JSON.stringify(input.peekLimit)}` : `the value ${JSON.stringify(input.peekLimit)}`;
|
|
36456
|
+
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 };
|
|
36457
|
+
}
|
|
36458
|
+
return { success: true, data: coordinator.check({ ...(peek ? { peek } : {}), ...(peekLimit !== undefined ? { peekLimit } : {}) }) };
|
|
36459
|
+
}
|
|
36460
|
+
if (input.mode === 'cancel' && (allExcept || (!input.channelId && coordinator.getAllExcept()))) {
|
|
36461
|
+
const r = coordinator.cancelAllExcept('agent-tool', 'agent request');
|
|
36462
|
+
return r.ok ? { success: true, data: { cancelled: 'all-except' } } : { success: false, error: r.error, isError: true };
|
|
36463
|
+
}
|
|
36464
|
+
|
|
36465
|
+
const channelId = String(input.channelId ?? '');
|
|
36466
|
+
const resolved = channelRegistry.resolveChannel(channelId, serverId, own);
|
|
36467
|
+
if ('error' in resolved) return { success: false, error: this.unknownChannelWords(channelId, resolved.error, own), isError: true };
|
|
36468
|
+
// The name the agent knows the channel by: its id, or where another
|
|
36469
|
+
// server shares the id, its server-qualified name. An answer that waits
|
|
36470
|
+
// for the transport reads it again then, since a registration meanwhile
|
|
36471
|
+
// can change which name picks the channel.
|
|
36472
|
+
const shownNow = (): string => channelRegistry.nameShown(resolved);
|
|
36473
|
+
if (input.mode === 'cancel') {
|
|
36474
|
+
const r = coordinator.cancel(resolved.serverId, resolved.channelId, 'agent-tool', 'agent request');
|
|
36475
|
+
return r.ok
|
|
36476
|
+
? { success: true, data: { cancelled: shownNow() } }
|
|
33695
36477
|
: { success: false, error: r.error, isError: true };
|
|
33696
|
-
}
|
|
33697
|
-
|
|
36478
|
+
}
|
|
36479
|
+
if (allExcept) {
|
|
36480
|
+
const r = coordinator.enterAllExcept(resolved, {
|
|
33698
36481
|
cadenceSeconds: input.cadenceSeconds,
|
|
33699
36482
|
backlogCap: input.backlogCap,
|
|
33700
36483
|
maxWakes: input.maxWakes,
|
|
33701
36484
|
durationSeconds: input.durationSeconds,
|
|
33702
36485
|
}, 'agent-tool');
|
|
33703
|
-
if (r.ok) {
|
|
33704
|
-
|
|
36486
|
+
if (!r.ok) return { success: false, error: r.error, isError: true };
|
|
36487
|
+
const speaker = this.agents.get(agentName);
|
|
36488
|
+
const home = this.homeOf(agentName);
|
|
36489
|
+
const open = turn && this.openLogical.get(agentName) === turn ? turn : undefined;
|
|
36490
|
+
if (open && open.locus == null && !open.noChannel && !open.heartbeat && !home
|
|
36491
|
+
&& (speaker?.proseRouting === 'locus' || speaker?.proseRouting === 'hybrid')) {
|
|
36492
|
+
open.locus = resolved;
|
|
36493
|
+
noteTurnChannel(open, resolved);
|
|
36494
|
+
this.rememberAnnouncedLocus(agentName, resolved);
|
|
36495
|
+
}
|
|
36496
|
+
const speech = home
|
|
36497
|
+
? `Your plain speech stays in ${this.channelShown(home)}, your conversation's channel.`
|
|
36498
|
+
: open?.locus && !sameChannel(open.locus, resolved)
|
|
36499
|
+
? `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.`
|
|
36500
|
+
: open && (open.noChannel || open.heartbeat)
|
|
36501
|
+
? "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."
|
|
36502
|
+
: "Your plain speech lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's.";
|
|
36503
|
+
return {
|
|
36504
|
+
success: true,
|
|
36505
|
+
data: {
|
|
36506
|
+
scope: 'all-except',
|
|
36507
|
+
except: shownNow(),
|
|
36508
|
+
announcedIn: coordinator.announces() ? shownNow() : null,
|
|
36509
|
+
expiresAtMs: r.params.expiresAtMs,
|
|
33705
36510
|
cadenceSeconds: r.params.cadenceSeconds,
|
|
33706
36511
|
backlogCap: r.params.backlogCap,
|
|
33707
36512
|
maxWakes: r.params.maxWakes,
|
|
33708
|
-
|
|
33709
|
-
|
|
33710
|
-
|
|
33711
|
-
|
|
33712
|
-
|
|
33713
|
-
|
|
33714
|
-
|
|
33715
|
-
|
|
33716
|
-
|
|
33717
|
-
|
|
33718
|
-
|
|
33719
|
-
|
|
33720
|
-
|
|
33721
|
-
type: 'tool-result',
|
|
33722
|
-
callId: call.id,
|
|
33723
|
-
toolName: call.name,
|
|
33724
|
-
agentName,
|
|
33725
|
-
moduleName: 'tune-out',
|
|
33726
|
-
result: { success: true, data: { tunedOut: shownNow(), ...data, ...(openError ? { openError } : {}) } },
|
|
33727
|
-
});
|
|
33728
|
-
});
|
|
33729
|
-
return;
|
|
33730
|
-
}
|
|
36513
|
+
note: coordinator.hasReader()
|
|
36514
|
+
? `${speech} Reader wakes pause, not cancel, when the budget is spent.`
|
|
36515
|
+
: `No subconscious is configured: this is a silent hold. ${speech} The backlog is delivered per channel when you cancel or the duration ends.`,
|
|
36516
|
+
},
|
|
36517
|
+
};
|
|
36518
|
+
}
|
|
36519
|
+
const r = coordinator.enter(resolved.serverId, resolved.channelId, {
|
|
36520
|
+
cadenceSeconds: input.cadenceSeconds,
|
|
36521
|
+
backlogCap: input.backlogCap,
|
|
36522
|
+
maxWakes: input.maxWakes,
|
|
36523
|
+
durationSeconds: input.durationSeconds,
|
|
36524
|
+
}, 'agent-tool');
|
|
36525
|
+
if (!r.ok) {
|
|
33731
36526
|
// tune_out repeated on a channel already tuned out is where an agent
|
|
33732
36527
|
// retries a transport the first open left closed, so where it isn't
|
|
33733
36528
|
// open, or work is queued for it, the repeat goes through
|
|
33734
36529
|
// openForTuneOut, which joins the open in flight or waits and, where
|
|
33735
36530
|
// this host can open the channel, opens it again; the refusal says
|
|
33736
36531
|
// what its transport is now.
|
|
33737
|
-
const tuneOut =
|
|
33738
|
-
if (r.alreadyTunedOut && tuneOut && !
|
|
33739
|
-
|
|
33740
|
-
|
|
33741
|
-
|
|
33742
|
-
|
|
33743
|
-
|
|
33744
|
-
|
|
33745
|
-
|
|
33746
|
-
|
|
33747
|
-
|
|
33748
|
-
|
|
33749
|
-
|
|
33750
|
-
|
|
33751
|
-
|
|
33752
|
-
|
|
33753
|
-
|
|
33754
|
-
|
|
33755
|
-
|
|
33756
|
-
|
|
33757
|
-
|
|
33758
|
-
|
|
33759
|
-
|
|
33760
|
-
|
|
33761
|
-
|
|
33762
|
-
|
|
33763
|
-
|
|
33764
|
-
|
|
33765
|
-
|
|
33766
|
-
|
|
33767
|
-
|
|
33768
|
-
|
|
33769
|
-
|
|
36532
|
+
const tuneOut = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId);
|
|
36533
|
+
if (r.alreadyTunedOut && tuneOut && !channelRegistry.tuneOutTransportOpen(resolved.serverId, resolved.channelId)) {
|
|
36534
|
+
const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew);
|
|
36535
|
+
// The tune-out is read again: one that ended or was replaced while
|
|
36536
|
+
// the open was out isn't described as standing, whatever the open
|
|
36537
|
+
// answered for it.
|
|
36538
|
+
const now = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId);
|
|
36539
|
+
const error = now?.params.epochId === tuneOut.params.epochId
|
|
36540
|
+
// A name in a sentence is quoted as tune_out takes it back (main's #852).
|
|
36541
|
+
? alreadyTunedOutText(channelRegistry.quotedShown(resolved), now.params,
|
|
36542
|
+
openError ?? 'Its transport is open now, so its messages reach the subconscious.')
|
|
36543
|
+
: TUNE_OUT_REPLACED;
|
|
36544
|
+
return { success: false, error, isError: true };
|
|
36545
|
+
}
|
|
36546
|
+
return { success: false, error: r.error, isError: true };
|
|
36547
|
+
}
|
|
36548
|
+
// A tuned-out channel's traffic must keep arriving for its reader, so the
|
|
36549
|
+
// answer waits for its transport (openForTuneOut). The tune-out is
|
|
36550
|
+
// recorded either way, so it's a success, and where the transport isn't
|
|
36551
|
+
// open, openError says why its messages don't or may not reach the
|
|
36552
|
+
// reader, and when they will where that's known, or that the tune-out
|
|
36553
|
+
// isn't in effect any more.
|
|
36554
|
+
const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew);
|
|
36555
|
+
return {
|
|
36556
|
+
success: true,
|
|
36557
|
+
data: {
|
|
36558
|
+
tunedOut: shownNow(),
|
|
36559
|
+
cadenceSeconds: r.params.cadenceSeconds,
|
|
36560
|
+
backlogCap: r.params.backlogCap,
|
|
36561
|
+
maxWakes: r.params.maxWakes,
|
|
36562
|
+
...(openError ? { openError } : {}),
|
|
36563
|
+
...(coordinator.hasReader()
|
|
36564
|
+
? {}
|
|
36565
|
+
: { note: 'No subconscious is configured: this is a silent hold. Nothing summarizes or wakes you; the backlog is delivered when you cancel.' }),
|
|
36566
|
+
},
|
|
36567
|
+
};
|
|
36568
|
+
}
|
|
36569
|
+
|
|
36570
|
+
/**
|
|
36571
|
+
* The admission stage for one inbound event: the pure decision over the
|
|
36572
|
+
* framework's policy inputs (recipients, the hold in force, the wake
|
|
36573
|
+
* gate), traced. Callers apply it; nothing downstream re-asks.
|
|
36574
|
+
*/
|
|
36575
|
+
private admitInbound(env: InboundEnvelope): AdmissionDecision {
|
|
36576
|
+
const gateInfo = (e: InboundEnvelope) => ({
|
|
36577
|
+
content: e.gateContent,
|
|
36578
|
+
eventType: String(e.gateMetadata.eventType ?? 'unknown'),
|
|
36579
|
+
serverId: String(e.gateMetadata.serverId ?? e.serverId),
|
|
36580
|
+
channelId: typeof e.gateMetadata.channelId === 'string' ? e.gateMetadata.channelId : '',
|
|
36581
|
+
metadata: e.gateMetadata,
|
|
36582
|
+
tags: Array.isArray(e.gateMetadata.tags) ? (e.gateMetadata.tags as string[]) : undefined,
|
|
36583
|
+
});
|
|
36584
|
+
const decision = admit(env, {
|
|
36585
|
+
untargetedRecipients: () => this.registry.untargetedRecipients(),
|
|
36586
|
+
holdFor: (serverId, channelId) => {
|
|
36587
|
+
const hold = this.tuneOutCoordinator?.holdFor(serverId, channelId) ?? null;
|
|
36588
|
+
return hold ? { kind: 'tune-out', epochId: hold.epochId, serverId, channelId } : null;
|
|
36589
|
+
},
|
|
36590
|
+
...(this.eventGate
|
|
36591
|
+
? {
|
|
36592
|
+
gate: {
|
|
36593
|
+
evaluate: (e: InboundEnvelope) => this.eventGate!.evaluate(gateInfo(e)).trigger,
|
|
36594
|
+
// "Would the gate have woken them": a debounce policy says yes,
|
|
36595
|
+
// later — for the hold's reader that is a pass, not a mute.
|
|
36596
|
+
probe: (e: InboundEnvelope) => {
|
|
36597
|
+
const d = this.eventGate!.probe(gateInfo(e));
|
|
36598
|
+
return d.trigger || (typeof d.behavior === 'object' && d.behavior !== null && 'debounce' in d.behavior);
|
|
36599
|
+
},
|
|
36600
|
+
},
|
|
36601
|
+
}
|
|
36602
|
+
: {}),
|
|
33770
36603
|
});
|
|
36604
|
+
this.emitTrace({
|
|
36605
|
+
type: 'admission:decided',
|
|
36606
|
+
lane: env.lane,
|
|
36607
|
+
serverId: env.serverId,
|
|
36608
|
+
channelId: env.channelId,
|
|
36609
|
+
messageId: env.messageId,
|
|
36610
|
+
recipients: decision.recipients,
|
|
36611
|
+
held: decision.held ? decision.held.epochId : null,
|
|
36612
|
+
wake: decision.wake,
|
|
36613
|
+
gatePass: decision.gatePass,
|
|
36614
|
+
});
|
|
36615
|
+
return decision;
|
|
36616
|
+
}
|
|
36617
|
+
|
|
36618
|
+
/**
|
|
36619
|
+
* Make delivered messages durable now, including those the delivery left
|
|
36620
|
+
* in the deferred queue: a tune-out release flips durable epoch state
|
|
36621
|
+
* first, so its dump must not be lost to a crash in between (#232 gap 6).
|
|
36622
|
+
*/
|
|
36623
|
+
private commitDeliveries(): void {
|
|
36624
|
+
if (this.deferredMessages.length > 0) {
|
|
36625
|
+
this.deferredWritesPersisted = true;
|
|
36626
|
+
this.persistDeferredWrites();
|
|
36627
|
+
}
|
|
36628
|
+
try {
|
|
36629
|
+
this.store.sync();
|
|
36630
|
+
} catch (err) {
|
|
36631
|
+
console.error('[tune-out] could not sync the store after a release:', err);
|
|
36632
|
+
}
|
|
36633
|
+
}
|
|
36634
|
+
|
|
36635
|
+
/** The history-reading tool the primary resident actually has, if any (#232 gap 11). */
|
|
36636
|
+
private residentHistoryTool(): string | null {
|
|
36637
|
+
const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined;
|
|
36638
|
+
if (!primary) return null;
|
|
36639
|
+
const names = this.getAllTools().map((t) => t.name).filter((n) => primary.canUseTool(n));
|
|
36640
|
+
return names.find((n) => /(^|--)(fetch_history|channel_read|read_history|history)$/.test(n)) ?? null;
|
|
33771
36641
|
}
|
|
33772
36642
|
|
|
33773
36643
|
private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
|
|
33774
36644
|
// The gate's sleep quiets the whole agent, every conversation of it, so a
|
|
33775
36645
|
// conversation fork or an ephemeral run neither sets one nor ends the
|
|
33776
36646
|
// agent's own. Refused before the tool runs, as a fork's channel_open is.
|
|
33777
|
-
const fork = this.
|
|
36647
|
+
const fork = this.registry.homeChannel(agentName) !== undefined;
|
|
33778
36648
|
if (fork || this.ephemeralRuns.has(agentName)) {
|
|
33779
36649
|
const one = fork ? 'conversation' : 'run';
|
|
33780
36650
|
// Only where the fork can follow it: skip_reply needs the channel
|